Skip to main content

reqwest_streams/
observability.rs

1//! Observing how much of a streamed response actually arrived.
2//!
3//! A streaming response is consumed lazily, long after the call that created it returned, so
4//! nothing at the call site can say how much of it arrived, whether it was truncated by a
5//! decode error, or that the consumer walked away after ten items. This module accounts for
6//! all of that and reports it two ways: through the [`on_progress`] / [`on_error`] callbacks,
7//! which are always available, and through `tracing` when the `tracing` feature is enabled.
8//!
9//! [`on_progress`]: ReqwestStreamOptions::on_progress
10//! [`on_error`]: ReqwestStreamOptions::on_error
11
12use crate::error::StreamBodyError;
13use crate::StreamBodyResult;
14use bytes::Bytes;
15use futures::{Stream, TryStreamExt};
16use std::pin::Pin;
17use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
18use std::sync::Arc;
19use std::task::{Context, Poll};
20use std::time::{Duration, Instant};
21
22/// This is the default capacity of the buffer used by `StreamReader`.
23pub(crate) const INITIAL_CAPACITY: usize = 8 * 1024;
24
25/// How often progress is reported when it is not otherwise configured.
26///
27/// Time-based rather than item-based on purpose: the volume of progress reports is then bound
28/// by how long the stream runs and not by how much it carries, so even a multi-million item
29/// stream cannot flood the logs.
30const DEFAULT_PROGRESS_INTERVAL: Duration = Duration::from_secs(1);
31
32/// How a streamed response ended, or that it is still going.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum ReqwestStreamOutcome {
36    /// A periodic snapshot: the response is still being read.
37    InProgress,
38    /// The response body ended and every item was handed over.
39    Completed,
40    /// The stream ended after at least one error, so items are missing.
41    Failed,
42    /// The stream was dropped before the body ended, typically because the consumer stopped
43    /// reading early.
44    Aborted,
45}
46
47impl ReqwestStreamOutcome {
48    /// The value used for the `outcome` tracing field.
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            ReqwestStreamOutcome::InProgress => "in_progress",
52            ReqwestStreamOutcome::Completed => "completed",
53            ReqwestStreamOutcome::Failed => "failed",
54            ReqwestStreamOutcome::Aborted => "aborted",
55        }
56    }
57}
58
59/// A snapshot of how much of a streamed response has been read so far.
60///
61/// `items` counts the objects successfully decoded, so an item that failed to decode is not
62/// counted. Note that an item is whatever the format produces: for the Arrow format that is a
63/// `RecordBatch`, not a row.
64///
65/// `bytes` counts the body bytes reqwest handed over, which is *after* transfer- and
66/// content-decoding. If you enabled reqwest's `gzip` or `brotli` features this is therefore
67/// the decompressed size, not the size on the wire.
68#[derive(Debug, Clone, Copy)]
69pub struct ReqwestStreamProgress {
70    pub items: u64,
71    pub bytes: u64,
72    pub errors: u64,
73    pub elapsed: Duration,
74    pub outcome: ReqwestStreamOutcome,
75}
76
77/// A callback invoked for every error produced while reading a streamed response.
78pub type ReqwestStreamErrorHandler = Arc<dyn Fn(&StreamBodyError) + Send + Sync + 'static>;
79
80/// A callback invoked with progress snapshots while reading a streamed response.
81pub type ReqwestStreamProgressHandler = Arc<dyn Fn(&ReqwestStreamProgress) + Send + Sync + 'static>;
82
83/// Options shared by every streaming format.
84///
85/// Build these with [`ReqwestStreamOptions::new`] and the setters below rather than with a
86/// struct literal, so that later options can be added without breaking you.
87///
88/// # Note on `max_obj_len`
89///
90/// Unlike the positional-argument methods, which make you choose a limit, a freshly built
91/// `ReqwestStreamOptions` does **not** limit object size — [`max_obj_len`] defaults to
92/// [`usize::MAX`]. Set it explicitly when reading from a source you do not control.
93///
94/// [`max_obj_len`]: ReqwestStreamOptions::max_obj_len
95#[non_exhaustive]
96pub struct ReqwestStreamOptions {
97    pub max_obj_len: usize,
98    pub buf_capacity: usize,
99    pub on_error: Option<ReqwestStreamErrorHandler>,
100    pub on_progress: Option<ReqwestStreamProgressHandler>,
101    pub progress_interval: Option<Duration>,
102    pub progress_items: Option<u64>,
103}
104
105impl Default for ReqwestStreamOptions {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl ReqwestStreamOptions {
112    pub fn new() -> Self {
113        Self {
114            max_obj_len: usize::MAX,
115            buf_capacity: INITIAL_CAPACITY,
116            on_error: None,
117            on_progress: None,
118            progress_interval: Some(DEFAULT_PROGRESS_INTERVAL),
119            progress_items: None,
120        }
121    }
122
123    /// The maximum size in bytes of a single decoded object.
124    ///
125    /// [`usize::MAX`], the default, means no limit.
126    pub fn max_obj_len(mut self, max_obj_len: usize) -> Self {
127        self.max_obj_len = max_obj_len;
128        self
129    }
130
131    /// The initial capacity of the stream's decoding buffer.
132    pub fn buf_capacity(mut self, buf_capacity: usize) -> Self {
133        self.buf_capacity = buf_capacity;
134        self
135    }
136
137    /// Registers a callback invoked for every error produced while reading the response,
138    /// covering both transport errors and decoding errors produced by the format itself.
139    ///
140    /// The error is still yielded by the stream; this is purely an observation hook. It does
141    /// not replace the `tracing` feature: when that feature is enabled both the log event and
142    /// this callback fire.
143    pub fn on_error<F>(mut self, handler: F) -> Self
144    where
145        F: Fn(&StreamBodyError) + Send + Sync + 'static,
146    {
147        self.on_error = Some(Arc::new(handler));
148        self
149    }
150
151    /// Registers a callback receiving progress snapshots while the response is read: one per
152    /// reporting interval or item step, plus a final one carrying the totals and how the
153    /// stream ended (completed, failed, or aborted because the consumer stopped reading).
154    ///
155    /// This is the same accounting the `tracing` feature reports, exposed for metrics: wire it
156    /// to a counter and you get streamed items and bytes without depending on tracing at all.
157    /// When the feature is enabled both happen.
158    ///
159    /// The counters are only maintained when someone is listening, so a stream with no
160    /// callback and no `tracing` subscriber interested in `reqwest_streams` pays nothing.
161    pub fn on_progress<F>(mut self, handler: F) -> Self
162    where
163        F: Fn(&ReqwestStreamProgress) + Send + Sync + 'static,
164    {
165        self.on_progress = Some(Arc::new(handler));
166        self
167    }
168
169    /// Reports progress at most once per `interval` (one second by default).
170    ///
171    /// Set the field to `None` directly to report on item steps only.
172    pub fn progress_interval(mut self, interval: Duration) -> Self {
173        self.progress_interval = Some(interval);
174        self
175    }
176
177    /// Additionally reports progress every `items` items.
178    ///
179    /// Off by default, and deliberately so: it is a linear step, so a large stream reports a
180    /// number of times proportional to its size. Prefer [`Self::progress_interval`] unless you
181    /// specifically want item-granular checkpoints.
182    pub fn progress_items(mut self, items: u64) -> Self {
183        self.progress_items = Some(items);
184        self
185    }
186}
187
188/// Lets [`ProgressStream`] classify items without being generic over the item type.
189///
190/// A `T` that appeared only in a `where` clause would be an unconstrained type parameter
191/// (E0207), and a `PhantomData<T>` would drag `T`'s auto traits into the stream's type — which
192/// would break `csv_stream`, whose `T` carries no `Send + 'b` bound even though the method
193/// promises them.
194pub(crate) trait ProgressItem {
195    fn stream_error(&self) -> Option<&StreamBodyError>;
196}
197
198impl<T> ProgressItem for StreamBodyResult<T> {
199    fn stream_error(&self) -> Option<&StreamBodyError> {
200        self.as_ref().err()
201    }
202}
203
204/// Shared accounting for one streamed response.
205///
206/// Bytes are counted on the body stream and items on the decoded stream, so the two counters
207/// live in different combinators and share this state. The ordering is `Relaxed` throughout:
208/// these are counters, not synchronisation.
209struct ProgressState {
210    items: AtomicU64,
211    bytes: AtomicU64,
212    errors: AtomicU64,
213    last_emit_micros: AtomicU64,
214    next_item_step: AtomicU64,
215    polled: AtomicBool,
216    finalized: AtomicBool,
217    start: Instant,
218    interval_micros: Option<u64>,
219    item_step: Option<u64>,
220    on_error: Option<ReqwestStreamErrorHandler>,
221    on_progress: Option<ReqwestStreamProgressHandler>,
222    #[cfg(feature = "tracing")]
223    span: tracing::Span,
224}
225
226/// Checked at `ERROR`, the least verbose level the accounting can produce: a failed stream
227/// reports there, so gating any higher would mean `RUST_LOG=reqwest_streams=error` silently
228/// loses the totals of the very streams it asked about. Every more verbose filter enables
229/// `ERROR` too, so this can never suppress wanted output.
230#[cfg(feature = "tracing")]
231fn tracing_enabled() -> bool {
232    tracing::enabled!(target: "reqwest_streams", tracing::Level::ERROR)
233}
234
235#[cfg(not(feature = "tracing"))]
236fn tracing_enabled() -> bool {
237    false
238}
239
240impl ProgressState {
241    /// Returns `None` when nobody is listening, in which case every accounting call below
242    /// short-circuits on a single `Option` check.
243    #[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
244    fn maybe_new(
245        format: &'static str,
246        response: &reqwest::Response,
247        options: &ReqwestStreamOptions,
248    ) -> Option<Arc<Self>> {
249        if options.on_progress.is_none() && options.on_error.is_none() && !tracing_enabled() {
250            return None;
251        }
252
253        // A step of zero would never advance, so treat it as "disabled" rather than looping.
254        let item_step = options.progress_items.filter(|step| *step > 0);
255
256        Some(Arc::new(Self {
257            items: AtomicU64::new(0),
258            bytes: AtomicU64::new(0),
259            errors: AtomicU64::new(0),
260            last_emit_micros: AtomicU64::new(0),
261            next_item_step: AtomicU64::new(item_step.unwrap_or(u64::MAX)),
262            polled: AtomicBool::new(false),
263            finalized: AtomicBool::new(false),
264            start: Instant::now(),
265            interval_micros: options
266                .progress_interval
267                .map(|interval| interval.as_micros() as u64),
268            item_step,
269            on_error: options.on_error.clone(),
270            on_progress: options.on_progress.clone(),
271            #[cfg(feature = "tracing")]
272            span: Self::new_span(format, response, options),
273        }))
274    }
275
276    /// The span covering the whole stream, created here, while the caller's own span is still
277    /// the current one, so collectors nest it under their request rather than orphaning it.
278    /// The stream itself is polled later, potentially from an entirely different task.
279    ///
280    /// Every counter is declared up front as an empty field so it can be filled in later with
281    /// [`tracing::Span::record`]: collectors that read span attributes (OpenTelemetry and
282    /// friends) then see `items`/`bytes`/`outcome` as structured values on a span whose
283    /// duration is the streaming duration, instead of having to parse log messages.
284    ///
285    /// The response is still owned at this point, which is a client-side opportunity the
286    /// server side does not have: `status` and `content_length` go on the span, and the
287    /// latter is what lets an operator turn `bytes` into a completion percentage. The URL is
288    /// deliberately *not* recorded — it carries query strings and userinfo, which routinely
289    /// means presigned-URL signatures and `?api_key=`.
290    #[cfg(feature = "tracing")]
291    fn new_span(
292        format: &'static str,
293        response: &reqwest::Response,
294        options: &ReqwestStreamOptions,
295    ) -> tracing::Span {
296        let span = tracing::info_span!(
297            target: "reqwest_streams",
298            "reqwest_streams::response_stream",
299            format = format,
300            status = response.status().as_u16(),
301            // `Option` is a `Value` that simply skips the field when it is empty.
302            content_length = response.content_length(),
303            max_obj_len = tracing::field::Empty,
304            buf_capacity = options.buf_capacity as u64,
305            items = tracing::field::Empty,
306            bytes = tracing::field::Empty,
307            errors = tracing::field::Empty,
308            elapsed_ms = tracing::field::Empty,
309            outcome = tracing::field::Empty,
310        );
311
312        // `usize::MAX` means "no limit", which is noise rather than information.
313        if options.max_obj_len != usize::MAX {
314            span.record("max_obj_len", options.max_obj_len as u64);
315        }
316
317        span
318    }
319
320    fn record_bytes(&self, len: u64) {
321        let bytes = self.bytes.fetch_add(len, Ordering::Relaxed) + len;
322        let items = self.items.load(Ordering::Relaxed);
323
324        #[cfg(feature = "tracing")]
325        tracing::trace!(
326            target: "reqwest_streams",
327            parent: &self.span,
328            chunk_bytes = len,
329            items,
330            bytes,
331            "Read an HTTP body chunk"
332        );
333
334        // Progress is driven from arriving bytes as well as from decoded items, because a
335        // single item can take a long time to arrive: one large Arrow batch, or a JSON array
336        // streamed slowly, would otherwise report nothing at all until it completed. Emitting
337        // resets the interval, so a chunk and an item cannot both report for the same tick.
338        if !self.finalized.load(Ordering::Relaxed) && self.should_emit(items) {
339            self.emit(
340                ReqwestStreamOutcome::InProgress,
341                items,
342                bytes,
343                self.errors.load(Ordering::Relaxed),
344            );
345        }
346    }
347
348    fn record_item(&self) {
349        let items = self.items.fetch_add(1, Ordering::Relaxed) + 1;
350
351        // Nothing may be reported after the summary, or the final snapshot would no longer be
352        // final. A consumer is free to keep polling a stream past its end.
353        if !self.finalized.load(Ordering::Relaxed) && self.should_emit(items) {
354            self.emit(
355                ReqwestStreamOutcome::InProgress,
356                items,
357                self.bytes.load(Ordering::Relaxed),
358                self.errors.load(Ordering::Relaxed),
359            );
360        }
361    }
362
363    /// Errors are reported as they happen but are deliberately **not** terminal.
364    ///
365    /// Only some of them are: `FramedRead` latches its own error state and ends the stream,
366    /// but the JSON Lines and CSV formats produce their decoding errors from a successfully
367    /// framed line, and the stream carries on to the next one. Finalising here would stop
368    /// counting the remaining items of a stream that is still perfectly healthy, so the
369    /// terminal outcome is decided at the end instead, from this counter.
370    fn record_error(&self, err: &StreamBodyError) {
371        self.errors.fetch_add(1, Ordering::Relaxed);
372
373        #[cfg(feature = "tracing")]
374        tracing::error!(
375            target: "reqwest_streams",
376            parent: &self.span,
377            error = %err,
378            error_kind = err.kind().as_str(),
379            "An error occurred while streaming an HTTP body"
380        );
381
382        if let Some(handler) = &self.on_error {
383            handler(err);
384        }
385    }
386
387    /// The two triggers are OR'd, and emitting resets both, so an item produces at most one
388    /// progress event.
389    fn should_emit(&self, items: u64) -> bool {
390        let mut emit = false;
391
392        if let Some(step) = self.item_step {
393            if items >= self.next_item_step.load(Ordering::Relaxed) {
394                // Skip past every step the current count already crossed, so a single poll
395                // carrying many items cannot queue up a burst of events.
396                self.next_item_step
397                    .store(items - (items % step) + step, Ordering::Relaxed);
398                emit = true;
399            }
400        }
401
402        if let Some(interval) = self.interval_micros {
403            let elapsed = self.start.elapsed().as_micros() as u64;
404            let since_last = elapsed.saturating_sub(self.last_emit_micros.load(Ordering::Relaxed));
405            if emit || since_last >= interval {
406                self.last_emit_micros.store(elapsed, Ordering::Relaxed);
407                emit = true;
408            }
409        }
410
411        emit
412    }
413
414    fn mark_polled(&self) {
415        self.polled.store(true, Ordering::Relaxed);
416    }
417
418    /// Emits the terminal snapshot, exactly once per stream.
419    ///
420    /// A stream that was never polled reports nothing at all. Building one and dropping it
421    /// unconsumed is routine on the client — a `?` short-circuits, a function returns early —
422    /// and reporting those as aborted would bury the real ones in `items=0 bytes=0` noise.
423    fn finalize(&self, aborted: bool) {
424        if !self.polled.load(Ordering::Relaxed) || self.finalized.swap(true, Ordering::Relaxed) {
425            return;
426        }
427
428        let items = self.items.load(Ordering::Relaxed);
429        let bytes = self.bytes.load(Ordering::Relaxed);
430        let errors = self.errors.load(Ordering::Relaxed);
431
432        let outcome = if errors > 0 {
433            ReqwestStreamOutcome::Failed
434        } else if aborted {
435            ReqwestStreamOutcome::Aborted
436        } else {
437            ReqwestStreamOutcome::Completed
438        };
439
440        // Recorded once, here rather than on every progress report: subscribers are free to
441        // treat `record` as append-only (`tracing-subscriber`'s formatter does), so writing a
442        // field repeatedly makes the rendered span grow with every tick. Once per span also
443        // means the values a collector reads are the final ones.
444        #[cfg(feature = "tracing")]
445        {
446            self.span.record("items", items);
447            self.span.record("bytes", bytes);
448            self.span.record("errors", errors);
449            self.span
450                .record("elapsed_ms", self.start.elapsed().as_millis() as u64);
451            self.span.record("outcome", outcome.as_str());
452        }
453
454        self.emit(outcome, items, bytes, errors);
455    }
456
457    fn emit(&self, outcome: ReqwestStreamOutcome, items: u64, bytes: u64, errors: u64) {
458        let progress = ReqwestStreamProgress {
459            items,
460            bytes,
461            errors,
462            elapsed: self.start.elapsed(),
463            outcome,
464        };
465
466        #[cfg(feature = "tracing")]
467        {
468            let elapsed_ms = progress.elapsed.as_millis() as u64;
469
470            match outcome {
471                // Interim progress is chatter; the summary is the line worth keeping, and a
472                // truncated stream is worth an operator's attention.
473                ReqwestStreamOutcome::InProgress => tracing::debug!(
474                    target: "reqwest_streams",
475                    parent: &self.span,
476                    items,
477                    bytes,
478                    elapsed_ms,
479                    "Streaming an HTTP body"
480                ),
481                ReqwestStreamOutcome::Failed => tracing::error!(
482                    target: "reqwest_streams",
483                    parent: &self.span,
484                    items,
485                    bytes,
486                    errors,
487                    elapsed_ms,
488                    outcome = outcome.as_str(),
489                    "Failed streaming an HTTP body"
490                ),
491                // Completed, and aborted: a consumer that stops reading early is ordinary.
492                _ => tracing::info!(
493                    target: "reqwest_streams",
494                    parent: &self.span,
495                    items,
496                    bytes,
497                    errors,
498                    elapsed_ms,
499                    outcome = outcome.as_str(),
500                    "Finished streaming an HTTP body"
501                ),
502            }
503        }
504
505        if let Some(handler) = &self.on_progress {
506            handler(&progress);
507        }
508    }
509}
510
511/// The accounting handle threaded through one stream's pipeline.
512///
513/// `None` inside means nobody is listening and every method is a no-op.
514#[derive(Clone)]
515pub(crate) struct Progress(Option<Arc<ProgressState>>);
516
517impl Progress {
518    /// Must be called before `bytes_stream()` consumes the response.
519    pub(crate) fn new(
520        format: &'static str,
521        response: &reqwest::Response,
522        options: &ReqwestStreamOptions,
523    ) -> Self {
524        Progress(ProgressState::maybe_new(format, response, options))
525    }
526}
527
528/// Counts the bytes of the response body.
529///
530/// Applied to the byte stream rather than the decoded one so that `bytes` is what actually
531/// arrived, independently of how many objects that turned into.
532pub(crate) fn count_bytes<'b, S>(
533    stream: S,
534    progress: &Progress,
535) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'b
536where
537    S: Stream<Item = Result<Bytes, std::io::Error>> + Send + 'b,
538{
539    let progress = progress.clone();
540    stream.inspect_ok(move |chunk| {
541        if let Some(state) = &progress.0 {
542            state.record_bytes(chunk.len() as u64);
543        }
544    })
545}
546
547/// Counts items, reports errors, and owns the outcome state machine.
548///
549/// It wraps the outermost stream on purpose: every format's errors pass through here, and its
550/// `Drop` is the only way to notice a consumer that stopped reading early.
551pub(crate) fn instrument<'b, S>(
552    stream: S,
553    progress: Progress,
554) -> impl Stream<Item = S::Item> + Send + 'b
555where
556    S: Stream + Unpin + Send + 'b,
557    S::Item: ProgressItem,
558{
559    ProgressStream {
560        inner: stream,
561        progress,
562    }
563}
564
565struct ProgressStream<S> {
566    inner: S,
567    progress: Progress,
568}
569
570impl<S> Stream for ProgressStream<S>
571where
572    S: Stream + Unpin,
573    S::Item: ProgressItem,
574{
575    type Item = S::Item;
576
577    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
578        // Safe without any projection: `Self: Unpin` whenever `S: Unpin`, which is exactly the
579        // bound above. That keeps `#![forbid(unsafe_code)]` intact.
580        let this = self.get_mut();
581
582        // Borrowed, not cloned: `progress` and `inner` are disjoint fields, so this avoids an
583        // atomic refcount bump on every single poll.
584        let Some(state) = this.progress.0.as_ref() else {
585            return Pin::new(&mut this.inner).poll_next(cx);
586        };
587
588        // Polling here drives the whole pipeline synchronously, reqwest and hyper included, so
589        // entering the span gives everything they log the stream's context. `poll_next` is
590        // synchronous, so this guard is never held across an await.
591        #[cfg(feature = "tracing")]
592        let _entered = state.span.enter();
593
594        state.mark_polled();
595
596        match Pin::new(&mut this.inner).poll_next(cx) {
597            Poll::Ready(Some(item)) => {
598                match item.stream_error() {
599                    Some(err) => state.record_error(err),
600                    None => state.record_item(),
601                }
602                Poll::Ready(Some(item))
603            }
604            Poll::Ready(None) => {
605                state.finalize(false);
606                Poll::Ready(None)
607            }
608            Poll::Pending => Poll::Pending,
609        }
610    }
611}
612
613impl<S> Drop for ProgressStream<S> {
614    fn drop(&mut self) {
615        if let Some(state) = &self.progress.0 {
616            // A no-op when the stream already ran to completion, or was never polled.
617            state.finalize(true);
618        }
619    }
620}