Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions TELEMETRY-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,13 @@ finds. Without totals on it, that query returns nothing, because summing the chi
having already found them.

`claude-agents` also writes `gen_ai.conversation.id` on the root, from the session id on the
CLI's `system` / `init` message. See section 4 for the third place it appears.
CLI's `system` / `init` message, write-if-absent. A caller-supplied id from `conversation_id(...)`
(TypeScript: `withConversationId`) wins. When the caller supplies none, the session id is used.
An app that opens a fresh CLI session per turn and re-feeds history must pass its own id, or each
turn becomes its own conversation. See section 4 for the third place it appears.

When a conversation id is bound, every handler stamps it on root, `chat`, and `execute_tool` (and
on `ld.ai.graph`) via the shared span processor. No id is invented when the caller supplies none.

---

Expand Down Expand Up @@ -182,7 +188,7 @@ requested name. See section 2a.
| Key | Value |
|---|---|
| `gen_ai.response.id` | provider request id |
| `gen_ai.conversation.id` | session id |
| `gen_ai.conversation.id` | session id, write-if-absent against a caller-supplied id |
| `gen_ai.agent.name` | subagent type |

---
Expand All @@ -200,8 +206,9 @@ requested name. See section 2a.
No usage attributes. A tool call spends no tokens.

`claude-agents` also writes `gen_ai.conversation.id` here, from the session id on the tool-use
hook input, when present. That attribute therefore appears on all three span types for this one
handler: root, `chat`, and `execute_tool`.
hook input, when present, write-if-absent. That attribute therefore appears on all three span types
for this one handler: root, `chat`, and `execute_tool`. A caller-supplied id is already on the span
when `conversation_id(...)` is bound.

---

Expand Down
13 changes: 7 additions & 6 deletions examples/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
import sys

import examples.register # noqa: F401 – side-effect: populate global_registry
from examples.utils import new_context, write_output
from launchdarkly_ai_server import config, global_registry
from examples.utils import new_context, new_conversation_id, write_output
from launchdarkly_ai_server import config, conversation_id, global_registry

HISTORY = [
{
Expand Down Expand Up @@ -48,10 +48,11 @@

async def run(key: str, user_input: str) -> None:
prompt = user_input or HISTORY_PROMPT
response = await config(
key=key,
registry=global_registry,
).invoke(prompt, new_context(), variables=None, history=HISTORY)
with conversation_id(new_conversation_id("history-example")):
response = await config(
key=key,
registry=global_registry,
).invoke(prompt, new_context(), variables=None, history=HISTORY)

text = str(
response.get("response", "")
Expand Down
22 changes: 16 additions & 6 deletions examples/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
Example: config().stream() — tokens are printed as they arrive,
then the final usage + judge results are logged when the stream ends.

Also the end-to-end check for call-time conversation binding: the generator is built inside the
``conversation_id`` block and iterated *outside* it, which is what a chat app does when it hands
the stream to a transport. An async generator body does not run until the first ``__anext__``, so
before ``stream()`` bound at call time this produced spans with no ``gen_ai.conversation.id`` at
all — silently. Every span of this run should carry the id printed below.

Usage (via main.py):
python main.py streaming <flag-key> "<user input>"
"""
Expand All @@ -11,15 +17,19 @@
import sys

import examples.register # noqa: F401 – side-effect: populate global_registry
from examples.utils import new_context
from launchdarkly_ai_server import config, global_registry
from examples.utils import new_context, new_conversation_id
from launchdarkly_ai_server import config, conversation_id, global_registry


async def run(key: str, user_input: str) -> None:
stream = config(
key=key,
registry=global_registry,
).stream(user_input, new_context())
conversation = new_conversation_id("streaming-example")
print(f"[conversation] {conversation}", file=sys.stderr)

with conversation_id(conversation):
stream = config(
key=key,
registry=global_registry,
).stream(user_input, new_context())

async for event in stream:
if event["type"] == "chunk":
Expand Down
12 changes: 12 additions & 0 deletions examples/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4


def new_context() -> dict[str, Any]:
Expand All @@ -17,6 +18,17 @@ def new_context() -> dict[str, Any]:
return {"kind": "user", "key": key}


def new_conversation_id(label: str) -> str:
"""A fresh conversation id per run.

A constant would collapse every run — by every developer, and every CI pass — into one
ever-growing conversation in LaunchDarkly's view: a misleading demo of the very feature it is
demonstrating. An id should be stable across the turns of one conversation and distinct across
conversations.
"""
return f"{label}-{uuid4().hex[:8]}"


def _default_encoder(obj: Any) -> Any:
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
return dataclasses.asdict(obj)
Expand Down
5 changes: 5 additions & 0 deletions packages/claude-agents/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ Span event after the call:

On error: `span.record_exception(exc)`, status set to ERROR, span ended, error re-raised.

`gen_ai.conversation.id` is a caller-supplied id from `conversation_id(...)`, or the CLI
`session_id` from the `init` message when the caller supplied none. Write-if-absent: the caller
id wins. An app that opens a fresh CLI session per turn and re-feeds history must pass its own
conversation id, or each turn becomes its own conversation.

---

## OTel Setup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
end_span_once,
end_unfinished_spans,
parse_template,
set_conversation_id_if_absent,
set_input_content_attributes,
set_output_content_attributes,
set_tool_call_content_attributes,
Expand Down Expand Up @@ -247,7 +248,7 @@ async def _pre_tool_use(
# Same grouping key as the root and as the CLI's own spans; the hook input is where this
# side sees it without waiting for a message. See TELEMETRY-CONTRACT.md section 4.
if span is not None and session_id:
span.set_attribute("gen_ai.conversation.id", session_id)
set_conversation_id_if_absent(span, session_id)
# Filed before the content write, not after. Serialising the arguments can raise, and a raise
# out of an unfiled span leaves it open with nothing tracking it: close_open_spans and the
# teardown both walk this dict, so a span missing from it is a span that never exports.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
add_cached_tokens_to_input,
end_span_once,
number_or_zero,
set_conversation_id_if_absent,
set_input_content_attributes,
set_ld_span_attributes,
set_model_identity_attributes,
Expand Down Expand Up @@ -181,7 +182,9 @@ def record_conversation_id(span: Any, message: Any) -> None:
It is the only key LaunchDarkly's trace view groups a conversation on, and the ``init`` system
message is where this side first learns it. The ``chat`` and ``execute_tool`` children read the
same id off their own message and hook input, so one run does not split into several
conversations. Set once: the id does not change within a run.
conversations. Write-if-absent: a caller-supplied id from ``conversation_id`` is already on the
span and must not be overwritten. Apps that open a fresh CLI session per turn and re-feed history
must pass their own conversation id, or each turn becomes its own conversation.
"""
if (
span is None
Expand All @@ -191,7 +194,7 @@ def record_conversation_id(span: Any, message: Any) -> None:
return
session_id = message.data.get("session_id")
if session_id:
span.set_attribute("gen_ai.conversation.id", session_id)
set_conversation_id_if_absent(span, session_id)


def record_native_tools(
Expand Down Expand Up @@ -532,7 +535,7 @@ def _emit(self, inference: _Inference) -> None:
if inference.request_id:
span.set_attribute("gen_ai.response.id", inference.request_id)
if inference.session_id:
span.set_attribute("gen_ai.conversation.id", inference.session_id)
set_conversation_id_if_absent(span, inference.session_id)
# Absent in practice: measured against Agent SDK 0.3.220, stop_reason and stop_details are
# both null on every assistant message. Written only when the SDK populates it — never
# synthesised from the presence of a tool-use block.
Expand Down
44 changes: 44 additions & 0 deletions packages/claude-agents/tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@
create_claude_agents_handler,
partition_tools,
)
from launchdarkly_ai_server import ConversationIdSpanProcessor, conversation_id

# ---------------------------------------------------------------------------
# A real tracer provider, reset between tests
# ---------------------------------------------------------------------------

_exporter = InMemorySpanExporter()
_provider = TracerProvider()
_provider.add_span_processor(ConversationIdSpanProcessor())
_provider.add_span_processor(SimpleSpanProcessor(_exporter))
trace.set_tracer_provider(_provider)

Expand Down Expand Up @@ -687,6 +689,48 @@ async def test_no_conversation_id_without_init(
await create_claude_agents_handler()(BASE_CONFIG, "q")
assert "gen_ai.conversation.id" not in root().attributes

async def test_caller_conversation_id_wins_over_session_id(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def _query(**kwargs: Any) -> AsyncIterator[Any]:
yield init_message("sess-abc")
yield assistant_message(session_id="sess-abc")
hooks = kwargs["options"].hooks
await hooks["PreToolUse"][0].hooks[0](
{
"hook_event_name": "PreToolUse",
"tool_name": "mcp__tool-mcp__search",
"tool_use_id": "tu-1",
"tool_input": {},
"session_id": "sess-abc",
},
"tu-1",
None,
)
await hooks["PostToolUse"][0].hooks[0](
{
"hook_event_name": "PostToolUse",
"tool_name": "mcp__tool-mcp__search",
"tool_use_id": "tu-1",
"tool_response": "r",
},
"tu-1",
None,
)
yield result_message()

monkeypatch.setattr(handler_mod, "query", _query)
with conversation_id("thread-stable"):
await create_claude_agents_handler()(
TOOL_CONFIG, "q", {"search": lambda _: "r"}
)
assert root().attributes["gen_ai.conversation.id"] == "thread-stable"
assert named("chat ")[0].attributes["gen_ai.conversation.id"] == "thread-stable"
assert (
named("execute_tool ")[0].attributes["gen_ai.conversation.id"]
== "thread-stable"
)


# ---------------------------------------------------------------------------
# Chat span attributes — sections 2a, 3, 5b, 8
Expand Down
59 changes: 59 additions & 0 deletions packages/claude-agents/tests/test_native_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,3 +652,62 @@ async def test_build_tool_mcp_throws_when_tool_not_in_handlers(self) -> None:
handlers: dict[str, Any] = {}
await build_tool_mcp(config_tools, handlers)
# The actual error is raised when the fn is *called*, not when mcp is built


class TestNativeGraphConversationId:
"""The telemetry contract claims the conversation id reaches ``ld.ai.graph`` spans.

The span is opened after an ``await`` on the graph definition, so this pins that the binding
survives the await chain rather than only covering spans started synchronously in the block.
"""

@pytest.mark.asyncio
async def test_stamps_conversation_id_on_graph_span(self) -> None:
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)

import launchdarkly_ai_claude_agents.native_graph as ng_mod
from launchdarkly_ai_server.conversation import (
GEN_AI_CONVERSATION_ID,
ConversationIdSpanProcessor,
conversation_id,
)

exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(ConversationIdSpanProcessor())
provider.add_span_processor(SimpleSpanProcessor(exporter))

# A real tracer behind the module's `trace` lookup: the span and the processor are real,
# only the global-provider registration is bypassed (it is process-wide and set once).
real_trace = MagicMock()
real_trace.get_tracer.return_value = provider.get_tracer(
"@launchdarkly/ai-claude-agents"
)

mock_sdk = _make_sdk_mock("done")
graph_def = _make_graph_def()

with patch(
"importlib.import_module",
side_effect=lambda n: (
mock_sdk if n == "claude_agent_sdk" else __import__(n)
),
):
with patch.object(ng_mod, "trace", real_trace):
with patch.object(ng_mod, "_HAS_OTEL", True):
with conversation_id("thread-graph"):
await to_claude_agents(_make_def_promise(graph_def)).invoke(
"hi"
)

graph_spans = [
s for s in exporter.get_finished_spans() if s.name == "ld.ai.graph"
]
assert len(graph_spans) == 1
assert (graph_spans[0].attributes or {}).get(
GEN_AI_CONVERSATION_ID
) == "thread-graph"
34 changes: 33 additions & 1 deletion packages/client/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ No other `launchdarkly-ai-*` package may define or duplicate these. They import

| File | Responsibility |
|---|---|
| `src/launchdarkly_ai_server/conversation.py` | `conversation_id`, `ConversationIdSpanProcessor` — stamps `gen_ai.conversation.id` |
| `src/launchdarkly_ai_server/lifecycle.py` | `init_client`, `get_client`, `shutdown`, `extract_variation` |
| `src/launchdarkly_ai_server/client.py` | `config()`, `ConfigInstance` |
| `src/launchdarkly_ai_server/tracking.py` | `execute_and_track`, `execute_and_stream`, `wrap_tool_handlers`, `parse_usage` |
Expand All @@ -41,6 +42,7 @@ Key symbols exported from `launchdarkly_ai_server`:
```python
# Lifecycle
from launchdarkly_ai_server import init_client, get_client, shutdown, extract_variation
from launchdarkly_ai_server import conversation_id, set_conversation_id_if_absent, ConversationIdSpanProcessor

# Types
from launchdarkly_ai_server import (
Expand Down Expand Up @@ -123,9 +125,39 @@ Handlers may return any of these — the client normalizes them before emitting

---

## Conversation grouping

LaunchDarkly's conversation view groups spans on `gen_ai.conversation.id`. Bind a caller-supplied id around any `invoke()` / `stream()` / `graph().invoke()` call:

```python
from launchdarkly_ai_server import conversation_id, config

with conversation_id("thread-123"):
await config(key=key, handler=handler).invoke(user_input, ctx)
```

`stream()` binds at call time rather than on first `__anext__`, so building the generator inside
the block and iterating it later — the normal shape for a chat app — keeps the id:

```python
with conversation_id("thread-123"):
gen = config(key=key, handler=handler).stream(user_input, ctx)
async for event in gen: # spans opened here still carry thread-123
...
```

Only the id is re-applied per step; the ambient context at iteration time is otherwise untouched,
so streaming span parenting is the same as it is with no id bound.

`init_client()` registers a span processor that stamps the id write-if-absent on every SDK span (root, chat, execute_tool, graph). The processor is registered on the *global* tracer provider, so it is scoped to spans from `@launchdarkly/ai-*` tracers only — a caller-supplied id must not land on third-party instrumentation spans (HTTP, Postgres, the outbound provider call). No id is invented when the caller supplies none — a UUID, a trace id, or a content hash would violate the semantic conventions.

This is an OTel context value, not W3C baggage, so the id does not leak onto outbound provider HTTP calls. A multi-tenant process must bind a different id per request; do not put it on the tracer resource.

---

## OTel Setup

The core client owns all OTel initialization. `init_client()` configures a `TracerProvider` with a `BatchSpanProcessor` and an OTLP HTTP exporter when the optional OTel packages are installed.
The core client owns all OTel initialization. `init_client()` configures a `TracerProvider` with `ConversationIdSpanProcessor` and a `BatchSpanProcessor` plus an OTLP HTTP exporter when the optional OTel packages are installed.

**Required packages:**

Expand Down
Loading
Loading