Skip to content

feat: emit gen_ai.conversation.id - #42

Merged
ccschmitz-launchdarkly merged 5 commits into
mainfrom
O11Y-1888-emit-conversation-id-and-evaluation
Aug 20, 2026
Merged

feat: emit gen_ai.conversation.id#42
ccschmitz-launchdarkly merged 5 commits into
mainfrom
O11Y-1888-emit-conversation-id-and-evaluation

Conversation

@ccschmitz-launchdarkly

@ccschmitz-launchdarkly ccschmitz-launchdarkly commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • conversation_id(...) binds a caller-supplied conversation id on OTel context (not W3C baggage, so it does not leak onto outbound provider calls). A ConversationIdSpanProcessor registered ahead of BatchSpanProcessor stamps gen_ai.conversation.id write-if-absent on every SDK span — root, chat, execute_tool, ld.ai.graph.
  • No id is invented when the caller supplies none: a UUID, a trace id, or a content hash would violate semconv.
  • 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-agents uses set_conversation_id_if_absent for the CLI session_id, so a bound caller id is never overwritten.
  • ConversationIdSpanProcessor now subclasses SpanProcessor under TYPE_CHECKING, fixing the red type check. The runtime base stays object because opentelemetry-sdk is 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.py and nothing else — review apart, merge together.

➡️ Stacked on top of this: #43 (judge evaluation events)

Test plan

  • uv run pytest — 1072 pass, 11 skipped
  • uv run mypy packages/*/src — clean (this was the one red check)
  • uv run ruff check . / ruff format --check . clean
  • Streaming: id present when the generator is iterated outside the with block (was the silent failure), and when iterated inside
  • Streaming: span parenting unchanged when no id is bound
  • Concurrency: two overlapping conversation_id scopes stay isolated under asyncio.gather
  • ld.ai.graph carries the id, driven through the real native_graph adapter (verified the assertion fails if the processor is dropped)
  • Importing conversation.py pulls in zero opentelemetry.sdk modules — api-only installs still work
  • Against staging OTLP: wrap invoke in conversation_id("thread-123") and confirm the Conversations list shows the id on root, chat, and tool spans
  • No bind → conversation does not appear (except claude-agents with a real session id)
  • claude-agents with caller id ≠ session id → one conversation, caller id on every span

Note

Overview
Lets callers group traces into one LaunchDarkly conversation by wrapping invoke() / stream() / graph().invoke() in conversation_id(...). A ConversationIdSpanProcessor (registered in init_client) stamps gen_ai.conversation.id write-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 an async def generator: it binds the id at call time so building the generator inside the with block and iterating later still stamps spans. Binding lasts the whole iteration (not per chunk) so handler span parenting across yield is unchanged.

claude-agents now uses set_conversation_id_if_absent for CLI session_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)

  • Processor scoped to @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.
  • Nullish id is unbound, not a crash. The natural call site is an optional value
    (withConversationId(req.headers['x-conversation-id'], …) / conversation_id(request.thread_id)).
  • Bound-path parenting test added. The previous "parenting unchanged" test only ran the branch
    where the binding helper returns the generator untouched, so it could never catch a regression
    in the wrapper.
  • Python only — real parenting bug fixed. Re-attaching per step meant the per-step detach
    reset the contextvar past a handler's own live start_as_current_span, reparenting every span
    the handler opened after resuming from a yield. The id is now bound once for the whole
    iteration, 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 (project 620eb988d081cb1452e74086), service
o11y-1888-verify-py. Spans confirm telemetry.sdk.language: python, so these are the Python
SDK'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. All Ok, input tokens climbing 27 → 90 → 180 as
history 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_id block and iterated outside it.

Not affected by the TypeScript init-order bug. js-ai-sdk#25 needed a fix for
withConversationId binding into OTel's NoopContextManager before initClient() registers a real
one. 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 query DSL with an explicit startDate/endDate; the Conversations tab
is gated behind enableObservabilityConversationsTab + enableObservabilityConversationView, so
these 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-tool spans carrying gen_ai.tool.name and gen_ai.tool.call.id, grouped
    under the same gen_ai.conversation.id as invoke_agent and chat.
  • Content attributes (gen_ai.input.messages, gen_ai.output.messages,
    gen_ai.system_instructions) from a run with capture_content=True, so the Messages panel
    renders the transcript. Content capture is not enabled in the committed examples — a
    deliberate one-off for this verification.

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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

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

Comment thread packages/client/src/launchdarkly_ai_server/conversation.py Outdated
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>
@ccschmitz-launchdarkly ccschmitz-launchdarkly changed the title feat: emit conversation id and judge evals feat: emit gen_ai.conversation.id Aug 19, 2026
Vega and others added 2 commits August 19, 2026 17:09
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>
@ccschmitz-launchdarkly
ccschmitz-launchdarkly merged commit c372e56 into main Aug 20, 2026
7 checks passed
@ccschmitz-launchdarkly
ccschmitz-launchdarkly deleted the O11Y-1888-emit-conversation-id-and-evaluation branch August 20, 2026 19:45
@github-actions github-actions Bot mentioned this pull request Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants