Skip to main content

http_streams_core/
progress.rs

1//! Accounting and observability for one streamed body, in either direction.
2//!
3//! This is the merge of two implementations that had independently converged on the same
4//! design (`axum-streams`' `progress.rs` and `reqwest-streams`' `observability.rs`), and it
5//! takes the union of their behaviour. Both dependents drive it; neither owns a copy.
6//!
7//! Unlike an earlier design, the tracing callsites live **here** rather than in the binding
8//! crates. `tracing` bakes a span's name and target into a `static Metadata`, so they cannot be
9//! passed in at runtime; keeping them here means one target, `http_streams_core`, with the
10//! direction carried as a span field instead.
11
12use crate::error::StreamError;
13use bytes::Bytes;
14use futures::stream::{Stream, TryStreamExt};
15use std::pin::Pin;
16use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
17use std::sync::Arc;
18use std::task::{Context, Poll};
19use std::time::{Duration, Instant};
20
21/// Reported about once a second unless overridden.
22pub const DEFAULT_PROGRESS_INTERVAL: Duration = Duration::from_secs(1);
23
24/// Which HTTP message the body belongs to.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26#[non_exhaustive]
27pub enum Direction {
28    /// A request body: uploaded by a client, received by a server.
29    Request,
30    /// A response body: produced by a server, read by a client.
31    #[default]
32    Response,
33}
34
35impl Direction {
36    /// A short, stable name, reported as the `direction` tracing field.
37    pub fn as_str(&self) -> &'static str {
38        match self {
39            Direction::Request => "request",
40            Direction::Response => "response",
41        }
42    }
43}
44
45/// Which end of the connection this code is running on.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47#[non_exhaustive]
48pub enum Side {
49    /// Running in an HTTP client.
50    #[default]
51    Client,
52    /// Running in an HTTP server.
53    Server,
54}
55
56impl Side {
57    /// A short, stable name, reported as the `side` tracing field.
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            Side::Client => "client",
61            Side::Server => "server",
62        }
63    }
64}
65
66/// How a stream ended.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum StreamOutcome {
70    /// Still running; reported by interim progress only.
71    InProgress,
72    /// Reached the end of the body with no errors.
73    Completed,
74    /// Ended early because the other side of the stream went away.
75    Aborted,
76    /// Reached its end, but at least one error was reported along the way.
77    Failed,
78}
79
80impl StreamOutcome {
81    /// A short, stable name, reported as the `outcome` tracing field.
82    pub fn as_str(&self) -> &'static str {
83        match self {
84            StreamOutcome::InProgress => "in_progress",
85            StreamOutcome::Completed => "completed",
86            StreamOutcome::Aborted => "aborted",
87            StreamOutcome::Failed => "failed",
88        }
89    }
90}
91
92/// A snapshot of one stream's accounting.
93#[derive(Debug, Clone, Copy)]
94#[non_exhaustive]
95pub struct StreamProgress {
96    /// Items encoded or decoded so far.
97    pub items: u64,
98    /// Body bytes transferred so far.
99    pub bytes: u64,
100    /// Errors reported so far. Not every error is terminal.
101    pub errors: u64,
102    /// Time since the stream was created.
103    pub elapsed: Duration,
104    /// How the stream ended, or [`InProgress`] if it has not.
105    ///
106    /// [`InProgress`]: StreamOutcome::InProgress
107    pub outcome: StreamOutcome,
108}
109
110/// Called for every error reported by a stream.
111pub type StreamErrorHandler = Arc<dyn Fn(&StreamError) + Send + Sync + 'static>;
112
113/// Called for every progress report, interim and terminal.
114pub type StreamProgressHandler = Arc<dyn Fn(&StreamProgress) + Send + Sync + 'static>;
115
116/// The direction-neutral subset of the dependents' options structs.
117///
118/// Both `StreamBodyAsOptions` and `ReqwestStreamOptions` keep their own definitions, because they
119/// carry direction-specific fields, and neither could add inherent builder methods to a type
120/// defined here (E0116). Each builds one of these internally instead.
121#[derive(Clone)]
122#[non_exhaustive]
123pub struct ProgressOptions {
124    /// Invoked for every error reported by the stream.
125    pub on_error: Option<StreamErrorHandler>,
126    /// Invoked for every progress report.
127    pub on_progress: Option<StreamProgressHandler>,
128    /// How often to report interim progress. `None` disables the time trigger.
129    pub progress_interval: Option<Duration>,
130    /// Report interim progress every N items. `None` disables the item trigger.
131    pub progress_items: Option<u64>,
132}
133
134impl ProgressOptions {
135    /// Default options: report about once a second, no item trigger, no callbacks.
136    pub fn new() -> Self {
137        Self {
138            on_error: None,
139            on_progress: None,
140            progress_interval: Some(DEFAULT_PROGRESS_INTERVAL),
141            progress_items: None,
142        }
143    }
144
145    /// Set the error callback.
146    pub fn on_error(mut self, handler: StreamErrorHandler) -> Self {
147        self.on_error = Some(handler);
148        self
149    }
150
151    /// Set the progress callback.
152    pub fn on_progress(mut self, handler: StreamProgressHandler) -> Self {
153        self.on_progress = Some(handler);
154        self
155    }
156
157    /// Set the interim reporting interval.
158    pub fn progress_interval(mut self, interval: Duration) -> Self {
159        self.progress_interval = Some(interval);
160        self
161    }
162
163    /// Report interim progress every `items` items.
164    pub fn progress_items(mut self, items: u64) -> Self {
165        self.progress_items = Some(items);
166        self
167    }
168}
169
170impl Default for ProgressOptions {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176/// What a stream is about, for the span covering it.
177///
178/// Fields the caller cannot know are simply left `None` and are omitted from the span rather
179/// than reported as a placeholder.
180#[derive(Debug, Clone, Default)]
181#[non_exhaustive]
182pub struct StreamContext {
183    /// The format's [`format_name`](crate::StreamFormat::format_name).
184    ///
185    /// `Cow` rather than `&'static str` because `axum-streams`' long-standing public
186    /// `StreamingFormat::format_name` returns a borrowed `&str`, and that signature cannot
187    /// change without breaking the third-party implementations it was made public for.
188    /// Core's own formats all return `&'static str` and so allocate nothing.
189    pub format: std::borrow::Cow<'static, str>,
190    /// Request or response body.
191    pub direction: Direction,
192    /// Client or server.
193    pub side: Side,
194    /// Response status, where one is known.
195    pub status: Option<u16>,
196    /// Declared body length, where one is known.
197    pub content_length: Option<u64>,
198    /// The negotiated content type, where one is known.
199    pub content_type: Option<String>,
200    /// The per-object limit in force, where one applies.
201    pub max_obj_len: Option<usize>,
202    /// The read-buffer size in force, where one applies.
203    pub buf_capacity: Option<usize>,
204}
205
206impl StreamContext {
207    /// A context for `format`, in the given direction and on the given side.
208    pub fn new(
209        format: impl Into<std::borrow::Cow<'static, str>>,
210        direction: Direction,
211        side: Side,
212    ) -> Self {
213        Self {
214            format: format.into(),
215            direction,
216            side,
217            ..Default::default()
218        }
219    }
220
221    /// Record the response status.
222    pub fn status(mut self, status: u16) -> Self {
223        self.status = Some(status);
224        self
225    }
226
227    /// Record the declared body length.
228    pub fn content_length(mut self, len: Option<u64>) -> Self {
229        self.content_length = len;
230        self
231    }
232
233    /// Record the content type.
234    pub fn content_type(mut self, ct: impl Into<String>) -> Self {
235        self.content_type = Some(ct.into());
236        self
237    }
238
239    /// Record the per-object limit.
240    pub fn max_obj_len(mut self, len: usize) -> Self {
241        self.max_obj_len = Some(len);
242        self
243    }
244
245    /// Record the read-buffer size.
246    pub fn buf_capacity(mut self, cap: usize) -> Self {
247        self.buf_capacity = Some(cap);
248        self
249    }
250}
251
252/// What the accounting needs to know about an error passing through a stream.
253///
254/// Deliberately not `&StreamError`: a binding crate's pipeline carries that crate's own error
255/// type, `axum::Error` say, and it could not implement a trait from here for it anyway,
256/// since both the trait and the type would be foreign to it (orphan rule). Everything the
257/// accounting actually needs is a `Display` and, when available, the typed error.
258pub struct ErrorInfo<'a> {
259    display: &'a dyn std::fmt::Display,
260    stream_error: Option<&'a StreamError>,
261}
262
263impl<'a> ErrorInfo<'a> {
264    /// The error's `Display`, for the `error` tracing field.
265    pub fn display(&self) -> &dyn std::fmt::Display {
266        self.display
267    }
268
269    /// The typed error, when this stream carries [`StreamError`]s.
270    pub fn stream_error(&self) -> Option<&'a StreamError> {
271        self.stream_error
272    }
273
274    /// A short, stable kind name for the `error_kind` tracing field.
275    pub fn kind_str(&self) -> &'static str {
276        self.stream_error.map_or("unknown", |e| e.kind().as_str())
277    }
278}
279
280/// Lets [`instrument`] classify items without being generic over the item type.
281///
282/// A `T` that appeared only in a `where` clause would be an unconstrained type parameter
283/// (E0207), and a `PhantomData<T>` would drag `T`'s auto traits into the stream's type, which
284/// would break any binding whose `T` carries no `Send + 'b` bound even though the method it
285/// backs promises a `Send` stream. `reqwest-streams`' CSV reader is one such.
286pub trait ProgressItem {
287    /// The error this item carries, if it is one.
288    fn progress_error(&self) -> Option<ErrorInfo<'_>>;
289}
290
291/// Covers every `Result` whose error is a standard error, so binding crates get this for their
292/// own error types without implementing anything.
293impl<T, E> ProgressItem for Result<T, E>
294where
295    E: std::error::Error + 'static,
296{
297    fn progress_error(&self) -> Option<ErrorInfo<'_>> {
298        self.as_ref().err().map(|err| ErrorInfo {
299            display: err,
300            // Resolves at compile time for any concrete `E`, so this costs nothing when the
301            // stream does not carry `StreamError`s.
302            stream_error: (err as &dyn std::any::Any).downcast_ref::<StreamError>(),
303        })
304    }
305}
306
307/// Checked at `ERROR`, the least verbose level the accounting can produce: a failed stream
308/// reports there, so gating any higher would mean `RUST_LOG=http_streams_core=error` silently
309/// loses the totals of the very streams it asked about. Every more verbose filter enables
310/// `ERROR` too, so this can never suppress wanted output.
311#[cfg(feature = "tracing")]
312fn tracing_enabled() -> bool {
313    tracing::enabled!(target: "http_streams_core", tracing::Level::ERROR)
314}
315
316#[cfg(not(feature = "tracing"))]
317fn tracing_enabled() -> bool {
318    false
319}
320
321/// Shared accounting for one streamed body.
322///
323/// Bytes are counted on the byte stream and items on the item stream, so the two counters live
324/// in different combinators and share this state. The ordering is `Relaxed` throughout: these
325/// are counters, not synchronisation.
326struct ProgressState {
327    items: AtomicU64,
328    bytes: AtomicU64,
329    errors: AtomicU64,
330    last_emit_micros: AtomicU64,
331    next_item_step: AtomicU64,
332    polled: AtomicBool,
333    finalized: AtomicBool,
334    start: Instant,
335    interval_micros: Option<u64>,
336    item_step: Option<u64>,
337    on_error: Option<StreamErrorHandler>,
338    on_progress: Option<StreamProgressHandler>,
339    #[cfg(feature = "tracing")]
340    span: tracing::Span,
341}
342
343impl ProgressState {
344    /// Returns `None` when nobody is listening, in which case every accounting call below
345    /// short-circuits on a single `Option` check.
346    #[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
347    fn maybe_new(context: &StreamContext, options: &ProgressOptions) -> Option<Arc<Self>> {
348        if options.on_progress.is_none() && options.on_error.is_none() && !tracing_enabled() {
349            return None;
350        }
351
352        // A step of zero would never advance, so treat it as "disabled" rather than looping.
353        let item_step = options.progress_items.filter(|step| *step > 0);
354
355        Some(Arc::new(Self {
356            items: AtomicU64::new(0),
357            bytes: AtomicU64::new(0),
358            errors: AtomicU64::new(0),
359            last_emit_micros: AtomicU64::new(0),
360            next_item_step: AtomicU64::new(item_step.unwrap_or(u64::MAX)),
361            polled: AtomicBool::new(false),
362            finalized: AtomicBool::new(false),
363            start: Instant::now(),
364            interval_micros: options
365                .progress_interval
366                .map(|interval| interval.as_micros() as u64),
367            item_step,
368            on_error: options.on_error.clone(),
369            on_progress: options.on_progress.clone(),
370            #[cfg(feature = "tracing")]
371            span: Self::new_span(context),
372        }))
373    }
374
375    /// The span covering the whole stream, created by the caller while its own span is still
376    /// current, so collectors nest it under their request rather than orphaning it. The stream
377    /// itself is polled later, potentially from an entirely different task.
378    ///
379    /// Every counter is declared up front as an empty field so it can be filled in later with
380    /// [`tracing::Span::record`]: collectors that read span attributes (OpenTelemetry and
381    /// friends) then see `items`/`bytes`/`outcome` as structured values on a span whose
382    /// duration is the streaming duration, instead of having to parse log messages.
383    ///
384    /// No URL is recorded, deliberately: it carries query strings and userinfo, which
385    /// routinely means presigned-URL signatures and `?api_key=`.
386    #[cfg(feature = "tracing")]
387    fn new_span(context: &StreamContext) -> tracing::Span {
388        let span = tracing::info_span!(
389            target: "http_streams_core",
390            "http_streams_core::stream",
391            format = context.format.as_ref(),
392            direction = context.direction.as_str(),
393            side = context.side.as_str(),
394            // `Option` is a `Value` that simply skips the field when it is empty.
395            status = context.status,
396            content_length = context.content_length,
397            content_type = context.content_type.as_deref(),
398            max_obj_len = tracing::field::Empty,
399            buf_capacity = tracing::field::Empty,
400            items = tracing::field::Empty,
401            bytes = tracing::field::Empty,
402            errors = tracing::field::Empty,
403            elapsed_ms = tracing::field::Empty,
404            outcome = tracing::field::Empty,
405        );
406
407        // `usize::MAX` means "no limit", which is noise rather than information.
408        if let Some(max) = context.max_obj_len.filter(|m| *m != usize::MAX) {
409            span.record("max_obj_len", max as u64);
410        }
411        if let Some(cap) = context.buf_capacity {
412            span.record("buf_capacity", cap as u64);
413        }
414
415        span
416    }
417
418    fn record_bytes(&self, len: u64) {
419        let bytes = self.bytes.fetch_add(len, Ordering::Relaxed) + len;
420        let items = self.items.load(Ordering::Relaxed);
421
422        #[cfg(feature = "tracing")]
423        tracing::trace!(
424            target: "http_streams_core",
425            parent: &self.span,
426            chunk_bytes = len,
427            items,
428            bytes,
429            "Transferred an HTTP body chunk"
430        );
431
432        // Progress is driven from transferred bytes as well as from items, because a single
433        // item can take a long time: one large Arrow batch, or a JSON array streamed slowly,
434        // would otherwise report nothing at all until it completed. Emitting resets the
435        // interval, so a chunk and an item cannot both report for the same tick.
436        if !self.finalized.load(Ordering::Relaxed) && self.should_emit_elapsed() {
437            self.emit(
438                StreamOutcome::InProgress,
439                items,
440                bytes,
441                self.errors.load(Ordering::Relaxed),
442            );
443        }
444    }
445
446    fn record_item(&self) {
447        let items = self.items.fetch_add(1, Ordering::Relaxed) + 1;
448
449        // Nothing may be reported after the summary, or the final snapshot would no longer be
450        // final. A consumer is free to keep polling a stream past its end.
451        if !self.finalized.load(Ordering::Relaxed) && self.should_emit_items(items) {
452            self.emit(
453                StreamOutcome::InProgress,
454                items,
455                self.bytes.load(Ordering::Relaxed),
456                self.errors.load(Ordering::Relaxed),
457            );
458        }
459    }
460
461    /// Errors are reported as they happen but are deliberately **not** terminal.
462    ///
463    /// Only some of them are: `FramedRead` latches its own error state and ends the stream,
464    /// but the JSON Lines and CSV formats produce their decoding errors from a successfully
465    /// framed line, and the stream carries on to the next one. Finalising here would stop
466    /// counting the remaining items of a stream that is still perfectly healthy, so the
467    /// terminal outcome is decided at the end instead, from this counter.
468    fn record_error(&self, info: &ErrorInfo<'_>) {
469        self.errors.fetch_add(1, Ordering::Relaxed);
470
471        #[cfg(feature = "tracing")]
472        tracing::error!(
473            target: "http_streams_core",
474            parent: &self.span,
475            error = %info.display(),
476            error_kind = info.kind_str(),
477            "An error occurred while streaming an HTTP body"
478        );
479
480        // Only fires for streams that carry `StreamError`s. A binding whose pipeline uses its
481        // own error type reports through its own typed callback instead, so that the closure
482        // its users already wrote keeps compiling.
483        if let (Some(handler), Some(err)) = (&self.on_error, info.stream_error()) {
484            handler(err);
485        }
486    }
487
488    /// The time trigger, checked when bytes move.
489    ///
490    /// The two triggers are deliberately driven from *different* signals rather than both
491    /// being checked everywhere. Bytes are what keeps flowing regardless of how the payload
492    /// divides into items (a single large item, or a slow one, still reports), so elapsed time
493    /// is checked here. Items drive the item-step trigger below.
494    ///
495    /// Checking both from both places double-reports: every pipeline counts the same payload
496    /// twice, once as items and once as bytes, so a zero interval would emit two events per
497    /// unit rather than one.
498    fn should_emit_elapsed(&self) -> bool {
499        let Some(interval) = self.interval_micros else {
500            return false;
501        };
502
503        let elapsed = self.start.elapsed().as_micros() as u64;
504        let since_last = elapsed.saturating_sub(self.last_emit_micros.load(Ordering::Relaxed));
505        if since_last >= interval {
506            self.last_emit_micros.store(elapsed, Ordering::Relaxed);
507            return true;
508        }
509
510        false
511    }
512
513    /// The item-step trigger, checked when items are counted.
514    fn should_emit_items(&self, items: u64) -> bool {
515        let Some(step) = self.item_step else {
516            return false;
517        };
518
519        if items < self.next_item_step.load(Ordering::Relaxed) {
520            return false;
521        }
522
523        // Skip past every step the current count already crossed, so a single poll carrying
524        // many items cannot queue up a burst of events.
525        self.next_item_step
526            .store(items - (items % step) + step, Ordering::Relaxed);
527
528        // Emitting resets the time trigger as well, so a step and a tick that fall together
529        // produce one event rather than two.
530        self.last_emit_micros
531            .store(self.start.elapsed().as_micros() as u64, Ordering::Relaxed);
532
533        true
534    }
535
536    fn mark_polled(&self) {
537        self.polled.store(true, Ordering::Relaxed);
538    }
539
540    /// Emits the terminal snapshot, exactly once per stream.
541    ///
542    /// A stream that was never polled reports nothing at all. Building one and dropping it
543    /// unconsumed is routine (a `?` short-circuits, a handler returns early), and reporting
544    /// those as aborted would bury the real ones in `items=0 bytes=0` noise.
545    fn finalize(&self, aborted: bool) {
546        if !self.polled.load(Ordering::Relaxed) || self.finalized.swap(true, Ordering::Relaxed) {
547            return;
548        }
549
550        let items = self.items.load(Ordering::Relaxed);
551        let bytes = self.bytes.load(Ordering::Relaxed);
552        let errors = self.errors.load(Ordering::Relaxed);
553
554        let outcome = if errors > 0 {
555            StreamOutcome::Failed
556        } else if aborted {
557            StreamOutcome::Aborted
558        } else {
559            StreamOutcome::Completed
560        };
561
562        // Recorded once, here rather than on every progress report: subscribers are free to
563        // treat `record` as append-only (`tracing-subscriber`'s formatter does), so writing a
564        // field repeatedly makes the rendered span grow with every tick. Once per span also
565        // means the values a collector reads are the final ones.
566        #[cfg(feature = "tracing")]
567        {
568            self.span.record("items", items);
569            self.span.record("bytes", bytes);
570            self.span.record("errors", errors);
571            self.span
572                .record("elapsed_ms", self.start.elapsed().as_millis() as u64);
573            self.span.record("outcome", outcome.as_str());
574        }
575
576        self.emit(outcome, items, bytes, errors);
577    }
578
579    fn emit(&self, outcome: StreamOutcome, items: u64, bytes: u64, errors: u64) {
580        let progress = StreamProgress {
581            items,
582            bytes,
583            errors,
584            elapsed: self.start.elapsed(),
585            outcome,
586        };
587
588        #[cfg(feature = "tracing")]
589        {
590            let elapsed_ms = progress.elapsed.as_millis() as u64;
591
592            match outcome {
593                // Interim progress is chatter; the summary is the line worth keeping, and a
594                // truncated stream is worth an operator's attention.
595                StreamOutcome::InProgress => tracing::debug!(
596                    target: "http_streams_core",
597                    parent: &self.span,
598                    items,
599                    bytes,
600                    elapsed_ms,
601                    "Streaming an HTTP body"
602                ),
603                StreamOutcome::Failed => tracing::error!(
604                    target: "http_streams_core",
605                    parent: &self.span,
606                    items,
607                    bytes,
608                    errors,
609                    elapsed_ms,
610                    outcome = outcome.as_str(),
611                    "Failed streaming an HTTP body"
612                ),
613                // Completed, and aborted: an end that stops early is ordinary.
614                _ => tracing::info!(
615                    target: "http_streams_core",
616                    parent: &self.span,
617                    items,
618                    bytes,
619                    errors,
620                    elapsed_ms,
621                    outcome = outcome.as_str(),
622                    "Finished streaming an HTTP body"
623                ),
624            }
625        }
626
627        if let Some(handler) = &self.on_progress {
628            handler(&progress);
629        }
630    }
631}
632
633/// The accounting handle threaded through one stream's pipeline.
634///
635/// `None` inside means nobody is listening and every method is a no-op. Cheap to clone.
636#[derive(Clone)]
637pub struct Progress(Option<Arc<ProgressState>>);
638
639impl Progress {
640    /// Build a handle for one stream.
641    ///
642    /// Call this while the caller's own tracing span is still current, and before the body is
643    /// consumed, so the span nests correctly and the context fields are still available.
644    pub fn new(context: &StreamContext, options: &ProgressOptions) -> Self {
645        Progress(ProgressState::maybe_new(context, options))
646    }
647
648    /// A handle that reports nothing.
649    pub fn disabled() -> Self {
650        Progress(None)
651    }
652
653    /// Whether anything is listening. Useful to skip work that only feeds reporting.
654    pub fn is_enabled(&self) -> bool {
655        self.0.is_some()
656    }
657
658    /// Count one item.
659    ///
660    /// A no-op when nobody is listening. Provided for bindings that cannot use
661    /// [`count_items`] because their pipeline's item type carries no `'static`-ish bound:
662    /// applying a combinator would force one onto their public signature.
663    pub fn record_item(&self) {
664        if let Some(state) = &self.0 {
665            state.record_item();
666        }
667    }
668
669    /// Count `len` transferred bytes.
670    ///
671    /// A no-op when nobody is listening. The combinator form is [`count_bytes`].
672    pub fn record_bytes(&self, len: u64) {
673        if let Some(state) = &self.0 {
674            state.record_bytes(len);
675        }
676    }
677}
678
679/// Whether [`instrument`] should count the items it sees.
680///
681/// The outermost stream of an encode pipeline yields `Bytes` chunks, not items, so counting
682/// its `Ok`s would report a JSON array's `[` and `]` as items. In that case items are counted
683/// upstream by [`count_items`] and this is set to [`Bytes`].
684///
685/// [`Bytes`]: Counting::Bytes
686#[derive(Debug, Clone, Copy, PartialEq, Eq)]
687pub enum Counting {
688    /// Each `Ok` is one item.
689    Items,
690    /// Each `Ok` is a byte chunk; items are counted elsewhere.
691    Bytes,
692}
693
694/// Counts the bytes flowing through a byte stream.
695///
696/// Applied to the byte stream rather than the item stream so that `bytes` is what actually
697/// crossed the wire, independently of how many objects that turned into.
698pub fn count_bytes<'b, S, E>(
699    stream: S,
700    progress: &Progress,
701) -> impl Stream<Item = Result<Bytes, E>> + Send + 'b
702where
703    S: Stream<Item = Result<Bytes, E>> + Send + 'b,
704    E: 'b,
705{
706    let progress = progress.clone();
707    stream.inspect_ok(move |chunk| {
708        if let Some(state) = &progress.0 {
709            state.record_bytes(chunk.len() as u64);
710        }
711    })
712}
713
714/// Counts items flowing through an item stream, without touching errors or the outcome.
715///
716/// Used on the encode side, where items exist only upstream of the encoder. This is the one
717/// place they are still items rather than bytes.
718pub fn count_items<'b, S, T, E>(
719    stream: S,
720    progress: &Progress,
721) -> impl Stream<Item = Result<T, E>> + Send + 'b
722where
723    S: Stream<Item = Result<T, E>> + Send + 'b,
724    // Deliberately no `T: Send`. The *stream* must be `Send`; its item type need not be, and
725    // requiring it would break callers whose `T` carries no such bound. Same reasoning as
726    // [`ProgressItem`].
727    T: 'b,
728    E: 'b,
729{
730    let progress = progress.clone();
731    stream.inspect_ok(move |_| {
732        if let Some(state) = &progress.0 {
733            state.record_item();
734        }
735    })
736}
737
738/// Reports errors, owns the outcome state machine, and optionally counts items.
739///
740/// Wrap the **outermost** stream with this: every error passes through there, and its `Drop`
741/// is the only way to notice an end that stopped early.
742pub fn instrument<'b, S>(
743    stream: S,
744    progress: Progress,
745    counting: Counting,
746) -> impl Stream<Item = S::Item> + Send + 'b
747where
748    S: Stream + Unpin + Send + 'b,
749    S::Item: ProgressItem,
750{
751    ProgressStream {
752        inner: stream,
753        progress,
754        counting,
755    }
756}
757
758struct ProgressStream<S> {
759    inner: S,
760    progress: Progress,
761    counting: Counting,
762}
763
764impl<S> Stream for ProgressStream<S>
765where
766    S: Stream + Unpin,
767    S::Item: ProgressItem,
768{
769    type Item = S::Item;
770
771    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
772        // Safe without any projection: `Self: Unpin` whenever `S: Unpin`, which is exactly the
773        // bound above. That keeps `#![forbid(unsafe_code)]` intact.
774        let this = self.get_mut();
775
776        // Borrowed, not cloned: `progress` and `inner` are disjoint fields, so this avoids an
777        // atomic refcount bump on every single poll.
778        let Some(state) = this.progress.0.as_ref() else {
779            return Pin::new(&mut this.inner).poll_next(cx);
780        };
781
782        // Polling here drives the whole pipeline synchronously, so entering the span gives
783        // everything it touches the stream's context. `poll_next` is synchronous, so this
784        // guard is never held across an await.
785        #[cfg(feature = "tracing")]
786        let _entered = state.span.enter();
787
788        state.mark_polled();
789
790        match Pin::new(&mut this.inner).poll_next(cx) {
791            Poll::Ready(Some(item)) => {
792                match item.progress_error() {
793                    Some(info) => state.record_error(&info),
794                    None => {
795                        if this.counting == Counting::Items {
796                            state.record_item();
797                        }
798                    }
799                }
800                Poll::Ready(Some(item))
801            }
802            Poll::Ready(None) => {
803                state.finalize(false);
804                Poll::Ready(None)
805            }
806            Poll::Pending => Poll::Pending,
807        }
808    }
809}
810
811impl<S> Drop for ProgressStream<S> {
812    fn drop(&mut self) {
813        if let Some(state) = &self.progress.0 {
814            // A no-op when the stream already ran to completion, or was never polled.
815            state.finalize(true);
816        }
817    }
818}