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}