feat(telemetry): align spans and metrics with OTel GenAI semantic conventions - #1551
Conversation
…nventions
Adopt OTel GenAI semantic-convention names and shapes across tracing and
metrics.
Spans:
- backend span name is now "{operation} {model}"; model/provider surfaced
on the chat pre-call payload for parity with the batch path
- backend spans use SpanKind.CLIENT for remote providers (INTERNAL for
in-process backends such as huggingface)
- emit gen_ai.request.stream (replaces the mellea.streaming bool)
- map watsonx -> ibm.watsonx.ai for gen_ai.provider.name at emit sites
(self._provider unchanged, so pricing keys are unaffected)
- gen_ai.output.type now "json" (was the invalid "json_schema")
- gen_ai.usage.total_tokens -> mellea.usage.total_tokens (not a spec attr)
- drop the redundant mellea.has_format (superseded by gen_ai.output.type)
- add gen_ai.response.time_to_first_chunk to streaming spans
Metrics:
- token counters -> single gen_ai.client.token.usage histogram, split by
gen_ai.token.type, with spec bucket boundaries
- mellea.llm.request.duration -> gen_ai.client.operation.duration and
mellea.llm.ttfb -> gen_ai.client.operation.time_to_first_chunk, with spec buckets
- add the required gen_ai.operation.name dimension to the backend metrics
- record gen_ai.client.operation.duration on failed operations too, tagged
with error.type (setup failures before a call have no duration; negative
durations are skipped)
- streaming dimension -> gen_ai.request.stream (matches the span attribute)
- error counter's non-standard error_type key -> mellea.error.category
(the semconv error.type, the exception class, is unchanged)
Also aligns mellea.adapter_function.phase_duration to the spec duration buckets
(it had copied the pre-fix values).
Assisted-by: Claude Code
Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
…stogram Record the streaming inter-chunk interval as the OTel GenAI time_per_output_chunk histogram, with spec buckets and the gen_ai.operation.name/request.model/provider.name dimensions. - core computes each interval from the generation's last_chunk_time and puts time_since_last_chunk_ms on the chunk_processed event (None for the first) - GenerationEventPayload gains top-level model/provider to attribute the metric - opt-in via MELLEA_GENERATION_CHUNK_EVENTS, which now drives both the per-chunk span events and this metric; record_time_per_output_chunk itself is ungated Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
Bring the remaining flat mellea.* span attributes under mellea.<scope>.<leaf>, scoped by the subsystem that owns the value (matching the event/metric convention), so shared leaf names no longer collide across spans. Session model/provider promote to gen_ai.request.model / gen_ai.provider.name. Docs and tracing tests updated to match. Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
…span events Also clarify that the time_per_output_chunk metric measures the interval between chunk processing completions, an approximation of the spec's receive-to-receive. Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
|
@ reviewers if you could prioritize this, I'd like to see if I can get it merged before I leave on my trip and am out for a week (ie by EOD tomorrow, Tuesday) and want to make sure I have time to address any feedback |
planetf1
left a comment
There was a problem hiding this comment.
Client latency metrics are not yet safe to use for application-performance diagnosis; see inline.
| prompt=prompt, | ||
| model_output=self, | ||
| latency_ms=latency_ms, | ||
| latency_ms=self._elapsed_ms(), |
There was a problem hiding this comment.
gen_ai.client.operation.duration is intended to be Mellea's client-observed model-call latency, but this timestamp is taken only after stream consumption, post-processing, parsing, and validation. TTFB is likewise recorded after queue.get*() and chunk intervals after _gen.process() (mellea/core/base.py:1133-1153, 1189-1207). The producer puts raw chunks into the queue without a receipt timestamp (mellea/helpers/async_helpers.py:79-125), so a slow renderer, paused agent loop, or validation step becomes apparent provider latency.
Please timestamp chunk receipt immediately after anext() returns (and completion on the producer side) using a monotonic clock, propagate those timestamps through the queue, and derive TTFB, inter-chunk timing, and client duration from them. Consumer processing and strategy time already have Mellea orchestration spans. Please add a regression test that delays the consumer after a chunk is produced and verifies these client metrics do not increase.
There was a problem hiding this comment.
I'm digging into this more (I had looked into it myself before opening the PR), but I see a few options on address this:
- I rip out the entire feature, span attributed and metric histogram and we just don't emit this value
- I rename it so it doesn't claim to be a gen_ai standard value
- I plumb the correct value to the metric. I had Claude do an initial attempt at this just now and it requires changes to 8 files. I'm still investigating, but I'm not convinced this metric is worth all that new plumbing.
I will note that if we go with option 2 we have the option to do option 3 in a follow up PR if desired. Though technically option 1 is the most correct
There was a problem hiding this comment.
I have also now realized that there are separate issues here:
- the existing span attr that I renamed in this PR (this diff line)
- the new metric I added by the same name.
I'll continue to dig into these, but would you be open to me filing a follow up PR to fix the values rather than blocking this entire PR on poor plumbing of an existing value for one item?
There was a problem hiding this comment.
Here's the proposed followup issue I'd open if you're ok with a followup unblocking this. I would pick up work on it once I get back from my trip next week. Full draft folded below.
Proposed follow-up issue — Client latency telemetry (TTFC / duration / per-output-chunk) is measured consumer-side, not at provider receipt
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'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) 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@generativedocumentation 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).
There was a problem hiding this comment.
Thanks for this — the rest of the PR looks good to me.
One thing to check on the latency metrics: the OTel GenAI spec defines the operation duration as ending when the response is fully received, not after post-processing. Currently we stop the clock after the response has been consumed from the queue and validated (at base.py:1246, after stream consumption, post-processing, and parsing), so the values will include that overhead.
Moving the measurement point to chunk receipt in send_to_queue in a follow-up PR, or adding a note in the docs stating that the values are consumer-observed (including post-processing), would help clarify and improve the metric.
planetf1
left a comment
There was a problem hiding this comment.
LGTM — good work on the semconv alignment. Follow-up on the latency measurement point noted in the inline comment.
67bfc74
Pull Request
Issue
Fixes #1209
Description
Aligns Mellea's tracing and metrics with the OTel GenAI semantic conventions and namespaces the remaining flat span attributes. Part of the tracing epic #444.
Spans:
{operation} {model}; model/provider surfaced on the chat pre-call payload for parity with the batch pathSpanKind.CLIENTfor remote providers (INTERNALfor in-process backends such as huggingface)gen_ai.request.stream(replaces themellea.streamingbool)ibm.watsonx.aiforgen_ai.provider.nameat emit sitesgen_ai.output.typenow"json"(was the invalid"json_schema")gen_ai.usage.total_tokens→mellea.usage.total_tokens(not a spec attr)mellea.has_format(superseded bygen_ai.output.type)gen_ai.response.time_to_first_chunkto streaming spansMetrics:
gen_ai.client.token.usagehistogram, split bygen_ai.token.type, with spec bucket boundariesmellea.llm.request.duration→gen_ai.client.operation.durationandmellea.llm.ttfb→gen_ai.client.operation.time_to_first_chunk, with spec bucketsgen_ai.operation.namedimension to the backend metricsgen_ai.client.operation.durationon failed operations too, tagged witherror.typestreamingdimension →gen_ai.request.stream(matches the span attribute)error_typekey →mellea.error.categorygen_ai.client.operation.time_per_output_chunkhistogram for streaming (opt-in viaMELLEA_GENERATION_CHUNK_EVENTS)Span attributes:
mellea.*span attribute by its owning subsystem (mellea.<scope>.<leaf>), matching the convention already used for event attributes and metricsgen_ai.request.model/gen_ai.provider.nameNot included: the
server.address/server.portspan and metric items from the checklist — the real endpoint isn't reliably available without per-backend SDK introspection, disproportionate for Recommended-only attributes.Testing
Attribution
Adding a new component, requirement, sampling strategy, or tool?
If your PR adds or modifies one of the types below, check the matching box. A checklist of type-specific review items will be posted as a comment.