Skip to content

fix: give native graph spans their LaunchDarkly identity - #101

Open
apucacao wants to merge 7 commits into
mainfrom
fix/native-graph-span-identity
Open

apucacao wants to merge 7 commits into
mainfrom
fix/native-graph-span-identity

Conversation

@apucacao

@apucacao apucacao commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Spans from the native graph adapters (toOpenAIAgents, toClaudeAgents, toLangGraph, toVercelAgents) had nothing tying them to the graph's AI Config, so Monitoring couldn't find them by config key.

  • The launchdarkly.graph span now gets setLdSpanAttributes, like every other handler: config key, run id, context keys and the feature_flag event. New helper: makeGraphTrackData.
  • Graph and node tracking events now carry the environment id.
  • Graph-level events ($ld:ai:graph:invocation_success and friends) now use the graph key as configKey, matching the span and graph(). They used the root node's key.
  • The graph() runner's own launchdarkly.graph span gets the same identity.
  • Every adapter now ends its graph span in a finally. toLangGraph never ended it, and toOpenAIAgents / toClaudeAgents left it open on setup errors.
  • makeGraphTrackData is marked @internal.

Python twin: launchdarkly/python-ai-sdk#121.

Testing

  • Adapter and client tests cover the span attributes, the event and the environment id. The LangGraph span-end tests fail on main.
  • Staging: graph run with this branch.

🤖 Generated with Claude Code


Note

Overview
Native graph runners and the built-in graph() client now tag launchdarkly.graph spans with the same LaunchDarkly identity as other handlers via setLdSpanAttributes and a new makeGraphTrackData helper, so AI Config Monitoring can correlate traces to the graph flag (config key, run id, context keys, feature_flag event with environment id).

Graph-level $ld:ai:graph:* events (success, failure, duration, tokens) now use the graph key as configKey instead of the root node. environmentId is attached to graph- and node-level track payloads through tryGetEnvironmentId.

All native adapters (toClaudeAgents, toLangGraph, toOpenAIAgents, toVercelAgents) end the graph span in a finally block so spans export on setup errors and LangGraph no longer leaks open spans.

Reviewed by Cursor Bugbot for commit 1cb2231. Bugbot is set up for automated code reviews on this repo. Configure here.

apucacao and others added 3 commits September 30, 2026 18:45
Every $ld:ai:* event that executeAndTrack sends carries the LaunchDarkly
environment id, because AI Config Monitoring needs it to match a trace to
the config that produced it. The two payloads built outside that path did
not: makeNodeTrackData, used by every native graph adapter, and the
graph-level graphTrackData in graph.ts.

Read the id through the same tryGetEnvironmentId helper, which is now
exported inside the client package so graph.ts can call it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A native graph adapter has no TrackData of its own: makeNodeTrackData
describes one node, and the run as a whole is the graph flag. Add
makeGraphTrackData, which names the graph flag as both the config key and
the graph key, the same choice the SDK's own graph runner makes.

Adapters pass it to setLdSpanAttributes in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
toOpenAIAgents, toClaudeAgents, toLangGraph and toVercelAgents each open a
launchdarkly.graph span that carried only the graph key and token counts.
The AI Config Monitoring tab finds a trace by a feature_flag span event and
the config key, so none of these runs appeared against their AI Config.

Tag the span through setLdSpanAttributes, the same helper every handler
already uses, so the graph span now carries launchdarkly.config.key,
launchdarkly.variation.key, launchdarkly.run.id, the context keys, and the
feature_flag event with feature_flag.set.id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
toLangGraph opened its launchdarkly.graph span and never ended it, on
success or failure. An unended span is never handed to the exporter, so
LangGraph users got no graph span in their traces at all. The body now
runs in try/finally, matching toVercelAgents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@apucacao

apucacao commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

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

Stale Bugbot comment from a previous run.

@apucacao
apucacao marked this pull request as ready for review October 1, 2026 14:27
apucacao and others added 2 commits October 2, 2026 13:12
The launchdarkly.graph span that graph() opens carried only
launchdarkly.graph.key. It now goes through setLdSpanAttributes with the
same track data its graph events use, so it has the config key,
variation, run id, context keys and the feature_flag event, like the
native adapters' graph span. invoke() runs the same walk as stream(), so
one call site covers both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The $ld:ai:graph:invocation_success, invocation_failure, duration:total
and total_tokens events (and path, in vercel-agents) from the native
adapters carried the root node's config key, while their graph span and
graph() use the graph key. They now use makeGraphTrackData, so a graph
run reports the same config key whichever runner produced it.

toOpenAIAgents and toClaudeAgents ended the graph span only on the run's
own success and failure paths. An error while building the agents or
sub-agent tools left it open, so it was never exported. Both bodies now
end the span in a finally, as toLangGraph and toVercelAgents do.

The adapters no longer set launchdarkly.graph.key by hand, since
setLdSpanAttributes already does. makeGraphTrackData is marked
@internal: only the adapters use it, and its signature changes if
GraphDefinition gains the graph's variation metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@apucacao

apucacao commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

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

Stale Bugbot comment from a previous run.

…-identity

# Conflicts:
#	packages/client/src/index.ts
@apucacao

apucacao commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 1cb2231. Configure here.

@jeffdupont jeffdupont left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Parity review alongside python #121 (launchdarkly/python-ai-sdk#121). This PR already handles most of what I raised there. Graph-level events in all four adapters now use the graph key through makeGraphTrackData. The graph() span gets setLdSpanAttributes (graph.ts:724). The hand-set launchdarkly.graph.key is gone, and every adapter ends its span in a finally. Tested at 1cb2231: yarn build, yarn test (1,174 tests, exit 0), yarn typecheck and biome check . all clean.

Nothing here blocks the merge. Two things I'd like settled before GA. Python has both of them too:

  1. Write the graph identity into TESTING.md. The monorepo spec (main at c2cca4f) doesn't say that the launchdarkly.graph span carries launchdarkly.config.key, the run id and the feature_flag event. It also doesn't say which configKey graph-level events use. Both PRs now agree on the graph key, so now is a good time to write it down, before the next change in either SDK drifts. I didn't find a monorepo PR for it.
  2. Graph-level events report a different variation depending on the runner. graph() sends the graph flag's real variationKey and version (graph.ts:133-142). The native adapters now send variationKey: '' and version: 1 (tracking.ts:133), where before this PR they sent the root node's. So one graph flag's $ld:ai:graph:invocation_success carries a different variation depending on which runner sent it, and per-variation views of native runs come up empty. Before 1.0, either expose the graph flag's meta on GraphDefinition or write this down in the spec as a known gap.

Smaller notes:

  • makeGraphTrackData is still exported from the root (index.ts:44). The @internal tag is only documentation: the repo has no stripInternal, and the name is in the built dist/index.d.ts. Python #121 has since dropped it from __all__ (d693d68). JS can't do that yet, because the client package.json only exports ., so I'm fine with it merging as is. I'll add it to the 1.0 trim list so it moves to the internal entry point with makeNodeTrackData.
  • Every graph() run with the same context object gets the same launchdarkly.run.id. I reproduced it: two invoke() calls on one graph() with the same context gave one run id on both spans and both invocation_success events. The cause is that runId is created in buildGraph (graph.ts:134) and the build is cached per context (graph.ts:636-643). That predates this PR for events, but now it's on the span too. Python caches the same way (graph.py:898-909); I haven't run it there. The run id is what ties a trace to its events, so I'd fix this in both SDKs before GA.
  • Setup errors are handled differently in the two SDKs. Here a setup error (for example "Root agent ... was not built") ends the span with status UNSET, no recorded exception and no invocation_failure. Python #121 opens the span only after setup, so it exports no span at all. Either approach is fine, but they should match.

export { compose, globalRegistry, Registry } from './registry.js';
export { registerAiSdkPackage } from './sdk-info.js';
export { makeNodeTrackData, makeRunTrackData } from './tracking.js';
export { makeGraphTrackData, makeNodeTrackData, makeRunTrackData } from './tracking.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@internal doesn't keep this out of the public surface. There's no stripInternal in the tsconfigs, and makeGraphTrackData is in the built dist/index.d.ts. Python #121 dropped make_graph_track_data from __all__ in d693d68. JS can't do that until the client has an internal entry point (the exports map only has .), so this is fine for now. I'll add it to the 1.0 trim with makeNodeTrackData.

export const makeGraphTrackData = (graphKey: string, runId: string): TrackData => ({
runId,
configKey: graphKey,
variationKey: '',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Graph-level events from the native adapters now carry variationKey: '' and version: 1. Before this PR they carried the root node's variation, and graph() sends the graph flag's real ones (graph.ts:133-142). So the same event from the same graph flag reports a different variation depending on the runner. The doc comment explains why (GraphDefinition has no _ldMeta). Can we either add the meta before 1.0 or write the gap into TESTING.md? Python's make_graph_track_data has the same issue.


const span = trace.getTracer('@launchdarkly/ai-server').startSpan('launchdarkly.graph', undefined, callerContext);
span.setAttribute('launchdarkly.graph.key', key);
setLdSpanAttributes(span, { __ld: graphTrackData, ldContext: context });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

graphTrackData.runId is created once in buildGraph (line 134), and the build is cached per context reference (lines 636-643). That means every run with the same context object reuses the same launchdarkly.run.id on this span. I checked with a scratch test: two invoke() calls with one context gave identical run ids on both launchdarkly.graph spans and both invocation_success events. The events behaved this way before this PR. Could the run id be made per run, the way the native adapters do it? Python looks the same (graph.py:898-909), but I haven't run it.

try {
const startTime = Date.now();
const runId = crypto.randomUUID();
setLdSpanAttributes(span, { __ld: makeGraphTrackData(def.key, runId), ldContext });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since the setup code is inside the try, a setup error (for example "Root agent ... was not built") now ends and exports the span, which is good. But the span has status UNSET, no recorded exception, and no $ld:ai:graph:invocation_failure. Python #121 does it differently: it opens the span only after setup (d693d68), so a setup error produces no span at all. Either is fine, but the SDKs should match. The same applies to claude-agents and langchain-agents.

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