Skip to main content

faucet_core/observability/
decorator.rs

1//! Pipeline-internal decorators that emit spans + metrics around every
2//! source / sink trait call. See the design spec for the full vocabulary.
3
4use crate::error::FaucetError;
5use crate::observability::labels::Labels;
6use crate::observability::timer::DurationGuard;
7use crate::pipeline::StreamPage;
8use crate::traits::{Sink, Source};
9use async_trait::async_trait;
10use futures::FutureExt;
11use futures_core::Stream;
12use metrics::{Label, SharedString, counter, gauge};
13use serde_json::Value;
14use std::collections::HashMap;
15use std::panic::AssertUnwindSafe;
16use std::pin::Pin;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use tracing::{Instrument, info_span};
20
21/// Guard an inner connector's `connector_name()` so an empty string maps to
22/// the `"unknown"` fallback. Used both for the `connector` metric label and the
23/// `connector_name()` passthrough so the two never disagree.
24fn guarded_connector_name(raw: &'static str) -> &'static str {
25    if raw.is_empty() { "unknown" } else { raw }
26}
27
28/// Build the base `pipeline` / `row` / `connector` label vec once. The two
29/// `pipeline` / `row` heap allocations and the vec construction happen a single
30/// time at decorator construction; per-call sites `clone()` this instead of
31/// rebuilding from the `Arc<str>` labels on every page / write / flush.
32fn base_metric_labels(labels: &Labels, connector: &SharedString) -> Vec<Label> {
33    vec![
34        Label::new("pipeline", SharedString::from(labels.pipeline.to_string())),
35        Label::new("row", SharedString::from(labels.row.to_string())),
36        Label::new("connector", connector.clone()),
37    ]
38}
39
40/// Wraps a `&dyn Source` (or any `&S: Source`) and emits spans + metrics
41/// around every call. Constructed by `Pipeline::run` and never exposed to
42/// end users; the wrapped source remains the user-facing object.
43pub struct InstrumentedSource<'a, S: Source + ?Sized> {
44    inner: &'a S,
45    labels: Labels,
46    connector: SharedString,
47    /// Precomputed `pipeline` / `row` / `connector` labels, cloned per call.
48    base_labels: Vec<Label>,
49    page_index: Arc<AtomicUsize>,
50}
51
52impl<'a, S: Source + ?Sized> InstrumentedSource<'a, S> {
53    pub fn new(inner: &'a S, labels: Labels) -> Self {
54        let raw = inner.connector_name();
55        debug_assert!(
56            !raw.is_empty(),
57            "connector_name() must return a non-empty string"
58        );
59        let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
60        let base_labels = base_metric_labels(&labels, &connector);
61        Self {
62            inner,
63            labels,
64            connector,
65            base_labels,
66            page_index: Arc::new(AtomicUsize::new(0)),
67        }
68    }
69
70    fn metric_labels(&self) -> Vec<Label> {
71        self.base_labels.clone()
72    }
73
74    /// Returns `metric_labels()` with an additional `kind` label appended.
75    /// Used by `InstrumentedSink::write_batch` (Task 9) and any future
76    /// instrumentation paths where `self` is in scope.
77    #[allow(dead_code)]
78    fn error_labels(&self, kind: &'static str) -> Vec<Label> {
79        let mut l = self.metric_labels();
80        l.push(Label::new("kind", SharedString::const_str(kind)));
81        l
82    }
83}
84
85#[async_trait]
86impl<'a, S: Source + ?Sized> Source for InstrumentedSource<'a, S> {
87    fn connector_name(&self) -> &'static str {
88        // Return the guarded name so an inner connector that returns "" maps to
89        // the "unknown" fallback — keeping this passthrough consistent with the
90        // `connector` metric label rather than leaking an empty string.
91        guarded_connector_name(self.inner.connector_name())
92    }
93
94    fn state_key(&self) -> Option<String> {
95        self.inner.state_key()
96    }
97
98    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
99        self.inner.apply_start_bookmark(bookmark).await
100    }
101
102    async fn fetch_with_context(
103        &self,
104        context: &HashMap<String, Value>,
105    ) -> Result<Vec<Value>, FaucetError> {
106        // Library-call path; the pipeline drives through stream_pages.
107        self.inner.fetch_with_context(context).await
108    }
109
110    async fn fetch_with_context_incremental(
111        &self,
112        context: &HashMap<String, Value>,
113    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
114        self.inner.fetch_with_context_incremental(context).await
115    }
116
117    fn stream_pages<'b>(
118        &'b self,
119        context: &'b HashMap<String, Value>,
120        batch_size: usize,
121    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'b>> {
122        let inner_stream = self.inner.stream_pages(context, batch_size);
123        let labels = self.labels.clone();
124        let connector = self.connector.clone();
125        let page_index = Arc::clone(&self.page_index);
126        let metric_labels = self.metric_labels();
127        let pipeline = self.labels.pipeline.clone();
128        let row = self.labels.row.clone();
129
130        Box::pin(async_stream::try_stream! {
131            // In-flight gauge tracks open streams. Decrement on drop so
132            // cancellation leaves the gauge consistent.
133            struct InFlightGuard(Vec<Label>);
134            impl Drop for InFlightGuard {
135                fn drop(&mut self) {
136                    gauge!("faucet_source_in_flight", self.0.clone()).decrement(1.0);
137                }
138            }
139            gauge!("faucet_source_in_flight", metric_labels.clone()).increment(1.0);
140            let _in_flight = InFlightGuard(metric_labels.clone());
141
142            let mut inner = inner_stream;
143            loop {
144                let idx = page_index.fetch_add(1, Ordering::Relaxed);
145                let span = info_span!(
146                    "faucet.source.page",
147                    pipeline = %pipeline,
148                    row = %row,
149                    run_id = %labels.run_id,
150                    connector = %connector,
151                    page_index = idx,
152                );
153                // Armed across the poll so a cancelled / panicking page-fetch
154                // still records the time spent. Disarmed on the terminal empty
155                // poll (`Ok(None)`) so end-of-stream doesn't record a spurious
156                // ~0 sample into the page-duration histogram.
157                let mut _timer = DurationGuard::new(
158                    "faucet_source_page_duration_seconds",
159                    metric_labels.clone(),
160                );
161
162                let next = AssertUnwindSafe(async {
163                    use futures::StreamExt;
164                    inner.next().await
165                })
166                .catch_unwind()
167                .instrument(span)
168                .await;
169
170                match next {
171                    Ok(Some(Ok(page))) => {
172                        counter!("faucet_source_pages_total", metric_labels.clone()).increment(1);
173                        counter!("faucet_source_records_total", metric_labels.clone())
174                            .increment(page.records.len() as u64);
175                        yield page;
176                    }
177                    Ok(Some(Err(e))) => {
178                        let mut l = metric_labels.clone();
179                        l.push(Label::new("kind", SharedString::const_str(error_kind(&e))));
180                        counter!("faucet_source_errors_total", l).increment(1);
181                        Err(e)?;
182                    }
183                    Ok(None) => {
184                        _timer.disarm();
185                        break;
186                    }
187                    Err(panic) => {
188                        let mut l = metric_labels.clone();
189                        l.push(Label::new("kind", SharedString::const_str("Panic")));
190                        counter!("faucet_source_errors_total", l).increment(1);
191                        let msg = panic.downcast_ref::<&'static str>().map(|s| (*s).to_string())
192                            .or_else(|| panic.downcast_ref::<String>().cloned())
193                            .unwrap_or_else(|| "<non-string panic payload>".to_string());
194                        Err(FaucetError::Custom(format!("panic in source: {msg}").into()))?;
195                    }
196                }
197            }
198        })
199    }
200}
201
202/// Map a `FaucetError` variant to its stable `kind` label value. The match
203/// must be exhaustive; update when new variants are added.
204pub(crate) fn error_kind(e: &FaucetError) -> &'static str {
205    match e {
206        FaucetError::Http(_) => "Http",
207        FaucetError::HttpStatus { .. } => "HttpStatus",
208        FaucetError::Json(_) => "Json",
209        FaucetError::JsonPath(_) => "JsonPath",
210        FaucetError::Auth(_) => "Auth",
211        FaucetError::RateLimited { .. } => "RateLimited",
212        FaucetError::Url(_) => "Url",
213        FaucetError::Transform(_) => "Transform",
214        FaucetError::Config(_) => "Config",
215        FaucetError::Source(_) => "Source",
216        FaucetError::Sink(_) => "Sink",
217        FaucetError::QualityFailure { .. } => "QualityFailure",
218        FaucetError::SchemaDrift { .. } => "SchemaDrift",
219        FaucetError::State(_) => "State",
220        FaucetError::CircuitOpen { .. } => "CircuitOpen",
221        FaucetError::Custom(_) => "Custom",
222    }
223}
224
225/// Wraps a `&dyn Sink` (or any `&S: Sink`) and emits spans + metrics around
226/// `write_batch` and `flush`. Constructed by `Pipeline::run`.
227pub struct InstrumentedSink<'a, S: Sink + ?Sized> {
228    inner: &'a S,
229    labels: Labels,
230    connector: SharedString,
231    /// Precomputed `pipeline` / `row` / `connector` labels, cloned per call.
232    base_labels: Vec<Label>,
233}
234
235impl<'a, S: Sink + ?Sized> InstrumentedSink<'a, S> {
236    pub fn new(inner: &'a S, labels: Labels) -> Self {
237        let raw = inner.connector_name();
238        debug_assert!(
239            !raw.is_empty(),
240            "connector_name() must return a non-empty string"
241        );
242        let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
243        let base_labels = base_metric_labels(&labels, &connector);
244        Self {
245            inner,
246            labels,
247            connector,
248            base_labels,
249        }
250    }
251
252    fn metric_labels(&self) -> Vec<Label> {
253        self.base_labels.clone()
254    }
255
256    fn error_labels(&self, kind: &'static str) -> Vec<Label> {
257        let mut l = self.metric_labels();
258        l.push(Label::new("kind", SharedString::const_str(kind)));
259        l
260    }
261}
262
263#[async_trait]
264impl<'a, S: Sink + ?Sized> Sink for InstrumentedSink<'a, S> {
265    fn connector_name(&self) -> &'static str {
266        // Return the guarded name so an inner connector that returns "" maps to
267        // the "unknown" fallback — keeping this passthrough consistent with the
268        // `connector` metric label rather than leaking an empty string.
269        guarded_connector_name(self.inner.connector_name())
270    }
271
272    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
273        let span = info_span!(
274            "faucet.sink.write",
275            pipeline = %self.labels.pipeline,
276            row = %self.labels.row,
277            run_id = %self.labels.run_id,
278            connector = %self.connector,
279            records = records.len(),
280        );
281        let metric_labels = self.metric_labels();
282        gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
283
284        // RAII guard ensures the gauge is decremented even if write_batch
285        // panics or the future is cancelled.
286        struct InFlightGuard(Vec<Label>);
287        impl Drop for InFlightGuard {
288            fn drop(&mut self) {
289                gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
290            }
291        }
292        let _in_flight = InFlightGuard(metric_labels.clone());
293
294        let _timer =
295            DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
296
297        let result = AssertUnwindSafe(self.inner.write_batch(records))
298            .catch_unwind()
299            .instrument(span)
300            .await;
301
302        match result {
303            Ok(Ok(n)) => {
304                counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
305                counter!("faucet_sink_records_total", metric_labels.clone()).increment(n as u64);
306                Ok(n)
307            }
308            Ok(Err(e)) => {
309                counter!(
310                    "faucet_sink_errors_total",
311                    self.error_labels(error_kind(&e))
312                )
313                .increment(1);
314                Err(e)
315            }
316            Err(panic) => {
317                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
318                let msg = panic
319                    .downcast_ref::<&'static str>()
320                    .map(|s| (*s).to_string())
321                    .or_else(|| panic.downcast_ref::<String>().cloned())
322                    .unwrap_or_else(|| "<non-string panic payload>".to_string());
323                Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
324            }
325        }
326    }
327
328    async fn write_batch_partial(
329        &self,
330        records: &[Value],
331    ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
332        let span = info_span!(
333            "faucet.sink.write_partial",
334            pipeline = %self.labels.pipeline,
335            row = %self.labels.row,
336            run_id = %self.labels.run_id,
337            connector = %self.connector,
338            records = records.len(),
339        );
340        let metric_labels = self.metric_labels();
341        gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
342
343        // RAII guard ensures the gauge is decremented even if write_batch_partial
344        // panics or the future is cancelled.
345        struct InFlightGuard(Vec<Label>);
346        impl Drop for InFlightGuard {
347            fn drop(&mut self) {
348                gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
349            }
350        }
351        let _in_flight = InFlightGuard(metric_labels.clone());
352
353        let _timer =
354            DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
355
356        let result = AssertUnwindSafe(self.inner.write_batch_partial(records))
357            .catch_unwind()
358            .instrument(span)
359            .await;
360
361        match result {
362            Ok(Ok(outcomes)) => {
363                let success_count = outcomes.iter().filter(|o| o.is_ok()).count();
364                counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
365                counter!("faucet_sink_records_total", metric_labels.clone())
366                    .increment(success_count as u64);
367                Ok(outcomes)
368            }
369            Ok(Err(e)) => {
370                counter!(
371                    "faucet_sink_errors_total",
372                    self.error_labels(error_kind(&e))
373                )
374                .increment(1);
375                Err(e)
376            }
377            Err(panic) => {
378                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
379                let msg = panic
380                    .downcast_ref::<&'static str>()
381                    .map(|s| (*s).to_string())
382                    .or_else(|| panic.downcast_ref::<String>().cloned())
383                    .unwrap_or_else(|| "<non-string panic payload>".to_string());
384                Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
385            }
386        }
387    }
388
389    async fn flush(&self) -> Result<(), FaucetError> {
390        let span = info_span!(
391            "faucet.sink.flush",
392            pipeline = %self.labels.pipeline,
393            row = %self.labels.row,
394            run_id = %self.labels.run_id,
395            connector = %self.connector,
396        );
397        let metric_labels = self.metric_labels();
398        let _timer =
399            DurationGuard::new("faucet_sink_flush_duration_seconds", metric_labels.clone());
400
401        let result = AssertUnwindSafe(self.inner.flush())
402            .catch_unwind()
403            .instrument(span)
404            .await;
405
406        match result {
407            Ok(Ok(())) => Ok(()),
408            Ok(Err(e)) => {
409                counter!(
410                    "faucet_sink_errors_total",
411                    self.error_labels(error_kind(&e))
412                )
413                .increment(1);
414                Err(e)
415            }
416            Err(panic) => {
417                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
418                let msg = panic
419                    .downcast_ref::<&'static str>()
420                    .map(|s| (*s).to_string())
421                    .or_else(|| panic.downcast_ref::<String>().cloned())
422                    .unwrap_or_else(|| "<non-string panic payload>".to_string());
423                Err(FaucetError::Custom(format!("panic in flush: {msg}").into()))
424            }
425        }
426    }
427
428    // ── Non-instrumented passthroughs ────────────────────────────────────────
429    // These carry no per-call metric/span of their own, but they MUST delegate
430    // to the inner sink — the `Sink` trait gives each a default that disables
431    // the corresponding feature (schema-drift, upsert, exactly-once). Because
432    // the pipeline drives the *wrapped* sink, failing to forward them silently
433    // makes those features inert through the entire CLI/observability path.
434
435    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
436        self.inner.current_schema().await
437    }
438
439    fn supports_schema_evolution(&self) -> bool {
440        self.inner.supports_schema_evolution()
441    }
442
443    async fn evolve_schema(
444        &self,
445        evolution: &crate::drift::SchemaEvolution,
446    ) -> Result<(), FaucetError> {
447        self.inner.evolve_schema(evolution).await
448    }
449
450    fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
451        self.inner.supported_write_modes()
452    }
453
454    fn supports_idempotent_writes(&self) -> bool {
455        self.inner.supports_idempotent_writes()
456    }
457
458    async fn write_batch_idempotent(
459        &self,
460        records: &[Value],
461        scope: &str,
462        token: &str,
463    ) -> Result<usize, FaucetError> {
464        self.inner
465            .write_batch_idempotent(records, scope, token)
466            .await
467    }
468
469    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
470        self.inner.last_committed_token(scope).await
471    }
472}
473
474#[cfg(test)]
475pub(crate) mod source_tests {
476    use super::*;
477    use async_trait::async_trait;
478    use futures::StreamExt;
479    use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshotter};
480    use serde_json::json;
481    use std::sync::{Mutex, OnceLock};
482
483    // Process-global recorder shared across all observability tests in this
484    // crate. Task 5 established the same pattern.
485    pub(crate) static LOCK: Mutex<()> = Mutex::new(());
486    static SNAPSHOTTER: OnceLock<Snapshotter> = OnceLock::new();
487
488    pub(crate) fn snapshotter() -> &'static Snapshotter {
489        SNAPSHOTTER.get_or_init(|| {
490            let recorder = DebuggingRecorder::new();
491            let snap = recorder.snapshotter();
492            // First test installs; the OnceLock guarantees we never install
493            // twice. If something else (e.g. the timer test) already installed
494            // a recorder, `set_global_recorder` will Err — but in that case
495            // *our* snapshotter is disconnected from the live recorder. The
496            // workaround is for all observability tests to share one source of
497            // truth — this file. If a future test elsewhere installs a
498            // recorder first, restructure so all tests share this OnceLock.
499            let _ = metrics::set_global_recorder(recorder);
500            snap
501        })
502    }
503
504    pub(in crate::observability) fn labels() -> Labels {
505        Labels::new("p", "r", "rid")
506    }
507
508    struct MockSource(Vec<Value>);
509    #[async_trait]
510    impl Source for MockSource {
511        async fn fetch_with_context(
512            &self,
513            _: &HashMap<String, Value>,
514        ) -> Result<Vec<Value>, FaucetError> {
515            Ok(self.0.clone())
516        }
517        fn connector_name(&self) -> &'static str {
518            "mock"
519        }
520    }
521
522    struct PanickingSource;
523    #[async_trait]
524    impl Source for PanickingSource {
525        async fn fetch_with_context(
526            &self,
527            _: &HashMap<String, Value>,
528        ) -> Result<Vec<Value>, FaucetError> {
529            panic!("kaboom")
530        }
531        fn connector_name(&self) -> &'static str {
532            "panic-test"
533        }
534    }
535
536    // Inner connector that returns an empty name. The instrumented wrapper must
537    // map this to the `"unknown"` fallback so the `connector_name()` passthrough
538    // never disagrees with the `connector` metric label.
539    struct EmptyNameSource;
540    #[async_trait]
541    impl Source for EmptyNameSource {
542        async fn fetch_with_context(
543            &self,
544            _: &HashMap<String, Value>,
545        ) -> Result<Vec<Value>, FaucetError> {
546            Ok(vec![])
547        }
548        fn connector_name(&self) -> &'static str {
549            ""
550        }
551    }
552
553    #[test]
554    fn empty_inner_connector_name_falls_back_to_unknown() {
555        let inner = EmptyNameSource;
556        // `InstrumentedSource::new` debug_asserts on an empty inner name, so
557        // build the wrapper directly with the fallback name to exercise the
558        // passthrough without tripping the assertion in debug builds.
559        let wrapped = InstrumentedSource {
560            inner: &inner,
561            labels: labels(),
562            connector: SharedString::const_str("unknown"),
563            base_labels: Vec::new(),
564            page_index: Arc::new(AtomicUsize::new(0)),
565        };
566        assert_eq!(
567            Source::connector_name(&wrapped),
568            "unknown",
569            "instrumented source must not leak an empty connector name"
570        );
571    }
572
573    #[tokio::test]
574    #[allow(clippy::await_holding_lock)]
575    async fn records_records_counter_per_page() {
576        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
577        let snap = snapshotter();
578        let inner = MockSource((0..5).map(|i| json!({"i": i})).collect());
579        let wrapped = InstrumentedSource::new(&inner, labels());
580        let ctx = HashMap::new();
581        let mut s = wrapped.stream_pages(&ctx, 2);
582        while s.next().await.is_some() {}
583        let snapshot = snap.snapshot();
584        let records: u64 = snapshot
585            .into_vec()
586            .into_iter()
587            .filter_map(|(key, _u, _d, v)| {
588                if key.key().name() == "faucet_source_records_total"
589                    && let DebugValue::Counter(c) = v
590                {
591                    return Some(c);
592                }
593                None
594            })
595            .sum();
596        assert!(
597            records >= 5,
598            "expected at least 5 records counted, got {records}"
599        );
600    }
601
602    // Source with a unique connector name so the page-duration histogram for
603    // this run can be isolated in the shared global recorder.
604    struct PageCountSource(Vec<Value>);
605    #[async_trait]
606    impl Source for PageCountSource {
607        async fn fetch_with_context(
608            &self,
609            _: &HashMap<String, Value>,
610        ) -> Result<Vec<Value>, FaucetError> {
611            Ok(self.0.clone())
612        }
613        fn connector_name(&self) -> &'static str {
614            "page-count-probe"
615        }
616    }
617
618    #[tokio::test]
619    #[allow(clippy::await_holding_lock)]
620    async fn page_duration_records_one_sample_per_yielded_page() {
621        // 5 records at batch_size 2 → pages [2, 2, 1] = 3 yielded pages. The
622        // terminal empty poll must NOT add a 4th (spurious ~0) sample.
623        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
624        let snap = snapshotter();
625        let inner = PageCountSource((0..5).map(|i| json!({"i": i})).collect());
626        let wrapped = InstrumentedSource::new(&inner, labels());
627        let ctx = HashMap::new();
628        let mut s = wrapped.stream_pages(&ctx, 2);
629        let mut pages = 0usize;
630        while s.next().await.is_some() {
631            pages += 1;
632        }
633        assert_eq!(pages, 3, "expected 3 yielded pages");
634
635        let snapshot = snap.snapshot();
636        let samples: usize = snapshot
637            .into_vec()
638            .into_iter()
639            .filter_map(|(key, _u, _d, v)| {
640                if key.key().name() == "faucet_source_page_duration_seconds"
641                    && key
642                        .key()
643                        .labels()
644                        .any(|l| l.key() == "connector" && l.value() == "page-count-probe")
645                    && let DebugValue::Histogram(h) = v
646                {
647                    return Some(h.len());
648                }
649                None
650            })
651            .sum();
652        assert_eq!(
653            samples, pages,
654            "page-duration histogram must have exactly one sample per yielded \
655             page ({pages}), not page+1 (no spurious terminal sample)"
656        );
657    }
658
659    #[tokio::test]
660    #[allow(clippy::await_holding_lock)]
661    async fn maps_panic_to_custom_error_with_kind_panic() {
662        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
663        let _snap = snapshotter();
664        let inner = PanickingSource;
665        let wrapped = InstrumentedSource::new(&inner, labels());
666        let ctx = HashMap::new();
667        let mut s = wrapped.stream_pages(&ctx, 10);
668        let first = s
669            .next()
670            .await
671            .expect("stream yields at least one item before terminating");
672        assert!(matches!(first, Err(FaucetError::Custom(_))));
673        // Process did not abort — implicit by reaching this line.
674    }
675
676    // ── error_kind: exhaustive variant → label mapping ───────────────────────
677
678    #[test]
679    fn error_kind_covers_all_variants() {
680        use std::time::Duration;
681        // Build one of every non-`Http` FaucetError variant and assert its
682        // stable label. (`Http` wraps a `reqwest::Error`, which has no public
683        // constructor; it is exercised through the live request paths in the
684        // connector crates' tests.)
685        let cases: Vec<(FaucetError, &str)> = vec![
686            (
687                FaucetError::HttpStatus {
688                    status: 500,
689                    url: "u".into(),
690                    body: "b".into(),
691                },
692                "HttpStatus",
693            ),
694            (
695                FaucetError::Json(serde_json::from_str::<Value>("nope").unwrap_err()),
696                "Json",
697            ),
698            (FaucetError::JsonPath("bad".into()), "JsonPath"),
699            (FaucetError::Auth("a".into()), "Auth"),
700            (
701                FaucetError::RateLimited(Duration::from_secs(1)),
702                "RateLimited",
703            ),
704            (FaucetError::Url("bad url".into()), "Url"),
705            (FaucetError::Transform("t".into()), "Transform"),
706            (FaucetError::Config("c".into()), "Config"),
707            (FaucetError::Source("s".into()), "Source"),
708            (FaucetError::Sink("s".into()), "Sink"),
709            (
710                FaucetError::QualityFailure {
711                    check: "chk".into(),
712                    message: "m".into(),
713                },
714                "QualityFailure",
715            ),
716            (FaucetError::State("st".into()), "State"),
717            (
718                FaucetError::CircuitOpen {
719                    failures: 3,
720                    cooldown: Duration::from_secs(60),
721                },
722                "CircuitOpen",
723            ),
724            (
725                FaucetError::Custom(Box::new(std::io::Error::other("boom"))),
726                "Custom",
727            ),
728        ];
729        for (err, expected) in cases {
730            assert_eq!(error_kind(&err), expected, "mismatch for {err:?}");
731        }
732    }
733
734    // ── Source passthrough methods ───────────────────────────────────────────
735
736    // A source that overrides every passthrough so the instrumented wrapper's
737    // delegating methods (state_key / apply_start_bookmark / fetch_with_context
738    // / fetch_with_context_incremental) are exercised.
739    struct PassthroughSource {
740        seen_bookmark: Mutex<Option<Value>>,
741    }
742    #[async_trait]
743    impl Source for PassthroughSource {
744        async fn fetch_with_context(
745            &self,
746            _: &HashMap<String, Value>,
747        ) -> Result<Vec<Value>, FaucetError> {
748            Ok(vec![json!({"fwc": 1})])
749        }
750        async fn fetch_with_context_incremental(
751            &self,
752            _: &HashMap<String, Value>,
753        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
754            Ok((vec![json!({"inc": 1})], Some(json!("bm"))))
755        }
756        fn state_key(&self) -> Option<String> {
757            Some("passthrough_key".into())
758        }
759        async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
760            *self.seen_bookmark.lock().unwrap() = Some(bookmark);
761            Ok(())
762        }
763        fn connector_name(&self) -> &'static str {
764            "passthrough"
765        }
766    }
767
768    #[tokio::test]
769    async fn source_passthroughs_delegate_to_inner() {
770        let inner = PassthroughSource {
771            seen_bookmark: Mutex::new(None),
772        };
773        let wrapped = InstrumentedSource::new(&inner, labels());
774
775        // state_key passthrough
776        assert_eq!(wrapped.state_key(), Some("passthrough_key".to_string()));
777
778        // fetch_with_context passthrough
779        let ctx = HashMap::new();
780        assert_eq!(
781            wrapped.fetch_with_context(&ctx).await.unwrap(),
782            vec![json!({"fwc": 1})]
783        );
784
785        // fetch_with_context_incremental passthrough
786        let (recs, bm) = wrapped.fetch_with_context_incremental(&ctx).await.unwrap();
787        assert_eq!(recs, vec![json!({"inc": 1})]);
788        assert_eq!(bm, Some(json!("bm")));
789
790        // apply_start_bookmark passthrough
791        wrapped.apply_start_bookmark(json!("resume")).await.unwrap();
792        assert_eq!(
793            *inner.seen_bookmark.lock().unwrap(),
794            Some(json!("resume")),
795            "apply_start_bookmark must reach the inner source"
796        );
797    }
798}
799
800#[cfg(test)]
801mod sink_tests {
802    use super::source_tests::{LOCK, labels, snapshotter};
803    use super::*;
804    use async_trait::async_trait;
805    use metrics_util::debugging::DebugValue;
806    use serde_json::json;
807
808    struct MockSink(std::sync::Mutex<Vec<Value>>);
809    #[async_trait]
810    impl Sink for MockSink {
811        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
812            self.0.lock().unwrap().extend(records.iter().cloned());
813            Ok(records.len())
814        }
815        fn connector_name(&self) -> &'static str {
816            "mock-sink"
817        }
818    }
819
820    struct FailingSink;
821    #[async_trait]
822    impl Sink for FailingSink {
823        async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
824            Err(FaucetError::Sink("nope".into()))
825        }
826        fn connector_name(&self) -> &'static str {
827            "failing-sink"
828        }
829    }
830
831    struct EmptyNameSink;
832    #[async_trait]
833    impl Sink for EmptyNameSink {
834        async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
835            Ok(0)
836        }
837        fn connector_name(&self) -> &'static str {
838            ""
839        }
840    }
841
842    #[test]
843    fn empty_inner_connector_name_falls_back_to_unknown() {
844        let inner = EmptyNameSink;
845        // `InstrumentedSink::new` debug_asserts on an empty inner name, so build
846        // the wrapper directly with the fallback name to exercise the
847        // passthrough without tripping the assertion in debug builds.
848        let wrapped = InstrumentedSink {
849            inner: &inner,
850            labels: labels(),
851            connector: SharedString::const_str("unknown"),
852            base_labels: Vec::new(),
853        };
854        assert_eq!(
855            Sink::connector_name(&wrapped),
856            "unknown",
857            "instrumented sink must not leak an empty connector name"
858        );
859    }
860
861    /// Regression (#194): the pipeline drives the *wrapped* sink, so
862    /// `InstrumentedSink` MUST forward the capability methods to the inner sink.
863    /// Before this was fixed, the trait defaults (`current_schema -> None`,
864    /// `supports_schema_evolution -> false`, `supports_idempotent_writes ->
865    /// false`) silently disabled schema-drift, evolution, and exactly-once
866    /// detection through the entire observability/CLI path even when the real
867    /// sink supported them.
868    struct CapableSink;
869    #[async_trait]
870    impl Sink for CapableSink {
871        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
872            Ok(records.len())
873        }
874        fn connector_name(&self) -> &'static str {
875            "capable-sink"
876        }
877        async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
878            Ok(Some(
879                json!({"type": "object", "properties": {"id": {"type": "integer"}}}),
880            ))
881        }
882        fn supports_schema_evolution(&self) -> bool {
883            true
884        }
885        fn supports_idempotent_writes(&self) -> bool {
886            true
887        }
888        fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
889            &[
890                crate::write_mode::WriteMode::Append,
891                crate::write_mode::WriteMode::Upsert,
892            ]
893        }
894        async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
895            Ok(Some("tok-1".into()))
896        }
897    }
898
899    #[tokio::test]
900    async fn instrumented_sink_forwards_capability_methods_to_inner() {
901        let inner = CapableSink;
902        let wrapped = InstrumentedSink::new(&inner, labels());
903
904        // Schema-drift (#194): the wrapper must surface the inner schema, not the
905        // `None` default — otherwise drift detection is inert through the pipeline.
906        assert_eq!(
907            wrapped.current_schema().await.unwrap(),
908            Some(json!({"type": "object", "properties": {"id": {"type": "integer"}}})),
909            "current_schema must delegate to the inner sink"
910        );
911        assert!(
912            wrapped.supports_schema_evolution(),
913            "supports_schema_evolution must delegate"
914        );
915        // Pre-existing capabilities the wrapper must also forward.
916        assert!(
917            wrapped.supports_idempotent_writes(),
918            "supports_idempotent_writes must delegate (exactly-once)"
919        );
920        assert!(
921            wrapped
922                .supported_write_modes()
923                .contains(&crate::write_mode::WriteMode::Upsert),
924            "supported_write_modes must delegate"
925        );
926        assert_eq!(
927            wrapped.last_committed_token("scope").await.unwrap(),
928            Some("tok-1".to_string()),
929            "last_committed_token must delegate"
930        );
931    }
932
933    #[tokio::test]
934    #[allow(clippy::await_holding_lock)]
935    async fn records_writes_and_records_counters() {
936        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
937        let snap = snapshotter();
938        let inner = MockSink(std::sync::Mutex::new(Vec::new()));
939        let wrapped = InstrumentedSink::new(&inner, labels());
940        wrapped
941            .write_batch(&[json!({"a": 1}), json!({"a": 2})])
942            .await
943            .unwrap();
944        let snapshot = snap.snapshot();
945        let writes: u64 = snapshot
946            .into_vec()
947            .into_iter()
948            .filter_map(|(key, _u, _d, v)| {
949                if key.key().name() == "faucet_sink_writes_total"
950                    && let DebugValue::Counter(c) = v
951                {
952                    return Some(c);
953                }
954                None
955            })
956            .sum();
957        assert!(writes >= 1, "expected at least one write counted");
958    }
959
960    #[tokio::test]
961    #[allow(clippy::await_holding_lock)]
962    async fn error_increments_errors_total_with_kind() {
963        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
964        let snap = snapshotter();
965        let inner = FailingSink;
966        let wrapped = InstrumentedSink::new(&inner, labels());
967        let _ = wrapped.write_batch(&[json!({})]).await;
968        let snapshot = snap.snapshot();
969        let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
970            key.key().name() == "faucet_sink_errors_total"
971                && key
972                    .key()
973                    .labels()
974                    .any(|l| l.key() == "kind" && l.value() == "Sink")
975                && matches!(v, DebugValue::Counter(c) if c >= 1)
976        });
977        assert!(found, "expected sink_errors_total with kind=Sink");
978    }
979
980    #[tokio::test]
981    #[allow(clippy::await_holding_lock)]
982    async fn instrumented_sink_write_batch_partial_counts_successful_outcomes() {
983        use crate::traits::RowOutcome;
984        use metrics_util::debugging::DebugValue;
985
986        // Sink that returns 2 Ok + 1 Err.
987        struct MixedSink;
988        #[async_trait]
989        impl Sink for MixedSink {
990            async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
991                unreachable!()
992            }
993            async fn write_batch_partial(
994                &self,
995                _r: &[Value],
996            ) -> Result<Vec<RowOutcome>, FaucetError> {
997                Ok(vec![
998                    Ok(()),
999                    Err(FaucetError::Sink("bad row".into())),
1000                    Ok(()),
1001                ])
1002            }
1003            fn connector_name(&self) -> &'static str {
1004                "mixed"
1005            }
1006        }
1007
1008        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1009        let snap = snapshotter();
1010
1011        let inner = MixedSink;
1012        let wrapped = InstrumentedSink::new(&inner, labels());
1013        let _ = wrapped
1014            .write_batch_partial(&[json!({}), json!({}), json!({})])
1015            .await
1016            .unwrap();
1017
1018        // faucet_sink_records_total should reflect 2 (Ok count), not 3.
1019        // Filter to this test's own labels (connector="mixed") — prior tests in
1020        // the same `mod sink_tests` (e.g. records_writes_and_records_counters
1021        // for connector="mock-sink") leave entries in the shared global
1022        // recorder, and the HashMap-iteration order of `Snapshot::into_vec()`
1023        // is non-deterministic, so a naïve `find_map` returns an arbitrary
1024        // entry.
1025        let snapshot = snap.snapshot();
1026        let records: u64 = snapshot
1027            .into_vec()
1028            .into_iter()
1029            .filter_map(|(k, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
1030                if k.key().name() == "faucet_sink_records_total"
1031                    && k.key()
1032                        .labels()
1033                        .any(|l| l.key() == "connector" && l.value() == "mixed")
1034                    && let DebugValue::Counter(c) = v
1035                {
1036                    Some(c)
1037                } else {
1038                    None
1039                }
1040            })
1041            .sum();
1042        assert!(
1043            records >= 2,
1044            "expected faucet_sink_records_total{{connector=mixed}} >= 2, got {records}"
1045        );
1046    }
1047
1048    // ── flush error path ─────────────────────────────────────────────────────
1049
1050    #[tokio::test]
1051    #[allow(clippy::await_holding_lock)]
1052    async fn flush_error_increments_errors_total_and_propagates() {
1053        // A sink whose flush() returns Err must surface the error and emit
1054        // faucet_sink_errors_total with the matching kind label.
1055        struct FlushFailSink;
1056        #[async_trait]
1057        impl Sink for FlushFailSink {
1058            async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
1059                Ok(r.len())
1060            }
1061            async fn flush(&self) -> Result<(), FaucetError> {
1062                Err(FaucetError::Sink("flush boom".into()))
1063            }
1064            fn connector_name(&self) -> &'static str {
1065                "flush-fail-sink"
1066            }
1067        }
1068
1069        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1070        let snap = snapshotter();
1071        let inner = FlushFailSink;
1072        let wrapped = InstrumentedSink::new(&inner, labels());
1073        let err = wrapped.flush().await.unwrap_err();
1074        assert!(matches!(&err, FaucetError::Sink(m) if m.contains("flush boom")));
1075
1076        let snapshot = snap.snapshot();
1077        let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1078            key.key().name() == "faucet_sink_errors_total"
1079                && key
1080                    .key()
1081                    .labels()
1082                    .any(|l| l.key() == "connector" && l.value() == "flush-fail-sink")
1083                && key
1084                    .key()
1085                    .labels()
1086                    .any(|l| l.key() == "kind" && l.value() == "Sink")
1087                && matches!(v, DebugValue::Counter(c) if c >= 1)
1088        });
1089        assert!(
1090            found,
1091            "expected sink_errors_total{{connector=flush-fail-sink,kind=Sink}}"
1092        );
1093    }
1094
1095    // ── panic isolation on every sink call ───────────────────────────────────
1096
1097    struct PanickingSink;
1098    #[async_trait]
1099    impl Sink for PanickingSink {
1100        async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1101            panic!("write kaboom")
1102        }
1103        async fn write_batch_partial(
1104            &self,
1105            _: &[Value],
1106        ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
1107            panic!("partial kaboom")
1108        }
1109        async fn flush(&self) -> Result<(), FaucetError> {
1110            panic!("flush kaboom")
1111        }
1112        fn connector_name(&self) -> &'static str {
1113            "panic-sink"
1114        }
1115    }
1116
1117    #[tokio::test]
1118    #[allow(clippy::await_holding_lock)]
1119    async fn write_batch_panic_maps_to_custom_error() {
1120        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1121        let _snap = snapshotter();
1122        let inner = PanickingSink;
1123        let wrapped = InstrumentedSink::new(&inner, labels());
1124        let err = wrapped.write_batch(&[json!({})]).await.unwrap_err();
1125        match err {
1126            FaucetError::Custom(b) => {
1127                assert!(b.to_string().contains("panic in sink: write kaboom"))
1128            }
1129            other => panic!("expected Custom panic error, got {other:?}"),
1130        }
1131    }
1132
1133    #[tokio::test]
1134    #[allow(clippy::await_holding_lock)]
1135    async fn write_batch_partial_panic_maps_to_custom_error() {
1136        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1137        let _snap = snapshotter();
1138        let inner = PanickingSink;
1139        let wrapped = InstrumentedSink::new(&inner, labels());
1140        let err = wrapped.write_batch_partial(&[json!({})]).await.unwrap_err();
1141        match err {
1142            FaucetError::Custom(b) => {
1143                assert!(b.to_string().contains("panic in sink: partial kaboom"))
1144            }
1145            other => panic!("expected Custom panic error, got {other:?}"),
1146        }
1147    }
1148
1149    #[tokio::test]
1150    #[allow(clippy::await_holding_lock)]
1151    async fn flush_panic_maps_to_custom_error() {
1152        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1153        let _snap = snapshotter();
1154        let inner = PanickingSink;
1155        let wrapped = InstrumentedSink::new(&inner, labels());
1156        let err = wrapped.flush().await.unwrap_err();
1157        match err {
1158            FaucetError::Custom(b) => {
1159                assert!(b.to_string().contains("panic in flush: flush kaboom"))
1160            }
1161            other => panic!("expected Custom panic error, got {other:?}"),
1162        }
1163    }
1164}