You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Split out of #1551 (align spans and metrics with OTel GenAI semantic conventions) to keep that PR scoped to the conventions rename, with the measurement corrections tracked here. Raised in review by @planetf1: the client latency instruments are timestamped on the consumer side of the streaming pipeline, so a slow renderer, paused agent loop, or client-side post-processing inflates what is reported as provider latency.
Root cause
Mellea streams through a producer→queue→consumer pipeline: backends push chunks into an asyncio.Queue from send_to_queue() (mellea/helpers/async_helpers.py) while ModelOutputThunk.astream() (mellea/core/base.py) drains it. All three latency instruments take their timestamps in the consumer — after the chunk is dequeued, and for some after it is processed — so they measure when the application handled the data, not when the provider delivered it.
Two structural problems compound, and they have different fix locations:
(A) Measurement location — timestamps are taken consumer-side instead of at chunk receipt in the producer. Straightforward to move.
(B) Bounded-queue backpressure — the queue is asyncio.Queue(maxsize=20); once full, the producer's put() blocks, which in turn paces its own anext(). So even a producer-side timestamp is dragged by a stalled consumer on any stream longer than the queue. Removing this needs decoupling the producer's drain from the bounded queue (buffer receipts independently, or use provider-reported timing).
Fixes required
The three are related — all "measure provider time, not consumer time," all enabled by producer-side receipt timestamps — but land in separate places with different scope.
1. time_to_first_chunk — fully fixable (location only, Issue A)
Surfaces: gen_ai.client.operation.time_to_first_chunk (metric) and gen_ai.response.time_to_first_chunk (span attr), both derived from ModelOutputThunk.generation.ttfb_ms.
ttfb_ms is recorded in _record_ttfb() at queue.get() time, so it includes the gap between request dispatch and the consumer draining the first chunk. The spec definition is explicit:
Time to receive the first chunk, measured from when the client issues the generation request to when the first chunk is received in the response stream.
Consumer-side stamping violates that definition. Fix location: send_to_queue() — stamp a monotonic receipt time the instant the first anext() returns (before enqueue) and derive ttfb_ms as first_receipt − request_dispatch. The queue is empty at the first chunk so put() never blocks — this is immune to Issue B and is completely solvable.
2. operation.duration — location fix + backpressure decoupling (Issues A + B)
latency_ms is _elapsed_ms() read at the generation_post_call hook — after full consumption, post-processing, parsing, and validation — and because that hook fires whenever the consumer finishes, it also absorbs idle time (e.g. a paused agent loop between astream() calls). Note the spec only defines this as "GenAI operation duration" (client perspective) and does not pin the boundary, so including post-processing is arguably within latitude; including arbitrary idle time is not defensible under any reading.
(b) Decouple the producer drain from the bounded queue to remove the Issue B backpressure contamination on long streams.
Resolution options: (i) keep the gen_ai.* spec name and implement (a) + (b); or (ii) rename to an honest mellea metric instead of re-measuring — viable for duration because the current value is a coherent quantity (end-to-end wall time) that only carries the wrong name, so an honest name makes it correct-by-definition and (b) is not needed.
3. time_per_output_chunk / time_since_last_chunk_ms — location + per-item propagation + backpressure (Issues A + B, plus queue-payload rework)
Surfaces: gen_ai.client.operation.time_per_output_chunk (metric) and time_since_last_chunk_ms on chunk_processed span events (opt-in via MELLEA_GENERATION_CHUNK_EVENTS).
time_since_last_chunk_ms is computed in the consumer after await self._gen.process(self, chunk), from datetime.now() deltas at processing time; the consumer also drains in batches, so the deltas reflect processing/batching cadence, not provider inter-arrival.
(b) Propagate them per-item through the queue so the consumer can emit them alongside chunk_index/chunk_text_length. The queue currently carries bare items and discriminates end-of-stream/error by identity (is None / isinstance Exception), so this needs a wrapper type or a parallel timestamp channel — the most invasive piece.
Note: these surfaces are new and opt-in (MELLEA_GENERATION_CHUNK_EVENTS, no existing consumers), so an alternative to shipping approximate values is to gate them off until (a) + (b) + (c) land. Renaming does not help here (unlike duration): the current value is a batching-distorted consumer-processing cadence, not a coherent quantity, so an honest name yields a truthful-but-low-value metric rather than a correct one.
Acceptance
TTFC, duration, and per-chunk timing are derived from provider-side receipt timestamps rather than consumer-side stamps.
Regression test: stall the consumer after chunks are produced and assert the client latency values do not increase.
The instruments distinguish provider latency from client-side/orchestration time, so they're usable for performance diagnosis (the original review concern).
Context
Split out of #1551 (align spans and metrics with OTel GenAI semantic conventions) to keep that PR scoped to the conventions rename, with the measurement corrections tracked here. Raised in review by @planetf1: the client latency instruments are timestamped on the consumer side of the streaming pipeline, so a slow renderer, paused agent loop, or client-side post-processing inflates what is reported as provider latency.
Root cause
Mellea streams through a producer→queue→consumer pipeline: backends push chunks into an
asyncio.Queuefromsend_to_queue()(mellea/helpers/async_helpers.py) whileModelOutputThunk.astream()(mellea/core/base.py) drains it. All three latency instruments take their timestamps in the consumer — after the chunk is dequeued, and for some after it is processed — so they measure when the application handled the data, not when the provider delivered it.Two structural problems compound, and they have different fix locations:
asyncio.Queue(maxsize=20); once full, the producer'sput()blocks, which in turn paces its ownanext(). So even a producer-side timestamp is dragged by a stalled consumer on any stream longer than the queue. Removing this needs decoupling the producer's drain from the bounded queue (buffer receipts independently, or use provider-reported timing).Fixes required
The three are related — all "measure provider time, not consumer time," all enabled by producer-side receipt timestamps — but land in separate places with different scope.
1.
time_to_first_chunk— fully fixable (location only, Issue A)Surfaces:
gen_ai.client.operation.time_to_first_chunk(metric) andgen_ai.response.time_to_first_chunk(span attr), both derived fromModelOutputThunk.generation.ttfb_ms.ttfb_msis recorded in_record_ttfb()atqueue.get()time, so it includes the gap between request dispatch and the consumer draining the first chunk. The spec definition is explicit:Consumer-side stamping violates that definition. Fix location:
send_to_queue()— stamp a monotonic receipt time the instant the firstanext()returns (before enqueue) and derivettfb_msasfirst_receipt − request_dispatch. The queue is empty at the first chunk soput()never blocks — this is immune to Issue B and is completely solvable.2.
operation.duration— location fix + backpressure decoupling (Issues A + B)Surface:
gen_ai.client.operation.duration(metric).latency_msis_elapsed_ms()read at thegeneration_post_callhook — after full consumption, post-processing, parsing, and validation — and because that hook fires whenever the consumer finishes, it also absorbs idle time (e.g. a paused agent loop betweenastream()calls). Note the spec only defines this as "GenAI operation duration" (client perspective) and does not pin the boundary, so including post-processing is arguably within latitude; including arbitrary idle time is not defensible under any reading.Fix location:
send_to_queue()— stamp completion at last-chunk receipt; removes post-processing/parse/validate. Same location as Better@generativedocumentation in the tutorial #1.Resolution options: (i) keep the
gen_ai.*spec name and implement (a) + (b); or (ii) rename to an honest mellea metric instead of re-measuring — viable for duration because the current value is a coherent quantity (end-to-end wall time) that only carries the wrong name, so an honest name makes it correct-by-definition and (b) is not needed.3.
time_per_output_chunk/time_since_last_chunk_ms— location + per-item propagation + backpressure (Issues A + B, plus queue-payload rework)Surfaces:
gen_ai.client.operation.time_per_output_chunk(metric) andtime_since_last_chunk_msonchunk_processedspan events (opt-in viaMELLEA_GENERATION_CHUNK_EVENTS).time_since_last_chunk_msis computed in the consumer afterawait self._gen.process(self, chunk), fromdatetime.now()deltas at processing time; the consumer also drains in batches, so the deltas reflect processing/batching cadence, not provider inter-arrival.Fix location:
send_to_queue()).chunk_index/chunk_text_length. The queue currently carries bare items and discriminates end-of-stream/error by identity (is None/isinstance Exception), so this needs a wrapper type or a parallel timestamp channel — the most invasive piece.Most involved of the three.
Note: these surfaces are new and opt-in (
MELLEA_GENERATION_CHUNK_EVENTS, no existing consumers), so an alternative to shipping approximate values is to gate them off until (a) + (b) + (c) land. Renaming does not help here (unlike duration): the current value is a batching-distorted consumer-processing cadence, not a coherent quantity, so an honest name yields a truthful-but-low-value metric rather than a correct one.Acceptance