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