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 crate::usage::{UsageMeter, estimate_page_bytes};
10use async_trait::async_trait;
11use futures::FutureExt;
12use futures_core::Stream;
13use metrics::{Label, SharedString, counter, gauge};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::panic::AssertUnwindSafe;
17use std::pin::Pin;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicUsize, Ordering};
20use tracing::{Instrument, info_span};
21
22/// Count a native payload's bytes as they pass: a buffered payload at once, a
23/// streamed one chunk by chunk.
24fn metered_payload(
25    payload: crate::native::NativePayload,
26    labels: Vec<Label>,
27    metric: &'static str,
28    add: impl Fn(u64) + Send + Sync + 'static,
29) -> crate::native::NativePayload {
30    use crate::native::NativePayload;
31    use futures::StreamExt;
32    match payload {
33        NativePayload::Bytes(b) => {
34            counter!(metric, labels).increment(b.len() as u64);
35            add(b.len() as u64);
36            NativePayload::Bytes(b)
37        }
38        NativePayload::Stream(s) => NativePayload::Stream(Box::pin(s.inspect(move |chunk| {
39            if let Ok(c) = chunk {
40                counter!(metric, labels.clone()).increment(c.len() as u64);
41                add(c.len() as u64);
42            }
43        }))),
44    }
45}
46
47fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
48    panic
49        .downcast_ref::<&'static str>()
50        .map(|s| (*s).to_string())
51        .or_else(|| panic.downcast_ref::<String>().cloned())
52        .unwrap_or_else(|| "<non-string panic payload>".to_string())
53}
54
55/// Guard an inner connector's `connector_name()` so an empty string maps to
56/// the `"unknown"` fallback. Used both for the `connector` metric label and the
57/// `connector_name()` passthrough so the two never disagree.
58fn guarded_connector_name(raw: &'static str) -> &'static str {
59    if raw.is_empty() { "unknown" } else { raw }
60}
61
62/// Build the base `pipeline` / `row` / `connector` label vec once. The two
63/// `pipeline` / `row` heap allocations and the vec construction happen a single
64/// time at decorator construction; per-call sites `clone()` this instead of
65/// rebuilding from the `Arc<str>` labels on every page / write / flush.
66fn base_metric_labels(labels: &Labels, connector: &SharedString) -> Vec<Label> {
67    vec![
68        Label::new("pipeline", SharedString::from(labels.pipeline.to_string())),
69        Label::new("row", SharedString::from(labels.row.to_string())),
70        Label::new("connector", connector.clone()),
71    ]
72}
73
74/// Wraps a `&dyn Source` (or any `&S: Source`) and emits spans + metrics
75/// around every call. Constructed by `Pipeline::run` and never exposed to
76/// end users; the wrapped source remains the user-facing object.
77pub struct InstrumentedSource<'a, S: Source + ?Sized> {
78    inner: &'a S,
79    labels: Labels,
80    connector: SharedString,
81    /// Precomputed `pipeline` / `row` / `connector` labels, cloned per call.
82    base_labels: Vec<Label>,
83    page_index: Arc<AtomicUsize>,
84    /// The run's usage meter (#704); `None` = count nothing beyond metrics.
85    meter: Option<Arc<UsageMeter>>,
86}
87
88impl<'a, S: Source + ?Sized> InstrumentedSource<'a, S> {
89    pub fn new(inner: &'a S, labels: Labels) -> Self {
90        let raw = inner.connector_name();
91        debug_assert!(
92            !raw.is_empty(),
93            "connector_name() must return a non-empty string"
94        );
95        let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
96        let base_labels = base_metric_labels(&labels, &connector);
97        Self {
98            inner,
99            labels,
100            connector,
101            base_labels,
102            page_index: Arc::new(AtomicUsize::new(0)),
103            meter: None,
104        }
105    }
106
107    /// Tally records and estimated bytes into a run's usage meter (#704).
108    pub fn with_meter(mut self, meter: Arc<UsageMeter>) -> Self {
109        self.meter = Some(meter);
110        self
111    }
112
113    fn metric_labels(&self) -> Vec<Label> {
114        self.base_labels.clone()
115    }
116
117    /// Returns `metric_labels()` with an additional `kind` label appended.
118    /// Used by `InstrumentedSink::write_batch` (Task 9) and any future
119    /// instrumentation paths where `self` is in scope.
120    #[allow(dead_code)]
121    fn error_labels(&self, kind: &'static str) -> Vec<Label> {
122        let mut l = self.metric_labels();
123        l.push(Label::new("kind", SharedString::const_str(kind)));
124        l
125    }
126}
127
128#[async_trait]
129impl<'a, S: Source + ?Sized> Source for InstrumentedSource<'a, S> {
130    fn connector_name(&self) -> &'static str {
131        // Return the guarded name so an inner connector that returns "" maps to
132        // the "unknown" fallback — keeping this passthrough consistent with the
133        // `connector` metric label rather than leaking an empty string.
134        guarded_connector_name(self.inner.connector_name())
135    }
136
137    /// Forward the round-trip recorder to the wrapped connector (#638) — the
138    /// decorator sits between the pipeline and the connector, so without this
139    /// the hook would never reach the code that performs the I/O.
140    fn set_roundtrip_recorder(
141        &self,
142        recorder: std::sync::Arc<crate::observability::RoundtripRecorder>,
143    ) {
144        self.inner.set_roundtrip_recorder(recorder);
145    }
146
147    fn set_run_clock(&self, now: chrono::DateTime<chrono::Utc>) {
148        self.inner.set_run_clock(now);
149    }
150
151    fn state_key(&self) -> Option<String> {
152        self.inner.state_key()
153    }
154
155    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
156        self.inner.apply_start_bookmark(bookmark).await
157    }
158
159    fn supports_exactly_once(&self) -> bool {
160        self.inner.supports_exactly_once()
161    }
162    fn consumes_destructively(&self) -> bool {
163        self.inner.consumes_destructively()
164    }
165
166    fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
167        self.inner.replay_guarantee()
168    }
169
170    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
171        self.inner.capture_resume_position().await
172    }
173    async fn lag(&self) -> Result<Option<crate::lag::SourceLag>, FaucetError> {
174        self.inner.lag().await
175    }
176
177    fn state_schema(&self) -> u32 {
178        self.inner.state_schema()
179    }
180
181    fn migrate_state(&self, from: u32, data: Value) -> Result<Value, FaucetError> {
182        self.inner.migrate_state(from, data)
183    }
184
185    fn record_table(&self, record: &Value) -> Option<String> {
186        self.inner.record_table(record)
187    }
188
189    fn position_le(&self, a: &Value, b: &Value) -> Option<bool> {
190        self.inner.position_le(a, b)
191    }
192
193    fn position_min(&self, positions: &[Value]) -> Option<Value> {
194        self.inner.position_min(positions)
195    }
196
197    async fn fetch_with_context(
198        &self,
199        context: &HashMap<String, Value>,
200    ) -> Result<Vec<Value>, FaucetError> {
201        // Library-call path; the pipeline drives through stream_pages.
202        self.inner.fetch_with_context(context).await
203    }
204
205    async fn fetch_with_context_incremental(
206        &self,
207        context: &HashMap<String, Value>,
208    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
209        self.inner.fetch_with_context_incremental(context).await
210    }
211
212    // Columnar fast path (feature `arrow`): forward transparently to the inner
213    // source. The columnar streaming loop in `pipeline.rs` emits the source
214    // metrics itself, so no instrumentation is layered here (RFC 0002 / #375).
215    #[cfg(feature = "arrow")]
216    fn supports_columnar(&self) -> bool {
217        self.inner.supports_columnar()
218    }
219
220    #[cfg(feature = "arrow")]
221    fn stream_batches<'b>(
222        &'b self,
223        context: &'b HashMap<String, Value>,
224        batch_size: usize,
225    ) -> Pin<Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'b>>
226    {
227        use futures::StreamExt;
228        let inner = self.inner.stream_batches(context, batch_size);
229        let Some(meter) = self.meter.clone() else {
230            return inner;
231        };
232        let labels = self.metric_labels();
233        Box::pin(inner.inspect(move |page| {
234            if let Ok(page) = page {
235                let bytes = page.batch.get_array_memory_size() as u64;
236                counter!("faucet_source_bytes_total", labels.clone()).increment(bytes);
237                meter.add_read(page.batch.num_rows() as u64, bytes);
238            }
239        }))
240    }
241
242    // Native byte-passthrough capability (#633) forwards to the inner source; the
243    // native streaming loop in `pipeline.rs` emits the source metrics itself.
244    fn native_output_formats(&self) -> &'static [crate::native::NativeFormat] {
245        self.inner.native_output_formats()
246    }
247
248    fn stream_native<'b>(
249        &'b self,
250        context: &'b HashMap<String, Value>,
251        format: crate::native::NativeFormat,
252        batch_size: usize,
253    ) -> Pin<Box<dyn Stream<Item = Result<crate::native::NativeBatch, FaucetError>> + Send + 'b>>
254    {
255        use futures::StreamExt;
256        let inner = self.inner.stream_native(context, format, batch_size);
257        let Some(meter) = self.meter.clone() else {
258            return inner;
259        };
260        let labels = self.metric_labels();
261        Box::pin(inner.map(move |batch| {
262            batch.map(|mut b| {
263                let records = b.records.unwrap_or(0);
264                b.payload =
265                    metered_payload(b.payload, labels.clone(), "faucet_source_bytes_total", {
266                        let meter = Arc::clone(&meter);
267                        move |n| meter.add_read(0, n)
268                    });
269                meter.add_read(records, 0);
270                b
271            })
272        }))
273    }
274
275    fn stream_pages<'b>(
276        &'b self,
277        context: &'b HashMap<String, Value>,
278        batch_size: usize,
279    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'b>> {
280        let inner_stream = self.inner.stream_pages(context, batch_size);
281        let labels = self.labels.clone();
282        let connector = self.connector.clone();
283        let page_index = Arc::clone(&self.page_index);
284        let meter = self.meter.clone();
285        let metric_labels = self.metric_labels();
286        let pipeline = self.labels.pipeline.clone();
287        let row = self.labels.row.clone();
288
289        Box::pin(async_stream::try_stream! {
290            // In-flight gauge tracks open streams. Decrement on drop so
291            // cancellation leaves the gauge consistent.
292            struct InFlightGuard(Vec<Label>);
293            impl Drop for InFlightGuard {
294                fn drop(&mut self) {
295                    gauge!("faucet_source_in_flight", self.0.clone()).decrement(1.0);
296                }
297            }
298            gauge!("faucet_source_in_flight", metric_labels.clone()).increment(1.0);
299            let _in_flight = InFlightGuard(metric_labels.clone());
300
301            let mut inner = inner_stream;
302            loop {
303                let idx = page_index.fetch_add(1, Ordering::Relaxed);
304                let span = info_span!(
305                    "faucet.source.page",
306                    pipeline = %pipeline,
307                    row = %row,
308                    run_id = %labels.run_id,
309                    connector = %connector,
310                    page_index = idx,
311                );
312                // Armed across the poll so a cancelled / panicking page-fetch
313                // still records the time spent. Disarmed on the terminal empty
314                // poll (`Ok(None)`) so end-of-stream doesn't record a spurious
315                // ~0 sample into the page-duration histogram.
316                let mut _timer = DurationGuard::new(
317                    "faucet_source_page_duration_seconds",
318                    metric_labels.clone(),
319                );
320
321                let next = AssertUnwindSafe(async {
322                    use futures::StreamExt;
323                    inner.next().await
324                })
325                .catch_unwind()
326                .instrument(span)
327                .await;
328
329                match next {
330                    Ok(Some(Ok(page))) => {
331                        counter!("faucet_source_pages_total", metric_labels.clone()).increment(1);
332                        counter!("faucet_source_records_total", metric_labels.clone())
333                            .increment(page.records.len() as u64);
334                        if let Some(m) = &meter {
335                            let bytes = estimate_page_bytes(&page.records);
336                            counter!("faucet_source_bytes_total", metric_labels.clone())
337                                .increment(bytes);
338                            m.add_read(page.records.len() as u64, bytes);
339                        }
340                        // Close the timing window BEFORE yielding: in an
341                        // `async_stream` the timer local persists across the
342                        // yield, so dropping it at scope-exit would fold the
343                        // downstream sink/consumer latency into the source's
344                        // page-duration histogram (audit #321 M10).
345                        _timer.record_now();
346                        yield page;
347                    }
348                    Ok(Some(Err(e))) => {
349                        let mut l = metric_labels.clone();
350                        l.push(Label::new("kind", SharedString::const_str(error_kind(&e))));
351                        counter!("faucet_source_errors_total", l).increment(1);
352                        Err(e)?;
353                    }
354                    Ok(None) => {
355                        _timer.disarm();
356                        break;
357                    }
358                    Err(panic) => {
359                        let mut l = metric_labels.clone();
360                        l.push(Label::new("kind", SharedString::const_str("Panic")));
361                        counter!("faucet_source_errors_total", l).increment(1);
362                        let msg = panic.downcast_ref::<&'static str>().map(|s| (*s).to_string())
363                            .or_else(|| panic.downcast_ref::<String>().cloned())
364                            .unwrap_or_else(|| "<non-string panic payload>".to_string());
365                        Err(FaucetError::Custom(format!("panic in source: {msg}").into()))?;
366                    }
367                }
368            }
369        })
370    }
371}
372
373/// Map a `FaucetError` variant to its stable `kind` label value. The match
374/// must be exhaustive; update when new variants are added.
375pub(crate) fn error_kind(e: &FaucetError) -> &'static str {
376    match e {
377        FaucetError::Http(_) => "Http",
378        FaucetError::HttpStatus { .. } => "HttpStatus",
379        FaucetError::Json(_) => "Json",
380        FaucetError::JsonPath(_) => "JsonPath",
381        FaucetError::Auth(_) => "Auth",
382        FaucetError::RateLimited { .. } => "RateLimited",
383        FaucetError::Url(_) => "Url",
384        FaucetError::Transform(_) => "Transform",
385        FaucetError::Config(_) => "Config",
386        FaucetError::Source(_) => "Source",
387        FaucetError::Sink(_) => "Sink",
388        FaucetError::QualityFailure { .. } => "QualityFailure",
389        FaucetError::SchemaDrift { .. } => "SchemaDrift",
390        FaucetError::ProfileDrift { .. } => "ProfileDrift",
391        FaucetError::PolicyViolation { .. } => "PolicyViolation",
392        FaucetError::BudgetExceeded { .. } => "BudgetExceeded",
393        FaucetError::ContractViolation { .. } => "ContractViolation",
394        FaucetError::State(_) => "State",
395        FaucetError::StateIncompatible { .. } => "StateIncompatible",
396        FaucetError::CircuitOpen { .. } => "CircuitOpen",
397        FaucetError::Custom(_) => "Custom",
398    }
399}
400
401/// Wraps a `&dyn Sink` (or any `&S: Sink`) and emits spans + metrics around
402/// `write_batch` and `flush`. Constructed by `Pipeline::run`.
403pub struct InstrumentedSink<'a, S: Sink + ?Sized> {
404    inner: &'a S,
405    labels: Labels,
406    connector: SharedString,
407    /// Precomputed `pipeline` / `row` / `connector` labels, cloned per call.
408    base_labels: Vec<Label>,
409    /// The run's usage meter (#704); `None` = count nothing beyond metrics.
410    meter: Option<Arc<UsageMeter>>,
411}
412
413impl<'a, S: Sink + ?Sized> InstrumentedSink<'a, S> {
414    pub fn new(inner: &'a S, labels: Labels) -> Self {
415        let raw = inner.connector_name();
416        debug_assert!(
417            !raw.is_empty(),
418            "connector_name() must return a non-empty string"
419        );
420        let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
421        let base_labels = base_metric_labels(&labels, &connector);
422        Self {
423            inner,
424            labels,
425            connector,
426            base_labels,
427            meter: None,
428        }
429    }
430
431    /// Tally accepted records and estimated bytes into a run's usage meter
432    /// (#704).
433    pub fn with_meter(mut self, meter: Arc<UsageMeter>) -> Self {
434        self.meter = Some(meter);
435        self
436    }
437
438    fn metric_labels(&self) -> Vec<Label> {
439        self.base_labels.clone()
440    }
441
442    fn error_labels(&self, kind: &'static str) -> Vec<Label> {
443        let mut l = self.metric_labels();
444        l.push(Label::new("kind", SharedString::const_str(kind)));
445        l
446    }
447
448    /// Count `accepted` records of `records` as written; when the sink
449    /// accepted a prefix, only that prefix's estimated size is attributed.
450    fn meter_written(&self, records: &[Value], accepted: usize) {
451        let Some(m) = &self.meter else {
452            return;
453        };
454        let bytes = if accepted >= records.len() {
455            estimate_page_bytes(records)
456        } else {
457            estimate_page_bytes(&records[..accepted])
458        };
459        counter!("faucet_sink_bytes_total", self.metric_labels()).increment(bytes);
460        m.add_written(accepted as u64, bytes);
461    }
462}
463
464#[async_trait]
465impl<'a, S: Sink + ?Sized> Sink for InstrumentedSink<'a, S> {
466    async fn admit_page(&self, records: &[Value]) -> Result<(), FaucetError> {
467        self.inner.admit_page(records).await
468    }
469    fn connector_name(&self) -> &'static str {
470        // Return the guarded name so an inner connector that returns "" maps to
471        // the "unknown" fallback — keeping this passthrough consistent with the
472        // `connector` metric label rather than leaking an empty string.
473        guarded_connector_name(self.inner.connector_name())
474    }
475
476    /// Forward the round-trip recorder to the wrapped connector (#638) — the
477    /// decorator sits between the pipeline and the connector, so without this
478    /// the hook would never reach the code that performs the I/O.
479    fn set_roundtrip_recorder(
480        &self,
481        recorder: std::sync::Arc<crate::observability::RoundtripRecorder>,
482    ) {
483        self.inner.set_roundtrip_recorder(recorder);
484    }
485
486    // Identity + provenance passthroughs. Instrumentation must be invisible to
487    // anything asking the sink *what* it is or *what it wrote* — a decorator that
488    // falls back to the trait defaults reports `"<name>://unknown"` and an empty
489    // output list, which for `local_outputs` means the retention GC (#587) never
490    // learns about files this sink created and can never reclaim them. Silent, and
491    // only observable as disk filling up.
492    fn dataset_uri(&self) -> String {
493        self.inner.dataset_uri()
494    }
495
496    async fn local_outputs(&self) -> Vec<crate::local_outputs::LocalOutput> {
497        self.inner.local_outputs().await
498    }
499
500    // Columnar fast path (feature `arrow`): forward transparently to the inner
501    // sink; the columnar loop in `pipeline.rs` emits the sink metrics (RFC 0002).
502    #[cfg(feature = "arrow")]
503    fn supports_columnar(&self) -> bool {
504        self.inner.supports_columnar()
505    }
506
507    #[cfg(feature = "arrow")]
508    async fn write_batch_columnar(
509        &self,
510        batch: &arrow::array::RecordBatch,
511    ) -> Result<usize, FaucetError> {
512        let n = self.inner.write_batch_columnar(batch).await?;
513        if let Some(m) = &self.meter {
514            let rows = batch.num_rows().max(1);
515            let bytes = (batch.get_array_memory_size() as u64).saturating_mul(n.min(rows) as u64)
516                / rows as u64;
517            counter!("faucet_sink_bytes_total", self.metric_labels()).increment(bytes);
518            m.add_written(n as u64, bytes);
519        }
520        Ok(n)
521    }
522
523    // Native byte-passthrough load (#633): forward to the inner sink; the native
524    // loop in `pipeline.rs` emits the sink metrics.
525    fn native_load_capabilities(&self) -> Vec<crate::native::NativeLoadCapability> {
526        self.inner.native_load_capabilities()
527    }
528
529    async fn load_native(
530        &self,
531        batch: crate::native::NativeBatch,
532        scope: &str,
533        ctx: crate::native::NativeLoadContext,
534    ) -> Result<usize, FaucetError> {
535        let Some(meter) = self.meter.clone() else {
536            return self.inner.load_native(batch, scope, ctx).await;
537        };
538        let bytes = Arc::new(std::sync::atomic::AtomicU64::new(0));
539        let mut batch = batch;
540        batch.payload = metered_payload(
541            batch.payload,
542            self.metric_labels(),
543            "faucet_sink_bytes_total",
544            {
545                let bytes = Arc::clone(&bytes);
546                move |n| {
547                    bytes.fetch_add(n, Ordering::Relaxed);
548                }
549            },
550        );
551        let n = self.inner.load_native(batch, scope, ctx).await?;
552        meter.add_written(n as u64, bytes.load(Ordering::Relaxed));
553        Ok(n)
554    }
555
556    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
557        let span = info_span!(
558            "faucet.sink.write",
559            pipeline = %self.labels.pipeline,
560            row = %self.labels.row,
561            run_id = %self.labels.run_id,
562            connector = %self.connector,
563            records = records.len(),
564        );
565        let metric_labels = self.metric_labels();
566        gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
567
568        // RAII guard ensures the gauge is decremented even if write_batch
569        // panics or the future is cancelled.
570        struct InFlightGuard(Vec<Label>);
571        impl Drop for InFlightGuard {
572            fn drop(&mut self) {
573                gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
574            }
575        }
576        let _in_flight = InFlightGuard(metric_labels.clone());
577
578        let _timer =
579            DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
580
581        let result = AssertUnwindSafe(self.inner.write_batch(records))
582            .catch_unwind()
583            .instrument(span)
584            .await;
585
586        match result {
587            Ok(Ok(n)) => {
588                counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
589                counter!("faucet_sink_records_total", metric_labels.clone()).increment(n as u64);
590                self.meter_written(records, n);
591                Ok(n)
592            }
593            Ok(Err(e)) => {
594                counter!(
595                    "faucet_sink_errors_total",
596                    self.error_labels(error_kind(&e))
597                )
598                .increment(1);
599                Err(e)
600            }
601            Err(panic) => {
602                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
603                let msg = panic
604                    .downcast_ref::<&'static str>()
605                    .map(|s| (*s).to_string())
606                    .or_else(|| panic.downcast_ref::<String>().cloned())
607                    .unwrap_or_else(|| "<non-string panic payload>".to_string());
608                Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
609            }
610        }
611    }
612
613    async fn write_batch_partial(
614        &self,
615        records: &[Value],
616    ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
617        let span = info_span!(
618            "faucet.sink.write_partial",
619            pipeline = %self.labels.pipeline,
620            row = %self.labels.row,
621            run_id = %self.labels.run_id,
622            connector = %self.connector,
623            records = records.len(),
624        );
625        let metric_labels = self.metric_labels();
626        gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
627
628        // RAII guard ensures the gauge is decremented even if write_batch_partial
629        // panics or the future is cancelled.
630        struct InFlightGuard(Vec<Label>);
631        impl Drop for InFlightGuard {
632            fn drop(&mut self) {
633                gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
634            }
635        }
636        let _in_flight = InFlightGuard(metric_labels.clone());
637
638        let _timer =
639            DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
640
641        let result = AssertUnwindSafe(self.inner.write_batch_partial(records))
642            .catch_unwind()
643            .instrument(span)
644            .await;
645
646        match result {
647            Ok(Ok(outcomes)) => {
648                let success_count = outcomes.iter().filter(|o| o.is_ok()).count();
649                counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
650                counter!("faucet_sink_records_total", metric_labels.clone())
651                    .increment(success_count as u64);
652                if let Some(m) = &self.meter {
653                    let bytes: u64 = outcomes
654                        .iter()
655                        .zip(records.iter())
656                        .filter(|(o, _)| o.is_ok())
657                        .map(|(_, r)| crate::usage::estimate_json_bytes(r))
658                        .sum();
659                    counter!("faucet_sink_bytes_total", metric_labels.clone()).increment(bytes);
660                    m.add_written(success_count as u64, bytes);
661                }
662                Ok(outcomes)
663            }
664            Ok(Err(e)) => {
665                counter!(
666                    "faucet_sink_errors_total",
667                    self.error_labels(error_kind(&e))
668                )
669                .increment(1);
670                Err(e)
671            }
672            Err(panic) => {
673                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
674                let msg = panic
675                    .downcast_ref::<&'static str>()
676                    .map(|s| (*s).to_string())
677                    .or_else(|| panic.downcast_ref::<String>().cloned())
678                    .unwrap_or_else(|| "<non-string panic payload>".to_string());
679                Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
680            }
681        }
682    }
683
684    async fn flush(&self) -> Result<(), FaucetError> {
685        let span = info_span!(
686            "faucet.sink.flush",
687            pipeline = %self.labels.pipeline,
688            row = %self.labels.row,
689            run_id = %self.labels.run_id,
690            connector = %self.connector,
691        );
692        let metric_labels = self.metric_labels();
693        let _timer =
694            DurationGuard::new("faucet_sink_flush_duration_seconds", metric_labels.clone());
695
696        let result = AssertUnwindSafe(self.inner.flush())
697            .catch_unwind()
698            .instrument(span)
699            .await;
700
701        match result {
702            Ok(Ok(())) => Ok(()),
703            Ok(Err(e)) => {
704                counter!(
705                    "faucet_sink_errors_total",
706                    self.error_labels(error_kind(&e))
707                )
708                .increment(1);
709                Err(e)
710            }
711            Err(panic) => {
712                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
713                let msg = panic
714                    .downcast_ref::<&'static str>()
715                    .map(|s| (*s).to_string())
716                    .or_else(|| panic.downcast_ref::<String>().cloned())
717                    .unwrap_or_else(|| "<non-string panic payload>".to_string());
718                Err(FaucetError::Custom(format!("panic in flush: {msg}").into()))
719            }
720        }
721    }
722
723    // ── Non-instrumented passthroughs ────────────────────────────────────────
724    // These carry no per-call metric/span of their own, but they MUST delegate
725    // to the inner sink — the `Sink` trait gives each a default that disables
726    // the corresponding feature (schema-drift, upsert, exactly-once). Because
727    // the pipeline drives the *wrapped* sink, failing to forward them silently
728    // makes those features inert through the entire CLI/observability path.
729
730    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
731        self.inner.current_schema().await
732    }
733
734    fn supports_schema_evolution(&self) -> bool {
735        self.inner.supports_schema_evolution()
736    }
737
738    async fn evolve_schema(
739        &self,
740        evolution: &crate::drift::SchemaEvolution,
741    ) -> Result<(), FaucetError> {
742        self.inner.evolve_schema(evolution).await
743    }
744
745    fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
746        self.inner.supported_write_modes()
747    }
748
749    fn supports_cleanup(&self) -> bool {
750        self.inner.supports_cleanup()
751    }
752
753    async fn cleanup_scope(
754        &self,
755        scope: &std::collections::BTreeMap<String, Value>,
756        seen: &crate::cleanup::SeenKeys,
757    ) -> Result<u64, FaucetError> {
758        self.inner.cleanup_scope(scope, seen).await
759    }
760
761    fn supports_idempotent_writes(&self) -> bool {
762        self.inner.supports_idempotent_writes()
763    }
764
765    fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
766        self.inner.sink_guarantee()
767    }
768
769    fn dedups_by_key(&self) -> bool {
770        self.inner.dedups_by_key()
771    }
772    fn batch_atomicity(&self) -> crate::dlq::BatchAtomicity {
773        self.inner.batch_atomicity()
774    }
775
776    async fn write_batch_idempotent(
777        &self,
778        records: &[Value],
779        scope: &str,
780        token: &str,
781    ) -> Result<usize, FaucetError> {
782        let span = info_span!(
783            "faucet.sink.write_idempotent",
784            pipeline = %self.labels.pipeline,
785            row = %self.labels.row,
786            run_id = %self.labels.run_id,
787            connector = %self.connector,
788            records = records.len(),
789        );
790        let metric_labels = self.metric_labels();
791        gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
792        struct InFlightGuard(Vec<Label>);
793        impl Drop for InFlightGuard {
794            fn drop(&mut self) {
795                gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
796            }
797        }
798        let _in_flight = InFlightGuard(metric_labels.clone());
799        let _timer =
800            DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
801
802        let result = AssertUnwindSafe(self.inner.write_batch_idempotent(records, scope, token))
803            .catch_unwind()
804            .instrument(span)
805            .await;
806        match result {
807            Ok(Ok(n)) => {
808                counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
809                counter!("faucet_sink_records_total", metric_labels).increment(n as u64);
810                self.meter_written(records, n);
811                Ok(n)
812            }
813            Ok(Err(e)) => {
814                counter!(
815                    "faucet_sink_errors_total",
816                    self.error_labels(error_kind(&e))
817                )
818                .increment(1);
819                Err(e)
820            }
821            Err(panic) => {
822                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
823                Err(FaucetError::Custom(
824                    format!("panic in sink: {}", panic_message(&*panic)).into(),
825                ))
826            }
827        }
828    }
829
830    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
831        self.inner.last_committed_token(scope).await
832    }
833
834    fn is_overwrite(&self) -> bool {
835        self.inner.is_overwrite()
836    }
837
838    async fn begin_overwrite(&self) -> Result<(), FaucetError> {
839        self.inner.begin_overwrite().await
840    }
841
842    async fn commit_overwrite(&self) -> Result<(), FaucetError> {
843        self.inner.commit_overwrite().await
844    }
845
846    async fn abort_overwrite(&self) -> Result<(), FaucetError> {
847        self.inner.abort_overwrite().await
848    }
849    async fn complete_run(&self) -> Result<(), FaucetError> {
850        self.inner.complete_run().await
851    }
852
853    fn write_batch_is_replay_safe(&self) -> bool {
854        self.inner.write_batch_is_replay_safe()
855    }
856
857    fn supports_staged_load(&self) -> bool {
858        self.inner.supports_staged_load()
859    }
860
861    async fn overwrite_staging_exists(&self) -> Result<Option<bool>, FaucetError> {
862        self.inner.overwrite_staging_exists().await
863    }
864
865    fn supports_rollback(&self) -> bool {
866        self.inner.supports_rollback()
867    }
868
869    async fn rollback_run(
870        &self,
871        run_id: &str,
872        opts: &crate::rollback::RollbackOptions,
873    ) -> Result<crate::rollback::RollbackOutcome, FaucetError> {
874        self.inner.rollback_run(run_id, opts).await
875    }
876
877    async fn forget_run(&self, run_id: &str) -> Result<(), FaucetError> {
878        self.inner.forget_run(run_id).await
879    }
880
881    async fn rewind_commit_token(
882        &self,
883        scope: &str,
884        token: Option<&str>,
885    ) -> Result<(), FaucetError> {
886        self.inner.rewind_commit_token(scope, token).await
887    }
888
889    fn readback_source(&self) -> Option<(String, Value)> {
890        self.inner.readback_source()
891    }
892
893    fn config_schema(&self) -> Value {
894        self.inner.config_schema()
895    }
896
897    async fn check(
898        &self,
899        ctx: &crate::check::CheckContext,
900    ) -> Result<crate::check::CheckReport, FaucetError> {
901        self.inner.check(ctx).await
902    }
903}
904
905#[cfg(test)]
906pub(crate) mod source_tests {
907    use super::*;
908    use async_trait::async_trait;
909    use futures::StreamExt;
910    use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshotter};
911    use serde_json::json;
912    use std::sync::{Mutex, OnceLock};
913
914    /// A change-stream double with its own multi-table hooks (#731).
915    #[tokio::test]
916    async fn routed_double_fetches_nothing() {
917        use crate::Source as _;
918        assert!(
919            RoutedSource
920                .fetch_with_context(&Default::default())
921                .await
922                .unwrap()
923                .is_empty()
924        );
925    }
926
927    struct RoutedSource;
928
929    #[async_trait::async_trait]
930    impl crate::Source for RoutedSource {
931        async fn fetch_with_context(
932            &self,
933            _ctx: &std::collections::HashMap<String, serde_json::Value>,
934        ) -> Result<Vec<serde_json::Value>, crate::FaucetError> {
935            Ok(Vec::new())
936        }
937
938        fn record_table(&self, record: &serde_json::Value) -> Option<String> {
939            record.get("t")?.as_str().map(str::to_string)
940        }
941
942        fn position_le(&self, a: &serde_json::Value, b: &serde_json::Value) -> Option<bool> {
943            Some(a.as_u64()? <= b.as_u64()?)
944        }
945
946        fn position_min(&self, _positions: &[serde_json::Value]) -> Option<serde_json::Value> {
947            Some(serde_json::json!("inner-min"))
948        }
949    }
950
951    fn assert_forwards_multi_table_hooks(s: &dyn crate::Source) {
952        assert_eq!(
953            s.record_table(&serde_json::json!({"t": "public.a"}))
954                .as_deref(),
955            Some("public.a")
956        );
957        assert_eq!(
958            s.position_le(&serde_json::json!(1), &serde_json::json!(2)),
959            Some(true)
960        );
961        assert_eq!(
962            s.position_le(&serde_json::json!(3), &serde_json::json!(2)),
963            Some(false)
964        );
965        assert_eq!(
966            s.position_min(&[serde_json::json!(1), serde_json::json!(2)]),
967            Some(serde_json::json!("inner-min"))
968        );
969    }
970
971    #[test]
972    fn multi_table_hooks_are_forwarded_to_the_inner_source() {
973        let inner = RoutedSource;
974        let wrapped = InstrumentedSource::new(&inner, labels());
975        assert_forwards_multi_table_hooks(&wrapped);
976        wrapped.set_run_clock(chrono::Utc::now());
977    }
978
979    // Process-global recorder shared across all observability tests in this
980    // crate. Task 5 established the same pattern.
981    pub(crate) static LOCK: Mutex<()> = Mutex::new(());
982    static SNAPSHOTTER: OnceLock<Snapshotter> = OnceLock::new();
983
984    pub(crate) fn snapshotter() -> &'static Snapshotter {
985        SNAPSHOTTER.get_or_init(|| {
986            let recorder = DebuggingRecorder::new();
987            let snap = recorder.snapshotter();
988            // First test installs; the OnceLock guarantees we never install
989            // twice. If something else (e.g. the timer test) already installed
990            // a recorder, `set_global_recorder` will Err — but in that case
991            // *our* snapshotter is disconnected from the live recorder. The
992            // workaround is for all observability tests to share one source of
993            // truth — this file. If a future test elsewhere installs a
994            // recorder first, restructure so all tests share this OnceLock.
995            let _ = metrics::set_global_recorder(recorder);
996            snap
997        })
998    }
999
1000    pub(in crate::observability) fn labels() -> Labels {
1001        Labels::new("p", "r", "rid")
1002    }
1003
1004    struct MockSource(Vec<Value>);
1005    #[async_trait]
1006    impl Source for MockSource {
1007        async fn fetch_with_context(
1008            &self,
1009            _: &HashMap<String, Value>,
1010        ) -> Result<Vec<Value>, FaucetError> {
1011            Ok(self.0.clone())
1012        }
1013        fn connector_name(&self) -> &'static str {
1014            "mock"
1015        }
1016    }
1017
1018    struct PanickingSource;
1019    #[async_trait]
1020    impl Source for PanickingSource {
1021        async fn fetch_with_context(
1022            &self,
1023            _: &HashMap<String, Value>,
1024        ) -> Result<Vec<Value>, FaucetError> {
1025            panic!("kaboom")
1026        }
1027        fn connector_name(&self) -> &'static str {
1028            "panic-test"
1029        }
1030    }
1031
1032    // Inner connector that returns an empty name. The instrumented wrapper must
1033    // map this to the `"unknown"` fallback so the `connector_name()` passthrough
1034    // never disagrees with the `connector` metric label.
1035    struct EmptyNameSource;
1036    #[async_trait]
1037    impl Source for EmptyNameSource {
1038        async fn fetch_with_context(
1039            &self,
1040            _: &HashMap<String, Value>,
1041        ) -> Result<Vec<Value>, FaucetError> {
1042            Ok(vec![])
1043        }
1044        fn connector_name(&self) -> &'static str {
1045            ""
1046        }
1047    }
1048
1049    #[test]
1050    fn empty_inner_connector_name_falls_back_to_unknown() {
1051        let inner = EmptyNameSource;
1052        // `InstrumentedSource::new` debug_asserts on an empty inner name, so
1053        // build the wrapper directly with the fallback name to exercise the
1054        // passthrough without tripping the assertion in debug builds.
1055        let wrapped = InstrumentedSource {
1056            inner: &inner,
1057            labels: labels(),
1058            connector: SharedString::const_str("unknown"),
1059            base_labels: Vec::new(),
1060            page_index: Arc::new(AtomicUsize::new(0)),
1061            meter: None,
1062        };
1063        assert_eq!(
1064            Source::connector_name(&wrapped),
1065            "unknown",
1066            "instrumented source must not leak an empty connector name"
1067        );
1068    }
1069
1070    #[tokio::test]
1071    #[allow(clippy::await_holding_lock)]
1072    async fn records_records_counter_per_page() {
1073        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1074        let snap = snapshotter();
1075        let inner = MockSource((0..5).map(|i| json!({"i": i})).collect());
1076        let wrapped = InstrumentedSource::new(&inner, labels());
1077        let ctx = HashMap::new();
1078        let mut s = wrapped.stream_pages(&ctx, 2);
1079        while s.next().await.is_some() {}
1080        let snapshot = snap.snapshot();
1081        let records: u64 = snapshot
1082            .into_vec()
1083            .into_iter()
1084            .filter_map(|(key, _u, _d, v)| {
1085                if key.key().name() == "faucet_source_records_total"
1086                    && let DebugValue::Counter(c) = v
1087                {
1088                    return Some(c);
1089                }
1090                None
1091            })
1092            .sum();
1093        assert!(
1094            records >= 5,
1095            "expected at least 5 records counted, got {records}"
1096        );
1097    }
1098
1099    // Source with a unique connector name so the page-duration histogram for
1100    // this run can be isolated in the shared global recorder.
1101    struct PageCountSource(Vec<Value>);
1102    #[async_trait]
1103    impl Source for PageCountSource {
1104        async fn fetch_with_context(
1105            &self,
1106            _: &HashMap<String, Value>,
1107        ) -> Result<Vec<Value>, FaucetError> {
1108            Ok(self.0.clone())
1109        }
1110        fn connector_name(&self) -> &'static str {
1111            "page-count-probe"
1112        }
1113    }
1114
1115    #[tokio::test]
1116    #[allow(clippy::await_holding_lock)]
1117    async fn page_duration_records_one_sample_per_yielded_page() {
1118        // 5 records at batch_size 2 → pages [2, 2, 1] = 3 yielded pages. The
1119        // terminal empty poll must NOT add a 4th (spurious ~0) sample.
1120        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1121        let snap = snapshotter();
1122        let inner = PageCountSource((0..5).map(|i| json!({"i": i})).collect());
1123        let wrapped = InstrumentedSource::new(&inner, labels());
1124        let ctx = HashMap::new();
1125        let mut s = wrapped.stream_pages(&ctx, 2);
1126        let mut pages = 0usize;
1127        while s.next().await.is_some() {
1128            pages += 1;
1129        }
1130        assert_eq!(pages, 3, "expected 3 yielded pages");
1131
1132        let snapshot = snap.snapshot();
1133        let samples: usize = snapshot
1134            .into_vec()
1135            .into_iter()
1136            .filter_map(|(key, _u, _d, v)| {
1137                if key.key().name() == "faucet_source_page_duration_seconds"
1138                    && key
1139                        .key()
1140                        .labels()
1141                        .any(|l| l.key() == "connector" && l.value() == "page-count-probe")
1142                    && let DebugValue::Histogram(h) = v
1143                {
1144                    return Some(h.len());
1145                }
1146                None
1147            })
1148            .sum();
1149        assert_eq!(
1150            samples, pages,
1151            "page-duration histogram must have exactly one sample per yielded \
1152             page ({pages}), not page+1 (no spurious terminal sample)"
1153        );
1154    }
1155
1156    #[tokio::test]
1157    #[allow(clippy::await_holding_lock)]
1158    async fn maps_panic_to_custom_error_with_kind_panic() {
1159        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1160        let _snap = snapshotter();
1161        let inner = PanickingSource;
1162        let wrapped = InstrumentedSource::new(&inner, labels());
1163        let ctx = HashMap::new();
1164        let mut s = wrapped.stream_pages(&ctx, 10);
1165        let first = s
1166            .next()
1167            .await
1168            .expect("stream yields at least one item before terminating");
1169        assert!(matches!(first, Err(FaucetError::Custom(_))));
1170        // Process did not abort — implicit by reaching this line.
1171    }
1172
1173    // ── error_kind: exhaustive variant → label mapping ───────────────────────
1174
1175    #[test]
1176    fn error_kind_covers_all_variants() {
1177        use std::time::Duration;
1178        // Build one of every non-`Http` FaucetError variant and assert its
1179        // stable label. (`Http` wraps a `reqwest::Error`, which has no public
1180        // constructor; it is exercised through the live request paths in the
1181        // connector crates' tests.)
1182        let cases: Vec<(FaucetError, &str)> = vec![
1183            (
1184                FaucetError::HttpStatus {
1185                    status: 500,
1186                    url: "u".into(),
1187                    body: "b".into(),
1188                },
1189                "HttpStatus",
1190            ),
1191            (
1192                FaucetError::Json(serde_json::from_str::<Value>("nope").unwrap_err()),
1193                "Json",
1194            ),
1195            (FaucetError::JsonPath("bad".into()), "JsonPath"),
1196            (FaucetError::Auth("a".into()), "Auth"),
1197            (
1198                FaucetError::RateLimited(Duration::from_secs(1)),
1199                "RateLimited",
1200            ),
1201            (FaucetError::Url("bad url".into()), "Url"),
1202            (FaucetError::Transform("t".into()), "Transform"),
1203            (FaucetError::Config("c".into()), "Config"),
1204            (FaucetError::Source("s".into()), "Source"),
1205            (FaucetError::Sink("s".into()), "Sink"),
1206            (
1207                FaucetError::QualityFailure {
1208                    check: "chk".into(),
1209                    message: "m".into(),
1210                },
1211                "QualityFailure",
1212            ),
1213            (FaucetError::State("st".into()), "State"),
1214            (
1215                FaucetError::CircuitOpen {
1216                    failures: 3,
1217                    cooldown: Duration::from_secs(60),
1218                },
1219                "CircuitOpen",
1220            ),
1221            (
1222                FaucetError::Custom(Box::new(std::io::Error::other("boom"))),
1223                "Custom",
1224            ),
1225        ];
1226        for (err, expected) in cases {
1227            assert_eq!(error_kind(&err), expected, "mismatch for {err:?}");
1228        }
1229    }
1230
1231    // ── Source passthrough methods ───────────────────────────────────────────
1232
1233    // A source that overrides every passthrough so the instrumented wrapper's
1234    // delegating methods (state_key / apply_start_bookmark / fetch_with_context
1235    // / fetch_with_context_incremental) are exercised.
1236    struct PassthroughSource {
1237        seen_bookmark: Mutex<Option<Value>>,
1238    }
1239    #[async_trait]
1240    impl Source for PassthroughSource {
1241        async fn fetch_with_context(
1242            &self,
1243            _: &HashMap<String, Value>,
1244        ) -> Result<Vec<Value>, FaucetError> {
1245            Ok(vec![json!({"fwc": 1})])
1246        }
1247        async fn fetch_with_context_incremental(
1248            &self,
1249            _: &HashMap<String, Value>,
1250        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1251            Ok((vec![json!({"inc": 1})], Some(json!("bm"))))
1252        }
1253        fn state_key(&self) -> Option<String> {
1254            Some("passthrough_key".into())
1255        }
1256        async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1257            *self.seen_bookmark.lock().unwrap() = Some(bookmark);
1258            Ok(())
1259        }
1260        fn connector_name(&self) -> &'static str {
1261            "passthrough"
1262        }
1263    }
1264
1265    #[tokio::test]
1266    async fn source_passthroughs_delegate_to_inner() {
1267        let inner = PassthroughSource {
1268            seen_bookmark: Mutex::new(None),
1269        };
1270        let wrapped = InstrumentedSource::new(&inner, labels());
1271
1272        // state_key passthrough
1273        assert_eq!(wrapped.state_key(), Some("passthrough_key".to_string()));
1274
1275        // fetch_with_context passthrough
1276        let ctx = HashMap::new();
1277        assert_eq!(
1278            wrapped.fetch_with_context(&ctx).await.unwrap(),
1279            vec![json!({"fwc": 1})]
1280        );
1281
1282        // fetch_with_context_incremental passthrough
1283        let (recs, bm) = wrapped.fetch_with_context_incremental(&ctx).await.unwrap();
1284        assert_eq!(recs, vec![json!({"inc": 1})]);
1285        assert_eq!(bm, Some(json!("bm")));
1286
1287        // apply_start_bookmark passthrough
1288        wrapped.apply_start_bookmark(json!("resume")).await.unwrap();
1289        assert_eq!(
1290            *inner.seen_bookmark.lock().unwrap(),
1291            Some(json!("resume")),
1292            "apply_start_bookmark must reach the inner source"
1293        );
1294
1295        // capability passthroughs: defaults for this inner source…
1296        assert!(!wrapped.supports_exactly_once());
1297        assert!(!wrapped.consumes_destructively());
1298        assert_eq!(
1299            wrapped.replay_guarantee(),
1300            crate::idempotency::ReplayGuarantee::NonDeterministic
1301        );
1302        assert_eq!(wrapped.capture_resume_position().await.unwrap(), None);
1303        assert_eq!(wrapped.lag().await.unwrap(), None);
1304    }
1305
1306    /// A source advertising exactly-once — the decorator must not mask it
1307    /// (the pipeline's mechanism selection reads these through the wrapper).
1308    struct ExactlyOnceSource;
1309    #[async_trait]
1310    impl Source for ExactlyOnceSource {
1311        async fn fetch_with_context(
1312            &self,
1313            _context: &HashMap<String, Value>,
1314        ) -> Result<Vec<Value>, FaucetError> {
1315            Ok(vec![])
1316        }
1317        fn supports_exactly_once(&self) -> bool {
1318            true
1319        }
1320        async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1321            Ok(Some(json!("pos")))
1322        }
1323        fn connector_name(&self) -> &'static str {
1324            "eo-source"
1325        }
1326    }
1327
1328    #[tokio::test]
1329    async fn source_capability_passthroughs_delegate_to_inner() {
1330        let inner = ExactlyOnceSource;
1331        let wrapped = InstrumentedSource::new(&inner, labels());
1332        assert!(wrapped.supports_exactly_once());
1333        assert_eq!(
1334            wrapped.replay_guarantee(),
1335            crate::idempotency::ReplayGuarantee::Deterministic,
1336            "typed capability derives through the wrapper"
1337        );
1338        assert_eq!(
1339            wrapped.capture_resume_position().await.unwrap(),
1340            Some(json!("pos"))
1341        );
1342    }
1343}
1344
1345#[cfg(test)]
1346mod sink_tests {
1347    use super::source_tests::{LOCK, labels, snapshotter};
1348    use super::*;
1349    use async_trait::async_trait;
1350    use metrics_util::debugging::DebugValue;
1351    use serde_json::json;
1352
1353    struct MockSink(std::sync::Mutex<Vec<Value>>);
1354    #[async_trait]
1355    impl Sink for MockSink {
1356        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1357            self.0.lock().unwrap().extend(records.iter().cloned());
1358            Ok(records.len())
1359        }
1360        fn connector_name(&self) -> &'static str {
1361            "mock-sink"
1362        }
1363    }
1364
1365    struct FailingSink;
1366    #[async_trait]
1367    impl Sink for FailingSink {
1368        async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1369            Err(FaucetError::Sink("nope".into()))
1370        }
1371        fn connector_name(&self) -> &'static str {
1372            "failing-sink"
1373        }
1374    }
1375
1376    struct EmptyNameSink;
1377    #[async_trait]
1378    impl Sink for EmptyNameSink {
1379        async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1380            Ok(0)
1381        }
1382        fn connector_name(&self) -> &'static str {
1383            ""
1384        }
1385    }
1386
1387    #[test]
1388    fn empty_inner_connector_name_falls_back_to_unknown() {
1389        let inner = EmptyNameSink;
1390        // `InstrumentedSink::new` debug_asserts on an empty inner name, so build
1391        // the wrapper directly with the fallback name to exercise the
1392        // passthrough without tripping the assertion in debug builds.
1393        let wrapped = InstrumentedSink {
1394            inner: &inner,
1395            labels: labels(),
1396            connector: SharedString::const_str("unknown"),
1397            base_labels: Vec::new(),
1398            meter: None,
1399        };
1400        assert_eq!(
1401            Sink::connector_name(&wrapped),
1402            "unknown",
1403            "instrumented sink must not leak an empty connector name"
1404        );
1405    }
1406
1407    /// Regression (#194): the pipeline drives the *wrapped* sink, so
1408    /// `InstrumentedSink` MUST forward the capability methods to the inner sink.
1409    /// Before this was fixed, the trait defaults (`current_schema -> None`,
1410    /// `supports_schema_evolution -> false`, `supports_idempotent_writes ->
1411    /// false`) silently disabled schema-drift, evolution, and exactly-once
1412    /// detection through the entire observability/CLI path even when the real
1413    /// sink supported them.
1414    struct CapableSink;
1415    #[async_trait]
1416    impl Sink for CapableSink {
1417        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1418            Ok(records.len())
1419        }
1420        fn connector_name(&self) -> &'static str {
1421            "capable-sink"
1422        }
1423        async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1424            Ok(Some(
1425                json!({"type": "object", "properties": {"id": {"type": "integer"}}}),
1426            ))
1427        }
1428        fn supports_schema_evolution(&self) -> bool {
1429            true
1430        }
1431        fn supports_idempotent_writes(&self) -> bool {
1432            true
1433        }
1434        fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
1435            &[
1436                crate::write_mode::WriteMode::Append,
1437                crate::write_mode::WriteMode::Upsert,
1438            ]
1439        }
1440        async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
1441            Ok(Some("tok-1".into()))
1442        }
1443        fn dedups_by_key(&self) -> bool {
1444            true
1445        }
1446    }
1447
1448    #[tokio::test]
1449    async fn instrumented_sink_forwards_capability_methods_to_inner() {
1450        let inner = CapableSink;
1451        let wrapped = InstrumentedSink::new(&inner, labels());
1452
1453        // Schema-drift (#194): the wrapper must surface the inner schema, not the
1454        // `None` default — otherwise drift detection is inert through the pipeline.
1455        assert_eq!(
1456            wrapped.current_schema().await.unwrap(),
1457            Some(json!({"type": "object", "properties": {"id": {"type": "integer"}}})),
1458            "current_schema must delegate to the inner sink"
1459        );
1460        assert!(
1461            wrapped.supports_schema_evolution(),
1462            "supports_schema_evolution must delegate"
1463        );
1464        // Pre-existing capabilities the wrapper must also forward.
1465        assert!(
1466            wrapped.supports_idempotent_writes(),
1467            "supports_idempotent_writes must delegate (exactly-once)"
1468        );
1469        assert!(
1470            wrapped
1471                .supported_write_modes()
1472                .contains(&crate::write_mode::WriteMode::Upsert),
1473            "supported_write_modes must delegate"
1474        );
1475        assert_eq!(
1476            wrapped.last_committed_token("scope").await.unwrap(),
1477            Some("tok-1".to_string()),
1478            "last_committed_token must delegate"
1479        );
1480        // Typed delivery capabilities (#292): the pipeline's mechanism
1481        // selection reads these through the wrapper.
1482        assert_eq!(
1483            wrapped.sink_guarantee(),
1484            crate::idempotency::SinkGuarantee::AtomicWatermark,
1485            "sink_guarantee must delegate"
1486        );
1487        assert!(wrapped.dedups_by_key(), "dedups_by_key must delegate");
1488        assert_eq!(
1489            wrapped.batch_atomicity(),
1490            crate::dlq::BatchAtomicity::BestEffort
1491        );
1492    }
1493
1494    #[tokio::test]
1495    #[allow(clippy::await_holding_lock)]
1496    async fn records_writes_and_records_counters() {
1497        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1498        let snap = snapshotter();
1499        let inner = MockSink(std::sync::Mutex::new(Vec::new()));
1500        let wrapped = InstrumentedSink::new(&inner, labels());
1501        wrapped
1502            .write_batch(&[json!({"a": 1}), json!({"a": 2})])
1503            .await
1504            .unwrap();
1505        let snapshot = snap.snapshot();
1506        let writes: u64 = snapshot
1507            .into_vec()
1508            .into_iter()
1509            .filter_map(|(key, _u, _d, v)| {
1510                if key.key().name() == "faucet_sink_writes_total"
1511                    && let DebugValue::Counter(c) = v
1512                {
1513                    return Some(c);
1514                }
1515                None
1516            })
1517            .sum();
1518        assert!(writes >= 1, "expected at least one write counted");
1519    }
1520
1521    #[tokio::test]
1522    #[allow(clippy::await_holding_lock)]
1523    async fn error_increments_errors_total_with_kind() {
1524        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1525        let snap = snapshotter();
1526        let inner = FailingSink;
1527        let wrapped = InstrumentedSink::new(&inner, labels());
1528        let _ = wrapped.write_batch(&[json!({})]).await;
1529        let snapshot = snap.snapshot();
1530        let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1531            key.key().name() == "faucet_sink_errors_total"
1532                && key
1533                    .key()
1534                    .labels()
1535                    .any(|l| l.key() == "kind" && l.value() == "Sink")
1536                && matches!(v, DebugValue::Counter(c) if c >= 1)
1537        });
1538        assert!(found, "expected sink_errors_total with kind=Sink");
1539    }
1540
1541    #[tokio::test]
1542    #[allow(clippy::await_holding_lock)]
1543    async fn instrumented_sink_write_batch_partial_counts_successful_outcomes() {
1544        use crate::traits::RowOutcome;
1545        use metrics_util::debugging::DebugValue;
1546
1547        // Sink that returns 2 Ok + 1 Err.
1548        struct MixedSink;
1549        #[async_trait]
1550        impl Sink for MixedSink {
1551            async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
1552                unreachable!()
1553            }
1554            async fn write_batch_partial(
1555                &self,
1556                _r: &[Value],
1557            ) -> Result<Vec<RowOutcome>, FaucetError> {
1558                Ok(vec![
1559                    Ok(()),
1560                    Err(FaucetError::Sink("bad row".into())),
1561                    Ok(()),
1562                ])
1563            }
1564            fn connector_name(&self) -> &'static str {
1565                "mixed"
1566            }
1567        }
1568
1569        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1570        let snap = snapshotter();
1571
1572        let inner = MixedSink;
1573        let wrapped = InstrumentedSink::new(&inner, labels());
1574        let _ = wrapped
1575            .write_batch_partial(&[json!({}), json!({}), json!({})])
1576            .await
1577            .unwrap();
1578
1579        // faucet_sink_records_total should reflect 2 (Ok count), not 3.
1580        // Filter to this test's own labels (connector="mixed") — prior tests in
1581        // the same `mod sink_tests` (e.g. records_writes_and_records_counters
1582        // for connector="mock-sink") leave entries in the shared global
1583        // recorder, and the HashMap-iteration order of `Snapshot::into_vec()`
1584        // is non-deterministic, so a naïve `find_map` returns an arbitrary
1585        // entry.
1586        let snapshot = snap.snapshot();
1587        let records: u64 = snapshot
1588            .into_vec()
1589            .into_iter()
1590            .filter_map(|(k, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
1591                if k.key().name() == "faucet_sink_records_total"
1592                    && k.key()
1593                        .labels()
1594                        .any(|l| l.key() == "connector" && l.value() == "mixed")
1595                    && let DebugValue::Counter(c) = v
1596                {
1597                    Some(c)
1598                } else {
1599                    None
1600                }
1601            })
1602            .sum();
1603        assert!(
1604            records >= 2,
1605            "expected faucet_sink_records_total{{connector=mixed}} >= 2, got {records}"
1606        );
1607    }
1608
1609    // ── flush error path ─────────────────────────────────────────────────────
1610
1611    #[tokio::test]
1612    #[allow(clippy::await_holding_lock)]
1613    async fn flush_error_increments_errors_total_and_propagates() {
1614        // A sink whose flush() returns Err must surface the error and emit
1615        // faucet_sink_errors_total with the matching kind label.
1616        struct FlushFailSink;
1617        #[async_trait]
1618        impl Sink for FlushFailSink {
1619            async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
1620                Ok(r.len())
1621            }
1622            async fn flush(&self) -> Result<(), FaucetError> {
1623                Err(FaucetError::Sink("flush boom".into()))
1624            }
1625            fn connector_name(&self) -> &'static str {
1626                "flush-fail-sink"
1627            }
1628        }
1629
1630        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1631        let snap = snapshotter();
1632        let inner = FlushFailSink;
1633        let wrapped = InstrumentedSink::new(&inner, labels());
1634        let err = wrapped.flush().await.unwrap_err();
1635        assert!(matches!(&err, FaucetError::Sink(m) if m.contains("flush boom")));
1636
1637        let snapshot = snap.snapshot();
1638        let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1639            key.key().name() == "faucet_sink_errors_total"
1640                && key
1641                    .key()
1642                    .labels()
1643                    .any(|l| l.key() == "connector" && l.value() == "flush-fail-sink")
1644                && key
1645                    .key()
1646                    .labels()
1647                    .any(|l| l.key() == "kind" && l.value() == "Sink")
1648                && matches!(v, DebugValue::Counter(c) if c >= 1)
1649        });
1650        assert!(
1651            found,
1652            "expected sink_errors_total{{connector=flush-fail-sink,kind=Sink}}"
1653        );
1654    }
1655
1656    // ── panic isolation on every sink call ───────────────────────────────────
1657
1658    struct PanickingSink;
1659    #[async_trait]
1660    impl Sink for PanickingSink {
1661        async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1662            panic!("write kaboom")
1663        }
1664        async fn write_batch_partial(
1665            &self,
1666            _: &[Value],
1667        ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
1668            panic!("partial kaboom")
1669        }
1670        async fn flush(&self) -> Result<(), FaucetError> {
1671            panic!("flush kaboom")
1672        }
1673        fn connector_name(&self) -> &'static str {
1674            "panic-sink"
1675        }
1676    }
1677
1678    #[tokio::test]
1679    #[allow(clippy::await_holding_lock)]
1680    async fn write_batch_panic_maps_to_custom_error() {
1681        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1682        let _snap = snapshotter();
1683        let inner = PanickingSink;
1684        let wrapped = InstrumentedSink::new(&inner, labels());
1685        let err = wrapped.write_batch(&[json!({})]).await.unwrap_err();
1686        match err {
1687            FaucetError::Custom(b) => {
1688                assert!(b.to_string().contains("panic in sink: write kaboom"))
1689            }
1690            other => panic!("expected Custom panic error, got {other:?}"),
1691        }
1692    }
1693
1694    #[tokio::test]
1695    #[allow(clippy::await_holding_lock)]
1696    async fn write_batch_partial_panic_maps_to_custom_error() {
1697        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1698        let _snap = snapshotter();
1699        let inner = PanickingSink;
1700        let wrapped = InstrumentedSink::new(&inner, labels());
1701        let err = wrapped.write_batch_partial(&[json!({})]).await.unwrap_err();
1702        match err {
1703            FaucetError::Custom(b) => {
1704                assert!(b.to_string().contains("panic in sink: partial kaboom"))
1705            }
1706            other => panic!("expected Custom panic error, got {other:?}"),
1707        }
1708    }
1709
1710    #[tokio::test]
1711    #[allow(clippy::await_holding_lock)]
1712    async fn flush_panic_maps_to_custom_error() {
1713        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1714        let _snap = snapshotter();
1715        let inner = PanickingSink;
1716        let wrapped = InstrumentedSink::new(&inner, labels());
1717        let err = wrapped.flush().await.unwrap_err();
1718        match err {
1719            FaucetError::Custom(b) => {
1720                assert!(b.to_string().contains("panic in flush: flush kaboom"))
1721            }
1722            other => panic!("expected Custom panic error, got {other:?}"),
1723        }
1724    }
1725    /// `InstrumentedSink` must forward the identity + provenance methods.
1726    ///
1727    /// It wraps every sink whenever observability is active — i.e. the default
1728    /// build — and it forwarded neither of these, which review caught. The
1729    /// failure mode is silent: `local_outputs()` falling back to the trait
1730    /// default hides every file the inner sink created from the retention GC
1731    /// (#587), so those files are never reclaimed and nothing logs or errors.
1732    /// `dataset_uri()` falling back records `jsonl://unknown` in lineage and the
1733    /// catalog.
1734    ///
1735    /// The sibling decorators are covered in
1736    /// `tests/local_output_forwarding.rs`; this one lives here because the
1737    /// module is private.
1738    #[tokio::test]
1739    async fn instrumented_sink_forwards_identity_and_local_outputs() {
1740        use crate::local_outputs::{LocalOutput, LocalOutputLog};
1741
1742        struct FileSink {
1743            outputs: LocalOutputLog,
1744        }
1745
1746        #[async_trait]
1747        impl Sink for FileSink {
1748            async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1749                Ok(records.len())
1750            }
1751            fn connector_name(&self) -> &'static str {
1752                "jsonl"
1753            }
1754            fn dataset_uri(&self) -> String {
1755                "file:///tmp/out.jsonl".to_string()
1756            }
1757            async fn local_outputs(&self) -> Vec<LocalOutput> {
1758                self.outputs.snapshot()
1759            }
1760        }
1761
1762        let outputs = LocalOutputLog::new();
1763        outputs.record_open("/tmp/out.jsonl", false);
1764        let inner = FileSink { outputs };
1765        let sink = InstrumentedSink::new(&inner, Labels::new("p", "r", "run-1"));
1766
1767        let reported = sink.local_outputs().await;
1768        assert_eq!(
1769            reported.len(),
1770            1,
1771            "InstrumentedSink must not hide the inner sink's files from the GC"
1772        );
1773        assert_eq!(reported[0].path, std::path::PathBuf::from("/tmp/out.jsonl"));
1774        assert!(
1775            !reported[0].pre_existing,
1776            "classification must survive verbatim"
1777        );
1778        assert_eq!(sink.dataset_uri(), "file:///tmp/out.jsonl");
1779    }
1780
1781    fn counters_for(row: &str) -> HashMap<String, u64> {
1782        let mut out = HashMap::new();
1783        for (key, _u, _d, v) in snapshotter().snapshot().into_vec() {
1784            let k = key.key();
1785            if k.labels().any(|l| l.key() == "row" && l.value() == row)
1786                && let DebugValue::Counter(c) = v
1787            {
1788                *out.entry(k.name().to_string()).or_default() += c;
1789            }
1790        }
1791        out
1792    }
1793
1794    #[tokio::test]
1795    async fn instrumented_sink_forwards_every_hook() {
1796        let probe = crate::sink_forwarding::HookSink::default();
1797        let wrapped = InstrumentedSink::new(&probe, labels());
1798        crate::sink_forwarding::assert_forwards_every_hook(&wrapped, &probe, &[]).await;
1799    }
1800
1801    #[allow(clippy::await_holding_lock)]
1802    #[tokio::test]
1803    async fn idempotent_write_is_counted_metered_and_panic_guarded() {
1804        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1805        let _snap = snapshotter();
1806        let probe = crate::sink_forwarding::HookSink::default();
1807        let meter = Arc::new(UsageMeter::new());
1808        let wrapped = InstrumentedSink::new(&probe, Labels::new("p", "eo-row", "rid"))
1809            .with_meter(Arc::clone(&meter));
1810        let n = wrapped
1811            .write_batch_idempotent(&[json!({"a": 1}), json!({"a": 2})], "s", "t")
1812            .await
1813            .unwrap();
1814        assert_eq!(n, 2);
1815        let c = counters_for("eo-row");
1816        assert_eq!(c["faucet_sink_writes_total"], 1);
1817        assert_eq!(c["faucet_sink_records_total"], 2);
1818        assert_eq!(meter.snapshot().records_written, 2);
1819
1820        struct EoPanics;
1821        #[async_trait]
1822        impl Sink for EoPanics {
1823            async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1824                Ok(0)
1825            }
1826            async fn write_batch_idempotent(
1827                &self,
1828                _: &[Value],
1829                _: &str,
1830                _: &str,
1831            ) -> Result<usize, FaucetError> {
1832                panic!("eo kaboom")
1833            }
1834        }
1835        let wrapped = InstrumentedSink::new(&EoPanics, Labels::new("p", "eo-panic", "rid"));
1836        let err = wrapped
1837            .write_batch_idempotent(&[json!({})], "s", "t")
1838            .await
1839            .unwrap_err();
1840        assert!(
1841            err.to_string().contains("panic in sink: eo kaboom"),
1842            "{err}"
1843        );
1844        assert_eq!(counters_for("eo-panic")["faucet_sink_errors_total"], 1);
1845
1846        struct EoFails;
1847        #[async_trait]
1848        impl Sink for EoFails {
1849            async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1850                Ok(0)
1851            }
1852            async fn write_batch_idempotent(
1853                &self,
1854                _: &[Value],
1855                _: &str,
1856                _: &str,
1857            ) -> Result<usize, FaucetError> {
1858                Err(FaucetError::Sink("nope".into()))
1859            }
1860        }
1861        let wrapped = InstrumentedSink::new(&EoFails, Labels::new("p", "eo-err", "rid"));
1862        assert!(wrapped.write_batch_idempotent(&[], "s", "t").await.is_err());
1863        assert_eq!(counters_for("eo-err")["faucet_sink_errors_total"], 1);
1864    }
1865
1866    #[tokio::test]
1867    async fn native_loads_are_metered_for_buffered_and_streamed_payloads() {
1868        use crate::native::{NativeBatch, NativeFormat, NativeLoadContext, NativePayload};
1869        let probe = crate::sink_forwarding::HookSink::default();
1870        let meter = Arc::new(UsageMeter::new());
1871        let wrapped = InstrumentedSink::new(&probe, labels()).with_meter(Arc::clone(&meter));
1872        let ctx = || NativeLoadContext {
1873            write_mode: crate::write_mode::WriteMode::Append,
1874            first_batch: true,
1875        };
1876        let batch = |payload| NativeBatch {
1877            format: NativeFormat::NdJson,
1878            payload,
1879            csv: Default::default(),
1880            records: Some(2),
1881            bookmark: None,
1882        };
1883        wrapped
1884            .load_native(batch(NativePayload::Bytes(vec![0; 10])), "s", ctx())
1885            .await
1886            .unwrap();
1887        let stream: Pin<Box<dyn Stream<Item = Result<Vec<u8>, FaucetError>> + Send>> =
1888            Box::pin(futures::stream::iter(vec![Ok(vec![0; 3]), Ok(vec![0; 4])]));
1889        struct Drains;
1890        #[async_trait]
1891        impl Sink for Drains {
1892            async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1893                Ok(0)
1894            }
1895            async fn load_native(
1896                &self,
1897                batch: NativeBatch,
1898                _: &str,
1899                _: NativeLoadContext,
1900            ) -> Result<usize, FaucetError> {
1901                use futures::StreamExt;
1902                if let NativePayload::Stream(mut s) = batch.payload {
1903                    while s.next().await.is_some() {}
1904                }
1905                Ok(5)
1906            }
1907        }
1908        let wrapped2 = InstrumentedSink::new(&Drains, labels()).with_meter(Arc::clone(&meter));
1909        wrapped2
1910            .load_native(batch(NativePayload::Stream(stream)), "s", ctx())
1911            .await
1912            .unwrap();
1913        let snap = meter.snapshot();
1914        assert_eq!(snap.records_written, 7 + 5);
1915        assert_eq!(snap.bytes_written, 10 + 7);
1916    }
1917
1918    #[tokio::test]
1919    async fn native_source_batches_are_metered() {
1920        use crate::native::{NativeBatch, NativeFormat, NativePayload};
1921        struct Native;
1922        #[async_trait]
1923        impl Source for Native {
1924            async fn fetch_with_context(
1925                &self,
1926                _: &HashMap<String, Value>,
1927            ) -> Result<Vec<Value>, FaucetError> {
1928                Ok(vec![])
1929            }
1930            fn stream_native<'b>(
1931                &'b self,
1932                _: &'b HashMap<String, Value>,
1933                _: NativeFormat,
1934                _: usize,
1935            ) -> Pin<Box<dyn Stream<Item = Result<NativeBatch, FaucetError>> + Send + 'b>>
1936            {
1937                let b = NativeBatch {
1938                    format: NativeFormat::NdJson,
1939                    payload: NativePayload::Bytes(vec![1; 6]),
1940                    csv: Default::default(),
1941                    records: Some(3),
1942                    bookmark: None,
1943                };
1944                Box::pin(futures::stream::iter(vec![Ok(b)]))
1945            }
1946        }
1947        use futures::StreamExt;
1948        let meter = Arc::new(UsageMeter::new());
1949        let wrapped = InstrumentedSource::new(&Native, labels()).with_meter(Arc::clone(&meter));
1950        let ctx = HashMap::new();
1951        let got: Vec<_> = wrapped
1952            .stream_native(&ctx, NativeFormat::NdJson, 10)
1953            .collect()
1954            .await;
1955        assert_eq!(got.len(), 1);
1956        let snap = meter.snapshot();
1957        assert_eq!((snap.records_read, snap.bytes_read), (3, 6));
1958        let unmetered = InstrumentedSource::new(&Native, labels());
1959        assert_eq!(
1960            unmetered
1961                .stream_native(&ctx, NativeFormat::NdJson, 10)
1962                .count()
1963                .await,
1964            1
1965        );
1966    }
1967
1968    #[cfg(feature = "arrow")]
1969    #[tokio::test]
1970    async fn columnar_batches_are_metered_on_both_sides() {
1971        use crate::columnar::ColumnarPage;
1972        use arrow::array::{Int64Array, RecordBatch};
1973        use arrow::datatypes::{DataType, Field, Schema};
1974        fn batch() -> RecordBatch {
1975            RecordBatch::try_new(
1976                Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
1977                vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
1978            )
1979            .unwrap()
1980        }
1981        struct Cols;
1982        #[async_trait]
1983        impl Source for Cols {
1984            async fn fetch_with_context(
1985                &self,
1986                _: &HashMap<String, Value>,
1987            ) -> Result<Vec<Value>, FaucetError> {
1988                Ok(vec![])
1989            }
1990            fn stream_batches<'b>(
1991                &'b self,
1992                _: &'b HashMap<String, Value>,
1993                _: usize,
1994            ) -> Pin<Box<dyn Stream<Item = Result<ColumnarPage, FaucetError>> + Send + 'b>>
1995            {
1996                Box::pin(futures::stream::iter(vec![Ok(ColumnarPage {
1997                    batch: batch(),
1998                    bookmark: None,
1999                })]))
2000            }
2001        }
2002        use futures::StreamExt;
2003        let meter = Arc::new(UsageMeter::new());
2004        let src = InstrumentedSource::new(&Cols, labels()).with_meter(Arc::clone(&meter));
2005        let ctx = HashMap::new();
2006        assert_eq!(src.stream_batches(&ctx, 10).count().await, 1);
2007        let unmetered = InstrumentedSource::new(&Cols, labels());
2008        assert_eq!(unmetered.stream_batches(&ctx, 10).count().await, 1);
2009
2010        let probe = crate::sink_forwarding::HookSink::default();
2011        let sink = InstrumentedSink::new(&probe, labels()).with_meter(Arc::clone(&meter));
2012        assert_eq!(sink.write_batch_columnar(&batch()).await.unwrap(), 3);
2013        let snap = meter.snapshot();
2014        assert_eq!((snap.records_read, snap.records_written), (3, 3));
2015        assert!(snap.bytes_read > 0);
2016        assert_eq!(snap.bytes_read, snap.bytes_written);
2017    }
2018}