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