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