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