Skip to content

Client latency telemetry (TTFC / duration / per-output-chunk) is measured consumer-side, not at provider receipt #1566

Description

@ajbozarth

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.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)

Surface: gen_ai.client.operation.duration (metric).

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.

Fix location:

  • (a) send_to_queue() — stamp completion at last-chunk receipt; removes post-processing/parse/validate. Same location as Better @generative documentation in the tutorial #1.
  • (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.

Fix location:

  • (a) Producer captures per-chunk receipt timestamps (send_to_queue()).
  • (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.
  • (c) Subject to the same Issue B backpressure ceiling as Easier RCA for failed requirements #2.

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

  • 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).

Metadata

Metadata

Assignees

Labels

area/telemetryOTel spans, metrics, tracing, semconvbugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions