Skip to content

feat(client): add result variant and value to the feature_flag span event - #240

Merged
apucacao merged 19 commits into
mainfrom
ag/ff-result-variant
Oct 29, 2026
Merged

apucacao merged 19 commits into
mainfrom
ag/ff-result-variant

Conversation

@apucacao

@apucacao apucacao commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Runs made through the turnkey SDK don't show up on an AI Config's monitoring traces tab. Turnkey writes the feature_flag span event without feature_flag.result.value. Since launchdarkly/observability#2553, a trace search for a flag key only scans hours where a feature_flag event with a value was recorded, so turnkey runs are never found.

This adds two attributes to the event set_ld_span_attributes writes:

  • feature_flag.result.variant: the variation key. The OpenTelemetry feature-flag conventions make it conditionally required when the provider has variants.
  • feature_flag.result.value: the evaluated value, gated by content capture (below).

Content capture

The raw variation holds the prompt, so it only leaves the process when the user opted in. This uses the existing capture_content setting, including the configure() default; no new setting.

  • Capture on and a raw variation present: json.dumps of the variation exactly as the SDK returned it, the same serialization as the ldotel tracing hook with value inclusion on.
  • Otherwise, when the variation key is non-empty: json.dumps of the key, for example "\"v1\"".
  • Otherwise the value is left off (native-graph spans, which have no variation key).

Two spans have no single handler. The graph() root follows the configured process default. A deferred judge (run_judge) follows the judge handler's setting.

What changed

  • extract_variation returns the raw variation from client.variation as value, beside meta rather than on it, since meta is public as GraphNode.meta and JudgeTask.judge_meta. This matches JS extractVariation.
  • execute_and_track / execute_and_stream take it as a keyword-only evaluated_value and pass it to handlers as variables["__ldValue"], next to __ld. It is kept out of the __ld track data, which rides on every $ld:ai:* track event. A caller-supplied __ldValue in variables is dropped, the same way __ld is overwritten. __ldValue is set only when the handler's effective capture setting is on (a plain function follows the process default), so a handler built with capture_content=False never receives the prompt, as in the JS SDK.
  • Every path that has the raw variation passes it: config(), inline judges, graph nodes (kept in a private map, not on GraphNode), deferred judges (JudgeTask.judge_value, passed on by run_judge), and the graph() root (the graph variation).
  • set_ld_span_attributes takes a keyword-only capture_content. Every handler passes the same resolved setting it already uses for its other content. Left unset, it trusts the core and writes __ldValue whenever it is present, since the core only passes it to a capture-on handler; only an explicit False suppresses it. This matches the JS helper.
  • create_handler behavior change, not covered by the title: calling it on a ProviderHandler keeps that handler's stream and explicit capture_content override, as of when it is wrapped, unless new ones are passed. Before, the new handler started with no stream and an unset capture_content. This matches the JS SDK and §3.14 in launchdarkly/ai-sdks-monorepo#113.
  • The cross-handler span vocabulary lock lists both new keys.
  • TELEMETRY-CONTRACT.md and the client API report are updated.

Testing

  • Helper tests: variant present, absent for an empty or missing key; value is the raw variation with capture on, the JSON-encoded key with capture off or no raw variation, and absent with neither.
  • graph() root tests for invoke and stream: the raw graph variation with capture on, the key with capture off, and nothing with capture off and no variation key.
  • A deferred judge test: build_judge_tasks stores the raw judge variation on the task (it survives a JSON round trip), and run_judge hands it to the judge handler.
  • extract_variation returns the raw variation beside meta, and meta does not contain it.
  • A caller-supplied __ldValue never reaches the handler, and a custom handler built with capture_content=False whose function calls the helper without a flag writes only the key.
  • An unset capture_content on the helper writes the value whenever the core passed it, and a custom handler with an explicit True under configure(capture_content=False) gets the full value.
  • The cross-handler parity suite checks that every handler passes capture_content= to its root span calls.
  • An openai-agents case with capture on asserts the value is the serialized raw variation; the exact-attributes test (capture off) expects the JSON-encoded key.
  • uv run pytest (whole repo), ruff check and mypy pass.

The cross-SDK spec change is in launchdarkly/ai-sdks-monorepo#113.


Note

Overview
Turnkey AI runs were missing from AI Config Monitoring traces because the root feature_flag event had no feature_flag.result.value. This PR adds feature_flag.result.variant and feature_flag.result.value on that event (documented in TELEMETRY-CONTRACT.md), aligned with the ldotel tracing hook.

set_ld_span_attributes now accepts capture_content: with capture on and variables['__ldValue'], the value is json.dumps of the raw variation; otherwise a non-empty variation key is written as JSON-encoded key; both attributes are omitted when there is no key (e.g. native graph roots). extract_variation returns the SDK payload as value beside meta. execute_and_track / execute_and_stream take evaluated_value and inject SDK-owned __ldValue only when the handler’s effective capture setting is on; caller-supplied __ldValue is stripped. config(), graph nodes, inline/deferred judges, and graph() roots all pass the evaluated variation through those paths.

Every provider handler passes resolved capture_content into start_root_span so root spans honor the same gate. create_handler re-wrapping a ProviderHandler now preserves its stream fn and explicit capture_content override unless overridden.

Cross-handler parity tests require capture_content= on root span calls; new unit/integration tests cover variant/value rules, graph invoke/stream, judges, and handler re-wrap behavior.

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

Serialization

feature_flag.result.value is written with json.dumps defaults, the same as the ldotel tracing hook, so the Python SDK and the Python plugin produce identical values. JavaScript's JSON.stringify omits the spaces; launchdarkly/gonfalon#76569 makes the flag Monitoring tabs match variations by parsed JSON value, so both forms resolve to the same variation.

Keep the raw variation value that client.variation returns and hand it to handlers as variables["__ldValue"], next to __ld. It stays out of the __ld track data on purpose: that data rides on every $ld:ai:* track event, and the value includes the prompt.
…vent

AI Config monitoring only finds traces whose feature_flag event carries feature_flag.result.value, which the LaunchDarkly observability plugins always send. Write the variation key as feature_flag.result.variant (conditionally required by the OpenTelemetry feature-flag conventions) and the evaluated value as feature_flag.result.value, serialized with json.dumps exactly like the ldotel tracing hook. Both are omitted when there is nothing to write, for example on native-graph spans.
@apucacao
apucacao marked this pull request as ready for review October 28, 2026 19:39

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

Comment thread packages/client/src/launchdarkly_ai_server/lifecycle.py Outdated
The parity suite fails on any span attribute key it does not know about,
and CI caught feature_flag.result.variant. Both result keys are deliberate:
they are the OpenTelemetry feature-flag convention's served variant and
evaluated value.
The raw variation holds the prompt, so it must not leave the process unless
the user opted in to content capture. set_ld_span_attributes takes the
handler's resolved capture_content (the existing setting: explicit on the
handler, else the configure() default, else off). With capture on and a raw
variation present, result.value is json.dumps of that variation, as before.
Otherwise it is json.dumps of the variation key when there is one, which
also gives graph() roots a value. Otherwise it is omitted.

Every handler package passes its own resolved setting to the root span, and
the graph() roots pass the process default. Native-graph roots keep the
default; they have no variation key and no raw variation, so nothing is
written either way. The api report records the new keyword argument.
@apucacao
apucacao requested a review from a team as a code owner October 28, 2026 20:04
@apucacao
apucacao requested a review from jeffdupont October 28, 2026 20:20

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

Matches the updated #113 spec: variant, gated value, key fallback, and nothing when both are missing. I checked that every start_root_span / _root_span call now passes capture_content. One small thing inline, plus:

  • Capture on is only tested at the helper level. The handler tests and tests/test_cross_handler_parity.py assert the capture-off case only. Each of the 12 handlers passes capture_content= to start_root_span by hand. If one of those arguments is dropped, every test still passes and capture-on users quietly get the key instead of the value. §1.5 asks handler tests to cover capture on too. The parity file already checks handler source text, so one parametrized check that every handler's start_root_span( / _root_span( call passes capture_content= would cover it. A behavioral capture-on case in one handler would also do.
  • Serialization question. gonfalon's flag Monitoring Traces tab groups chart series by the exact feature_flag.result.value string, matched against JSON.stringify(variation.value) (static/ld/components/feature-monitoring/hooks/useVariationChartConfig.ts:8,45-49). json.dumps writes {"a": 1} with spaces, so it never matches, and neither does the capture-off key fallback. Do you know which UI reads result.value for AI Configs? If it's that chart, json.dumps(value, separators=(",", ":")) would line up the capture-on case.
  • The PR description is out of date. Line 15 says "The value isn't gated by content capture, matching the plugins". The code now gates it.

Comment thread packages/client/src/launchdarkly_ai_server/graph.py
graph() roots never carry a raw variation, so the capture setting cannot
change what their feature_flag event writes. Pass the variables alone, as
before, and drop the now-unused imports.
The parity suite now checks that every handler passes capture_content= to
each root span call, since a missing keyword falls back to off and silently
drops the value for capture-on runs. One openai-agents case runs the
handler with capture on and asserts the event carries the raw variation.
…es test

Content capture now defaults to on, so the test's raw variation became the
result value. It asserts the capture-off key fallback, so say so.
@apucacao
apucacao requested a review from jeffdupont October 29, 2026 03:00

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

Since my last review: 8702b2c and the new check in tests/test_cross_handler_parity.py cover the capture-on wiring in every handler, 208c1e3 drops the capture argument from the graph root as suggested, and the description now says the value is gated and points to gonfalon#76569 for the serialization question. Thanks.

This matches launchdarkly/ai-sdks-monorepo#113 and the JS PR (#186) on the parts that show up on the span. Both attribute names are right. The variant is left off when the variation key is empty or missing. With capture on, the value is json.dumps of the raw variation. That is what ldotel/tracing.py writes. With capture off, the value is the JSON-encoded key, and it is left off when there is neither. Every handler's root span passes its resolved capture setting. I checked claude-agents, litellm-agents, langchain-messages, typesafe, opencode and vercel, and the AST parity test covers the rest. The change is additive: set_ld_span_attributes gains a keyword-only argument with a default, and no attribute or public name is removed.

One point is worth settling before 1.0. The raw variation is stored on meta as evaluatedValue, and meta is public. JS returns it next to meta and leaves VariationMeta alone. See the inline comment.

A note on parity, not on this PR: Python's capture_content defaults to True (effective_capture_content, SDKOptions in the API report). JS handlers default captureContent to false. So with no configuration, a Python run sends the full AI Config JSON in feature_flag.result.value, and a JS run sends only the key.

Comment thread packages/client/src/launchdarkly_ai_server/lifecycle.py Outdated
meta is public through GraphNode.meta and JudgeTask.judge_meta, so a
public evaluatedValue key would expose the full prompt in user logging
regardless of capture_content, and renaming it after 1.0 would break
callers. Store it as _evaluatedValue, read only by the tracking
helpers.
@apucacao
apucacao requested a review from jeffdupont October 29, 2026 05:25

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

Comment thread packages/client/src/launchdarkly_ai_server/lifecycle.py Outdated
A private _evaluatedValue key still rode on public objects: GraphNode.meta
and JudgeTask.judge_meta carried the full prompt into any log or pickle of
them, whatever capture_content was set to.

extract_variation now returns the raw variation as a separate value, as
JS's extractVariation does. execute_and_track and execute_and_stream take
it as a keyword-only evaluated_value, config() and inline judges pass it,
and the graph runner keeps node values in a private map beside its nodes.
A JudgeTask run falls back to the key, as in JS.

@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 a61c664. Configure here.

Comment thread packages/client/src/launchdarkly_ai_server/judges.py
….result.value

A deferred judge (skip_judges=True, then run_judge) wrote the variation
key even with content capture on, while an inline judge wrote the raw
variation. JudgeTask now carries it as judge_value, and run_judge passes
it on, so both paths write the same value. The task already holds the
judge config and the response, so this adds no new kind of content.
…on the graph root

The graph root span always wrote the key. It now writes the graph
variation as returned by the LD SDK when content capture is on, and the
key otherwise. The root has no single handler, so it follows the process
default. The helper also skips a null value instead of writing "null".
# Conflicts:
#	packages/client/api-report/launchdarkly_ai_server.txt

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

Since my last review: a61c664 takes the raw variation off meta entirely. extract_variation now returns it as a separate value, so GraphNode.meta and JudgeTask.judge_meta no longer carry the prompt. 36e2e93 carries the judge variation on JudgeTask.judge_value for deferred judges. 404fcc1 gives the graph root the graph variation under the configured default. That replaces my earlier suggestion to drop the argument there, and it is the right call now that the root has a value. Both have tests, with capture on and off for invoke and stream.

This matches the spec in ai-sdks-monorepo #113 and js-ai-sdk #186 on everything that shows up on the span. json.dumps matches what ldotel/tracing.py writes. Every handler passes its resolved capture setting, and the AST check guards that. The API changes are additive.

One request: the description still says graph() roots never carry a raw variation, and the testing section says the root tests check only the key fallback. 404fcc1 changed both, so please update it.

For the spec rather than this PR: set_ld_span_attributes defaults capture_content to False. A custom handler therefore gets only the key unless it passes the flag, while JS writes the value whenever the core passes it. I've asked on #113 for the spec to pick one.

execute_and_track and execute_and_stream merged the caller's variables
and only set __ldValue when there was an evaluated value, so a
caller-supplied __ldValue could reach the handler and be written as
feature_flag.result.value. Both now drop it first, the same as the JS
SDK does.
…e default

capture_content defaulted to False, so a custom handler that called the
helper without the flag sent only the variation key, while the JS SDK
writes whatever the core passes, which follows configure(). The argument
is now bool | None = None, and None reads the process default at call
time. Built-in handlers already pass their resolved setting, so they are
unchanged.
…hen it is wrapped again

create_handler(q, create_handler(p, fn, None, False)).capture_content was
True, because the new ProviderHandler started unset. Wrapping a
ProviderHandler again now keeps its streaming implementation and its
explicit capture_content override unless new ones are passed, as the JS
SDK does. An unset inner handler stays unset and still follows
configure().
@apucacao
apucacao requested a review from jeffdupont October 29, 2026 19:55

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

Re-checked 4e1d24e..4173b65.

558cc6f (strip a caller-supplied __ldValue) and 4173b65 (a re-wrap keeps the stream and the explicit capture_content override, as of when it's wrapped) both look right. 4173b65 matches §3.14 in ai-sdks-monorepo#113 and js-ai-sdk#190. Could you add a line to the description for the create_handler change, since the title doesn't cover it?

One problem with 248ad6d. set_ld_span_attributes now falls back to the process default when capture_content is None, but execute_and_track / execute_and_stream still hand every handler __ldValue regardless of its setting (tracking.py:222, :328). A custom handler's function can't see its own ProviderHandler, so it calls the helper without a flag, and its explicit capture_content=False is skipped. I ran this: create_handler(("Custom","messages"), fn, None, False), where fn calls set_ld_span_attributes(span, variables), with no configure(). At 4e1d24e it writes "v1". At 4173b65 it writes the whole variation, system prompt included, as feature_flag.result.value. §3.9a says an explicit false beats the configured default, and JS (#186) gates this in the core on the handler's effective setting, so a JS custom handler with captureContent: false only gets the key.

Suggested fix, matching JS: only set __ldValue in execute_and_track / execute_and_stream when handler.capture_content is true, and add a test for the case above. The helper's None default can stay as a second gate. I'll ask for the matching edit on #113's new line 398, which currently describes the Python side as "the process default" rather than the handler's setting.

…tent

execute_and_track and execute_and_stream handed __ldValue to every
handler, and since set_ld_span_attributes now follows the process default
when no flag is passed, a custom handler built with capture_content=False
whose function called the helper without a flag exported the whole
variation. The core now sets __ldValue only when the handler's effective
setting is on (a plain function follows the process default), as the JS
SDK does. The helper's default stays as a second gate.
@apucacao
apucacao requested a review from jeffdupont October 29, 2026 20:29

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

Thanks, f168176 closes the leak. I re-ran my probe: a custom handler with capture_content=False whose function calls set_ld_span_attributes(span, variables) now writes only "v1", and the new test fails on 4173b65 and passes here. The client suite and the built-in handler suites pass. The caller-__ldValue strip and the re-wrap behaviour are unchanged.

One case left, and it's my fault, since I suggested keeping the helper's None default as a second gate. With configure(capture_content=False), a custom handler built with an explicit capture_content=True now gets only the key. The core passes __ldValue because the handler's effective setting is True, but the helper resolves its own None to the process default (utils.py:842-846) and drops it. §3.9a says an explicit true beats a configured false. js-ai-sdk#186's setLdSpanAttributes writes __ldValue whenever the core passed it, so JS writes the full value in this case.

Suggested fix: have set_ld_span_attributes treat capture_content=None as "trust the core" and write __ldValue when it's present. An explicit False still suppresses it, and built-in handlers are unaffected because they pass their resolved flag. Plus a test for explicit True under configure(False). Then I'm happy to approve.

…ontent is None

With configure(capture_content=False), a custom handler built with an
explicit capture_content=True got only the key: the core passed __ldValue
because the handler's effective setting was on, but the helper resolved
its own None to the process default again and dropped it. The core
already passes __ldValue only to a capture-on handler, so None now writes
it whenever it is present, as the JS helper does. An explicit False still
suppresses it.
@apucacao
apucacao requested a review from jeffdupont October 29, 2026 20:44

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

Approving at 0ccd265. set_ld_span_attributes now trusts the core when capture_content is None and only an explicit False suppresses the value, so a custom handler's explicit True beats configure(capture_content=False).

I re-ran the full matrix at 0ccd265 and f168176:

  • explicit False gives the key
  • unset with configure(False) gives the key
  • explicit True with configure(False) now gives the full value; at f168176 it gave the key
  • unset and plain function under the default give the full value
  • a plain function with configure(False) gives the key
  • a forged __ldValue is stripped
  • the streaming path matches invoke

The new parameterized test fails at f168176 and passes here. The client, cross-handler parity and built-in handler suites all pass. This now matches js-ai-sdk#190 and the spec at ai-sdks-monorepo#113 8a86c34 case for case. Thanks for sticking with this one.

@apucacao
apucacao merged commit 040d487 into main Oct 29, 2026
11 checks passed
@apucacao
apucacao deleted the ag/ff-result-variant branch October 29, 2026 22:30
@github-actions github-actions Bot mentioned this pull request Oct 29, 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