feat: emit gen_ai.conversation.id - #42
Merged
ccschmitz-launchdarkly merged 5 commits intoAug 20, 2026
Merged
Conversation
Bind a caller-supplied gen_ai.conversation.id via conversation_id() and stamp it write-if-absent on every SDK span. Judge scores land as gen_ai.evaluation.result on the judge invoke_agent span so conversation turn badges can render, while track(evaluationMetricKey) is unchanged. O11Y-1888
An async generator body does not run until the first __anext__, so the natural streaming shape — bind, build the generator, iterate later — left every span without gen_ai.conversation.id, silently. stream() now binds at call time and re-attaches the id around each step. Only the id is re-attached, not the whole captured context, so span parenting for streaming callers is unchanged. Also fixes the red mypy check: ConversationIdSpanProcessor now subclasses SpanProcessor under TYPE_CHECKING, which gets the interface checked without importing opentelemetry-sdk at runtime (it is an optional extra). Adds the concurrency-isolation and ld.ai.graph assertions the telemetry contract claims but nothing covered. O11Y-1888 Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit bda8393. Configure here.
Per review: the two halves of O11Y-1888 share conversation.py and nothing else, and the judge half overrides span.end on a live span — the riskiest surface here. Splitting so they can be reviewed apart and merged together. This PR is now conversation-id only. The judge evaluation work moves to the stacked branch and lands with it. O11Y-1888 Co-Authored-By: Claude <noreply@anthropic.com>
5 tasks
Review follow-ups on the conversation-id half. - Bind the conversation id once for the whole stream instead of re-attaching per step. A streaming handler normally holds a span current across a `yield`; detaching per step reset the contextvar past that live `start_as_current_span`, so every span the handler opened after resuming was reparented. This contradicted the PR's own "parenting is unchanged" claim, and the existing test could not catch it because it only exercised the unbound path. The generator is now closed before the detach so contextvar tokens unwind in LIFO order. - Scope the span processor to `@launchdarkly/ai-*` tracers. It is registered on the global provider, so it was stamping a caller-supplied id onto every span in the process — Postgres queries, inbound HTTP server spans, and the outbound provider call itself. That last one undercut the stated reason for preferring an OTel context value over W3C baggage. - Treat `None` as unbound instead of raising AttributeError. The natural call site is an optional value: `conversation_id(request.thread_id)`. O11Y-1888 Co-Authored-By: Claude <noreply@anthropic.com>
The streaming example never bound a conversation id, so the bug this branch fixes — an async generator built inside the block and iterated after it — had no end-to-end coverage at all. A regression to a late-binding `stream()` would have passed both CI and the examples run. It now binds, builds the generator inside the block, and iterates outside it, which is the shape a chat app uses when it hands the stream to a transport. The id is printed so it can be pasted into the Conversations list. Both examples also take a fresh id per run. A constant collapsed every run by every developer into one ever-growing conversation. O11Y-1888 Co-Authored-By: Claude <noreply@anthropic.com>
apucacao
approved these changes
Aug 20, 2026
ccschmitz-launchdarkly
deleted the
O11Y-1888-emit-conversation-id-and-evaluation
branch
August 20, 2026 19:45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
conversation_id(...)binds a caller-supplied conversation id on OTel context (not W3C baggage, so it does not leak onto outbound provider calls). AConversationIdSpanProcessorregistered ahead ofBatchSpanProcessorstampsgen_ai.conversation.idwrite-if-absent on every SDK span — root,chat,execute_tool,ld.ai.graph.stream()binds at call time rather than on first__anext__. An async generator body does not run until the first__anext__, so the natural streaming shape — bind, build the generator, iterate later — previously produced spans with no id at all, silently. Only the id is re-attached per step, so span parenting for streaming callers is unchanged.claude-agentsusesset_conversation_id_if_absentfor the CLIsession_id, so a bound caller id is never overwritten.ConversationIdSpanProcessornow subclassesSpanProcessorunderTYPE_CHECKING, fixing the red type check. The runtime base staysobjectbecauseopentelemetry-sdkis an optional extra — this gets the interface genuinely checked without importing the SDK at runtime.Mirrors js-ai-sdk#25; TypeScript is the source of truth. Fixes O11Y-1888.
Split out of the original combined PR per review: judge evaluation events now live in the stacked PR below. The two halves share
conversation.pyand nothing else — review apart, merge together.➡️ Stacked on top of this: #43 (judge evaluation events)
Test plan
uv run pytest— 1072 pass, 11 skippeduv run mypy packages/*/src— clean (this was the one red check)uv run ruff check ./ruff format --check .cleanwithblock (was the silent failure), and when iterated insideconversation_idscopes stay isolated underasyncio.gatherld.ai.graphcarries the id, driven through the realnative_graphadapter (verified the assertion fails if the processor is dropped)conversation.pypulls in zeroopentelemetry.sdkmodules — api-only installs still workconversation_id("thread-123")and confirm the Conversations list shows the id on root, chat, and tool spansNote
Overview
Lets callers group traces into one LaunchDarkly conversation by wrapping
invoke()/stream()/graph().invoke()inconversation_id(...). AConversationIdSpanProcessor(registered ininit_client) stampsgen_ai.conversation.idwrite-if-absent on SDK spans only (@launchdarkly/tracers). No id is invented when unbound; the value lives on OTel context, not W3C baggage, so it does not leak onto outbound provider calls.stream()is no longer anasync defgenerator: it binds the id at call time so building the generator inside thewithblock and iterating later still stamps spans. Binding lasts the whole iteration (not per chunk) so handler span parenting acrossyieldis unchanged.claude-agentsnow usesset_conversation_id_if_absentfor CLIsession_id, so a caller id wins. Apps that open a fresh CLI session per turn must pass their own id or each turn is a separate conversation.Reviewed by Cursor Bugbot for commit 3e2ed0f. Bugbot is set up for automated code reviews on this repo. Configure here.
Review follow-ups (pushed)
@launchdarkly/ai-*tracers. It is registered on the global provider,so it was stamping the caller's conversation id onto every span in the process — Postgres
queries, inbound HTTP server spans, and the outbound provider call itself. That last one
undercut the stated reason for preferring an OTel context value over W3C baggage. Conservative
by design: an unrecognisable scope means "not ours", and a companion test asserts LD spans are
stamped so a scope-field rename fails loudly rather than silently disabling the feature.
(
withConversationId(req.headers['x-conversation-id'], …)/conversation_id(request.thread_id)).where the binding helper returns the generator untouched, so it could never catch a regression
in the wrapper.
detachreset the contextvar past a handler's own live
start_as_current_span, reparenting every spanthe handler opened after resuming from a
yield. The id is now bound once for the wholeiteration, and the generator is closed before the detach so tokens unwind in LIFO order.
Trade-off, deliberate: spans the consumer opens between chunks are stamped too.
Staging verification — links
Run against
default/staging(project620eb988d081cb1452e74086), serviceo11y-1888-verify-py. Spans confirmtelemetry.sdk.language: python, so these are the PythonSDK's own, not the TypeScript run.
Multi-turn: three turns, one conversation id. Six spans across three otherwise-unrelated
traces, stitched only by
gen_ai.conversation.id. AllOk, input tokens climbing 27 → 90 → 180 ashistory accumulates.
https://ld-stg.launchdarkly.com/projects/default/traces?selected-env=staging&tab=traces&startDate=2026-08-20T17%3A30%3A00Z&endDate=2026-08-20T19%3A00%3A00Z&query=gen_ai.conversation.id%3Dconversation-example-23bcd8fa
All Python runs (streaming + multi-turn):
https://ld-stg.launchdarkly.com/projects/default/traces?selected-env=staging&tab=traces&startDate=2026-08-20T17%3A30%3A00Z&endDate=2026-08-20T19%3A00%3A00Z&query=service_name%3Do11y-1888-verify-py
The streaming example was also run and returned a real completion, exercising the call-time binding
this PR adds — the generator is built inside the
conversation_idblock and iterated outside it.Not affected by the TypeScript init-order bug. js-ai-sdk#25 needed a fix for
withConversationIdbinding into OTel'sNoopContextManagerbeforeinitClient()registers a realone. Python's OTel context is contextvars-based with no manager to register, so a binding made
before
init_client()works. Verified with the same pre-init probe rather than assumed.Filters must live in the
queryDSL with an explicitstartDate/endDate; the Conversations tabis gated behind
enableObservabilityConversationsTab+enableObservabilityConversationView, sothese link to the traces list.
Verified against a real AI Config
Re-run against a genuine AI Config (
chriss-test-config, Anthropic /claude-haiku-4-5-20251001)rather than the stand-in feature flag used earlier:
Adds two span shapes the earlier runs never produced:
execute_tool Test-toolspans carryinggen_ai.tool.nameandgen_ai.tool.call.id, groupedunder the same
gen_ai.conversation.idasinvoke_agentandchat.gen_ai.input.messages,gen_ai.output.messages,gen_ai.system_instructions) from a run withcapture_content=True, so the Messages panelrenders the transcript. Content capture is not enabled in the committed examples — a
deliberate one-off for this verification.