From b16dfda010040bf3c9d4b36c4e3a844c500ffa58 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Fri, 25 Sep 2026 11:27:58 -0400 Subject: [PATCH 1/2] refactor(agents): rename the graph span to launchdarkly.graph The graph span carried ld.ai.graph.key while the invoke_agent root carried launchdarkly.graph.key, so one graph key had two attribute names. js-ai-sdk #16 fixed this in TypeScript by moving the graph span, its key and its path under launchdarkly., and keeping ld.ai. for metric and event keys. This makes the same rename in the three native_graph.py handlers so both SDKs emit the same names again: ld.ai.graph -> launchdarkly.graph ld.ai.graph.key -> launchdarkly.graph.key ld.ai.graph.path -> launchdarkly.graph.path The parity test's vocabulary lock and span-name pattern move with it. launchdarkly.graph.key was already in the lock as a root attribute, so it is listed once. --- .../launchdarkly_ai_claude_agents/native_graph.py | 6 +++--- packages/claude-agents/tests/test_native_graph.py | 4 ++-- .../launchdarkly_ai_langchain_agents/native_graph.py | 6 +++--- packages/langchain-agents/tests/test_native_graph.py | 6 ++++-- .../launchdarkly_ai_openai_agents/native_graph.py | 6 +++--- packages/openai-agents/tests/test_native_graph.py | 12 ++++++------ tests/test_cross_handler_parity.py | 9 ++++----- 7 files changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index 8fa8ce33..d6e499a6 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -238,8 +238,8 @@ async def invoke( tracer_name = "@launchdarkly/ai-claude-agents" if _HAS_OTEL: - span = trace.get_tracer(tracer_name).start_span("ld.ai.graph") - span.set_attribute("ld.ai.graph.key", def_obj.key) + span = trace.get_tracer(tracer_name).start_span("launchdarkly.graph") + span.set_attribute("launchdarkly.graph.key", def_obj.key) else: span = None @@ -383,7 +383,7 @@ async def _subagent_execute( graph_dur = int((time.monotonic() - start_time) * 1000) if span: - span.set_attribute("ld.ai.graph.path", "->".join(path)) + span.set_attribute("launchdarkly.graph.path", "->".join(path)) span.set_attribute("gen_ai.usage.input_tokens", total_usage["input"]) span.set_attribute("gen_ai.usage.output_tokens", total_usage["output"]) span.set_attribute("gen_ai.usage.total_tokens", total_usage["total"]) diff --git a/packages/claude-agents/tests/test_native_graph.py b/packages/claude-agents/tests/test_native_graph.py index da3195f2..c8099ffe 100644 --- a/packages/claude-agents/tests/test_native_graph.py +++ b/packages/claude-agents/tests/test_native_graph.py @@ -707,7 +707,7 @@ async def test_build_tool_mcp_throws_when_tool_not_in_handlers(self) -> None: class TestNativeGraphConversationId: - """The telemetry contract claims the conversation id reaches ``ld.ai.graph`` spans. + """The telemetry contract claims the conversation id reaches ``launchdarkly.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. @@ -757,7 +757,7 @@ async def test_stamps_conversation_id_on_graph_span(self) -> None: ) graph_spans = [ - s for s in exporter.get_finished_spans() if s.name == "ld.ai.graph" + s for s in exporter.get_finished_spans() if s.name == "launchdarkly.graph" ] assert len(graph_spans) == 1 assert (graph_spans[0].attributes or {}).get( diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py index bf959881..6a709141 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py @@ -169,8 +169,8 @@ async def invoke( tracer_name = "@launchdarkly/ai-langchain-agents" if _HAS_OTEL: - span = trace.get_tracer(tracer_name).start_span("ld.ai.graph") - span.set_attribute("ld.ai.graph.key", def_obj.key) + span = trace.get_tracer(tracer_name).start_span("launchdarkly.graph") + span.set_attribute("launchdarkly.graph.key", def_obj.key) else: span = None @@ -395,7 +395,7 @@ def _content_str(msg: Any) -> str: final_output = _content_str(last_msg) if span: - span.set_attribute("ld.ai.graph.path", "->".join(path)) + span.set_attribute("launchdarkly.graph.path", "->".join(path)) span.set_attribute("gen_ai.usage.input_tokens", total_usage["input"]) span.set_attribute("gen_ai.usage.output_tokens", total_usage["output"]) span.set_attribute("gen_ai.usage.total_tokens", total_usage["total"]) diff --git a/packages/langchain-agents/tests/test_native_graph.py b/packages/langchain-agents/tests/test_native_graph.py index 9191a537..45b2ff5a 100644 --- a/packages/langchain-agents/tests/test_native_graph.py +++ b/packages/langchain-agents/tests/test_native_graph.py @@ -574,9 +574,11 @@ async def test_otel_span_has_graph_key_attribute(self) -> None: with patch.object(ng_mod, "_HAS_OTEL", True): await to_lang_graph(_make_def_promise(graph_def)).invoke("hi") - mock_trace.get_tracer.return_value.start_span.assert_called_with("ld.ai.graph") + mock_trace.get_tracer.return_value.start_span.assert_called_with( + "launchdarkly.graph" + ) calls = {c[0][0]: c[0][1] for c in mock_span.set_attribute.call_args_list} - assert "ld.ai.graph.key" in calls + assert "launchdarkly.graph.key" in calls @pytest.mark.asyncio async def test_terminal_leaf_connected_to_end(self) -> None: diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index 88aef9cb..4003c01e 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -131,8 +131,8 @@ async def invoke( tracer_name = "@launchdarkly/ai-openai-agents" if _HAS_OTEL: - span = trace.get_tracer(tracer_name).start_span("ld.ai.graph") - span.set_attribute("ld.ai.graph.key", def_obj.key) + span = trace.get_tracer(tracer_name).start_span("launchdarkly.graph") + span.set_attribute("launchdarkly.graph.key", def_obj.key) else: span = None @@ -286,7 +286,7 @@ async def on_agent_start(self, context: Any, agent: Any) -> None: duration = int((time.monotonic() - start_time) * 1000) if span: - span.set_attribute("ld.ai.graph.path", "->".join(path)) + span.set_attribute("launchdarkly.graph.path", "->".join(path)) span.set_attribute("gen_ai.usage.input_tokens", input_tokens) span.set_attribute("gen_ai.usage.output_tokens", output_tokens) span.set_attribute("gen_ai.usage.total_tokens", total_tokens) diff --git a/packages/openai-agents/tests/test_native_graph.py b/packages/openai-agents/tests/test_native_graph.py index d596a425..9e227599 100644 --- a/packages/openai-agents/tests/test_native_graph.py +++ b/packages/openai-agents/tests/test_native_graph.py @@ -549,7 +549,7 @@ async def test_total_tokens_tracked_on_success(self) -> None: @pytest.mark.asyncio async def test_otel_span_has_graph_key_attribute(self) -> None: - """OTel span must have ld.ai.graph.key attribute set to the graph key.""" + """OTel span must have launchdarkly.graph.key attribute set to the graph key.""" mock_span = MagicMock() mock_trace = MagicMock() mock_trace.get_tracer.return_value.start_span.return_value = mock_span @@ -569,8 +569,8 @@ async def test_otel_span_has_graph_key_attribute(self) -> None: set_attr_calls = { c[0][0]: c[0][1] for c in mock_span.set_attribute.call_args_list } - assert "ld.ai.graph.key" in set_attr_calls - assert set_attr_calls["ld.ai.graph.key"] == "test-graph" + assert "launchdarkly.graph.key" in set_attr_calls + assert set_attr_calls["launchdarkly.graph.key"] == "test-graph" @pytest.mark.asyncio async def test_agent_end_hook_emits_generation_success(self) -> None: @@ -667,14 +667,14 @@ async def _run_and_fire_hooks(agent: Any, text: str, hooks: Any = None) -> Any: with patch.object(_openai_ng, "_HAS_OTEL", True): await to_openai_agents(_make_def_promise(graph_def)).invoke("hi") - # Extract the path from the span set_attribute call for "ld.ai.graph.path" + # Extract the path from the span set_attribute call for "launchdarkly.graph.path" path_val: str | None = None for call in mock_span.set_attribute.call_args_list: - if call[0][0] == "ld.ai.graph.path": + if call[0][0] == "launchdarkly.graph.path": path_val = call[0][1] break - assert path_val is not None, "ld.ai.graph.path attribute was not set" + assert path_val is not None, "launchdarkly.graph.path attribute was not set" path_parts = [p for p in path_val.split("->") if p] child_occurrences = path_parts.count("child") assert child_occurrences <= 1, ( diff --git a/tests/test_cross_handler_parity.py b/tests/test_cross_handler_parity.py index 3ab7513a..9ab77678 100644 --- a/tests/test_cross_handler_parity.py +++ b/tests/test_cross_handler_parity.py @@ -380,10 +380,9 @@ def test_the_langchain_provider_name_is_the_configured_name( "feature_flag.context.id", "feature_flag.contextKeys", "context.contextKeys", - # Graph spans, unchanged from before the span work - "ld.ai.graph", - "ld.ai.graph.key", - "ld.ai.graph.path", + # Graph spans. The graph key is listed with the root attributes above. + "launchdarkly.graph", + "launchdarkly.graph.path", } #: Functions kept exported for one release that nothing calls any more. @@ -415,7 +414,7 @@ def _without_superseded(source: str) -> str: _KEY_PATTERN = re.compile( r'set_attribute\(\s*f?"([^"{]+)"' r'|add_event\(\s*"([^"]+)"' - r'|start_span\(\s*"(ld\.ai\.graph)"' + r'|start_span\(\s*"(launchdarkly\.graph)"' r'|"(gen_ai\.[a-z_.0-9]+)"' r'|f"(gen_ai\.[a-z_.]+)\.\{' # The feature_flag event's own attributes are built as a plain dict before being handed to From 488717e0bfa761e9a96f818fbd0b1ea66f5206e2 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Fri, 25 Sep 2026 11:27:58 -0400 Subject: [PATCH 2/2] docs(telemetry): use the launchdarkly.graph names in the contract The span table, the conversation-id section and section 10 still named ld.ai.graph, and section 10 said to rename only if js-ai-sdk #16 merged. It has, so the contract now names launchdarkly.graph and says why the prefix changed. The span table pointed at section 8 for the graph span; it now points at section 10, where the graph span is described. --- TELEMETRY-CONTRACT.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/TELEMETRY-CONTRACT.md b/TELEMETRY-CONTRACT.md index a9d4cb07..ef183049 100644 --- a/TELEMETRY-CONTRACT.md +++ b/TELEMETRY-CONTRACT.md @@ -56,7 +56,7 @@ lost. | root | `invoke_agent` | Literal. No interpolation. | | model turn | `chat {model}` | For example `chat claude-3-5-sonnet-20241022`. | | tool call | `execute_tool {tool_name}` | For example `execute_tool get_weather`. | -| graph | `ld.ai.graph` | Already correct in Python. Do not touch. See section 8. | +| graph | `launchdarkly.graph` | LaunchDarkly-owned, so it takes the `launchdarkly.` prefix. See section 10. | The model goes in the `chat` span name because the semantic conventions name an inference span `{gen_ai.operation.name} {gen_ai.request.model}`. A bare `chat` aggregates more neatly but tells a @@ -130,7 +130,7 @@ An app that opens a fresh CLI session per turn and re-feeds history must pass it 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. +on `launchdarkly.graph`) via the shared span processor. No id is invented when the caller supplies none. --- @@ -641,12 +641,13 @@ implement this as a passthrough. ## 10. What not to touch -The graph span is already at parity. `ld.ai.graph` with `ld.ai.graph.key` and `ld.ai.graph.path` -matches between `client/graph.ts` and `graph.py`, and across all three `native_graph` pairs. +The graph span is at parity. `launchdarkly.graph` with `launchdarkly.graph.key` and +`launchdarkly.graph.path` matches across all three `native_graph` pairs. -`js-ai-sdk` PR #16 renames these three to `launchdarkly.graph`, `launchdarkly.graph.key` and -`launchdarkly.graph.path`. If that PR merges, make the same rename here, in `graph.py` and the -three `native_graph.py`. If it does not, change nothing. +These used to be `ld.ai.graph`, `ld.ai.graph.key` and `ld.ai.graph.path`. `js-ai-sdk` #16 renamed +them so the graph span and the `invoke_agent` root share one name for the graph key, and so +everything LaunchDarkly owns on a span sits under `launchdarkly.`. `ld.ai.` stays reserved for +metric and event keys. Nothing else in `graph.py` or `native_graph.py` needs work.