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}