use std::{
pin::Pin,
task::{Context, Poll},
time::Instant,
};
use futures::Stream;
use pin_project::pin_project;
use crate::{
LanguageModelError,
response::{StreamDelta, Usage},
};
pub(crate) const fn instrument_stream<S>(
span: tracing::Span,
started_at: Instant,
inner: S,
) -> InstrumentedStream<S>
where
S: Stream<Item = Result<StreamDelta, LanguageModelError>>,
{
InstrumentedStream {
inner,
span,
started_at,
first_seen: false,
last_usage: None,
}
}
#[pin_project(PinnedDrop)]
pub(crate) struct InstrumentedStream<S> {
#[pin]
inner: S,
span: tracing::Span,
started_at: Instant,
first_seen: bool,
last_usage: Option<Usage>,
}
impl<S> Stream for InstrumentedStream<S>
where
S: Stream<Item = Result<StreamDelta, LanguageModelError>>,
{
type Item = Result<StreamDelta, LanguageModelError>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.project();
let poll = this.inner.poll_next(cx);
if let Poll::Ready(Some(Ok(delta))) = &poll {
if !*this.first_seen {
let elapsed_ms =
u64::try_from(this.started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
this.span.record("first_token_ms", elapsed_ms);
*this.first_seen = true;
}
if let Some(usage) = delta.usage {
*this.last_usage = Some(usage);
}
}
poll
}
}
#[pin_project::pinned_drop]
impl<S> PinnedDrop for InstrumentedStream<S> {
fn drop(self: Pin<&mut Self>) {
let this = self.project();
if let Some(usage) = this.last_usage {
let cache_creation = usage.cache_creation_input_tokens;
let cache_read = usage.cache_read_input_tokens;
let prompt_total = usage.input_tokens + cache_read + cache_creation;
this.span.record("prompt_tokens", prompt_total);
this.span.record("completion_tokens", usage.output_tokens);
this.span
.record("total_tokens", prompt_total + usage.output_tokens);
this.span
.record("cache_creation_input_tokens", cache_creation);
this.span.record("cache_read_input_tokens", cache_read);
}
}
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use futures::{StreamExt, stream};
use parking_lot::Mutex;
use tracing_subscriber::{
Layer,
layer::{Context as LayerContext, SubscriberExt as _},
registry::LookupSpan,
};
use super::*;
struct FieldCapture {
captured: Arc<Mutex<BTreeMap<String, Vec<u64>>>>,
}
impl<S> Layer<S> for FieldCapture
where
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
{
fn on_record(
&self,
_id: &tracing::span::Id,
values: &tracing::span::Record<'_>,
_ctx: LayerContext<'_, S>,
) {
struct V {
captured: Arc<Mutex<BTreeMap<String, Vec<u64>>>>,
}
impl tracing::field::Visit for V {
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.captured
.lock()
.entry(field.name().to_owned())
.or_default()
.push(value);
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
if let Ok(v) = u64::try_from(value) {
self.captured
.lock()
.entry(field.name().to_owned())
.or_default()
.push(v);
}
}
fn record_debug(
&mut self,
_field: &tracing::field::Field,
_value: &dyn std::fmt::Debug,
) {
}
}
values.record(&mut V {
captured: Arc::clone(&self.captured),
});
}
}
fn delta(content: &str, usage: Option<Usage>) -> StreamDelta {
StreamDelta {
content: content.to_owned(),
thinking: None,
usage,
model: None,
stop_reason: None,
is_final: usage.is_some(),
}
}
fn make_span() -> tracing::Span {
tracing::info_span!(
"test_stream",
first_token_ms = tracing::field::Empty,
prompt_tokens = tracing::field::Empty,
completion_tokens = tracing::field::Empty,
total_tokens = tracing::field::Empty,
cache_creation_input_tokens = tracing::field::Empty,
cache_read_input_tokens = tracing::field::Empty,
)
}
#[tokio::test]
async fn first_token_ms_recorded_on_first_ok_item() {
let captured: Arc<Mutex<BTreeMap<String, Vec<u64>>>> =
Arc::new(Mutex::new(BTreeMap::new()));
let subscriber = tracing_subscriber::registry().with(FieldCapture {
captured: Arc::clone(&captured),
});
let _guard = tracing::subscriber::set_default(subscriber);
let span = make_span();
let inner = stream::iter(vec![Ok(delta("hello", None)), Ok(delta("world", None))]);
let started_at = Instant::now();
let mut wrapped = instrument_stream(span, started_at, inner);
tokio::time::sleep(Duration::from_millis(2)).await;
assert!(wrapped.next().await.is_some());
assert!(wrapped.next().await.is_some());
let values = captured
.lock()
.get("first_token_ms")
.cloned()
.unwrap_or_default();
assert_eq!(
values.len(),
1,
"first_token_ms should have been recorded exactly once"
);
assert!(
values[0] >= 1,
"first_token_ms should reflect the elapsed sleep, got {}",
values[0]
);
}
#[tokio::test]
async fn first_token_ms_only_recorded_once() {
let captured: Arc<Mutex<BTreeMap<String, Vec<u64>>>> =
Arc::new(Mutex::new(BTreeMap::new()));
let subscriber = tracing_subscriber::registry().with(FieldCapture {
captured: Arc::clone(&captured),
});
let _guard = tracing::subscriber::set_default(subscriber);
let span = make_span();
let inner = stream::iter(vec![
Ok(delta("a", None)),
Ok(delta("b", None)),
Ok(delta("c", None)),
]);
let started_at = Instant::now();
let mut wrapped = instrument_stream(span, started_at, inner);
while wrapped.next().await.is_some() {}
drop(wrapped);
assert_eq!(
captured.lock().get("first_token_ms").map_or(0, Vec::len),
1,
"first_token_ms should be recorded exactly once across multiple emissions",
);
}
#[tokio::test]
async fn errors_do_not_record_first_token_ms() {
let captured: Arc<Mutex<BTreeMap<String, Vec<u64>>>> =
Arc::new(Mutex::new(BTreeMap::new()));
let subscriber = tracing_subscriber::registry().with(FieldCapture {
captured: Arc::clone(&captured),
});
let _guard = tracing::subscriber::set_default(subscriber);
let span = make_span();
let inner = stream::iter(vec![
Err::<StreamDelta, LanguageModelError>(LanguageModelError::provider("boom")),
Ok(delta("recovered", None)),
]);
let started_at = Instant::now();
let mut wrapped = instrument_stream(span, started_at, inner);
assert!(matches!(wrapped.next().await, Some(Err(_))));
assert!(
captured
.lock()
.get("first_token_ms")
.is_none_or(Vec::is_empty),
"Err items must not record first_token_ms",
);
assert!(wrapped.next().await.is_some());
assert_eq!(
captured.lock().get("first_token_ms").map_or(0, Vec::len),
1,
"first Ok after Err records first_token_ms",
);
}
#[tokio::test]
async fn usage_fields_recorded_on_drop_from_final_delta() {
let captured: Arc<Mutex<BTreeMap<String, Vec<u64>>>> =
Arc::new(Mutex::new(BTreeMap::new()));
let subscriber = tracing_subscriber::registry().with(FieldCapture {
captured: Arc::clone(&captured),
});
let _guard = tracing::subscriber::set_default(subscriber);
let span = make_span();
let inner = stream::iter(vec![
Ok(delta("a", None)),
Ok(delta("b", None)),
Ok(delta(
"",
Some(Usage {
input_tokens: 17,
output_tokens: 7,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 1824,
}),
)),
]);
let started_at = Instant::now();
let mut wrapped = instrument_stream(span, started_at, inner);
while wrapped.next().await.is_some() {}
drop(wrapped);
let by_field = captured.lock().clone();
assert_eq!(by_field.get("prompt_tokens"), Some(&vec![1841_u64]));
assert_eq!(by_field.get("completion_tokens"), Some(&vec![7_u64]));
assert_eq!(by_field.get("total_tokens"), Some(&vec![1848_u64]));
assert_eq!(
by_field.get("cache_creation_input_tokens"),
Some(&vec![0_u64])
);
assert_eq!(
by_field.get("cache_read_input_tokens"),
Some(&vec![1824_u64])
);
}
#[tokio::test]
async fn no_usage_fields_recorded_when_upstream_omits_usage() {
let captured: Arc<Mutex<BTreeMap<String, Vec<u64>>>> =
Arc::new(Mutex::new(BTreeMap::new()));
let subscriber = tracing_subscriber::registry().with(FieldCapture {
captured: Arc::clone(&captured),
});
let _guard = tracing::subscriber::set_default(subscriber);
let span = make_span();
let inner = stream::iter(vec![Ok(delta("a", None)), Ok(delta("b", None))]);
let started_at = Instant::now();
let mut wrapped = instrument_stream(span, started_at, inner);
while wrapped.next().await.is_some() {}
drop(wrapped);
let by_field = captured.lock().clone();
assert!(
!by_field.contains_key("prompt_tokens"),
"no usage chunk on the stream means no token field recorded; got {by_field:?}",
);
assert!(!by_field.contains_key("cache_read_input_tokens"));
}
}