Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
94564a8
feat(client): pass the evaluated variation to handlers
apucacao Oct 28, 2026
9db397d
feat(client): add result variant and value to the feature_flag span e…
apucacao Oct 28, 2026
56ac296
test: add the feature_flag result keys to the span vocabulary lock
apucacao Oct 28, 2026
3cb5372
feat(client): respect content capture for feature_flag.result.value
apucacao Oct 28, 2026
208c1e3
refactor(client): drop the capture setting from graph root spans
apucacao Oct 28, 2026
8702b2c
test: cover content capture on the root span's feature_flag value
apucacao Oct 28, 2026
d468be1
Merge remote-tracking branch 'origin/main' into ag/ff-result-variant
apucacao Oct 28, 2026
a9aa7a6
test(openai-agents): pin capture off in the root event exact-attribut…
apucacao Oct 28, 2026
296fc3c
fix(client): keep the raw variation under a private meta key
apucacao Oct 29, 2026
7023ec6
Merge remote-tracking branch 'origin/main' into ag/ff-result-variant
apucacao Oct 29, 2026
a61c664
fix(client): keep the raw variation off meta entirely
apucacao Oct 29, 2026
36e2e93
feat(client): carry the judge variation on JudgeTask for feature_flag…
apucacao Oct 29, 2026
404fcc1
feat(client): write the graph variation as feature_flag.result.value …
apucacao Oct 29, 2026
4e1d24e
Merge remote-tracking branch 'origin/main' into ag/ff-result-variant
apucacao Oct 29, 2026
558cc6f
fix(client): never pass a caller-supplied __ldValue to a handler
apucacao Oct 29, 2026
248ad6d
feat(client): let set_ld_span_attributes follow the configured captur…
apucacao Oct 29, 2026
4173b65
feat(client): keep a handler's stream and explicit capture override w…
apucacao Oct 29, 2026
f168176
fix(client): pass the raw variation only to handlers that capture con…
apucacao Oct 29, 2026
0ccd265
fix(client): let set_ld_span_attributes trust the core when capture_c…
apucacao Oct 29, 2026
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
2 changes: 2 additions & 0 deletions TELEMETRY-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ The root also carries one span event, `feature_flag`, with these event attribute
|---|---|
| `feature_flag.key` | config key |
| `feature_flag.provider.name` | `LaunchDarkly` |
| `feature_flag.result.variant` | variation key, only when non-empty (graph spans may have none) |
| `feature_flag.result.value` | with content capture on (the existing `capture_content` setting, including the `configure()` default) and a raw variation present: `json.dumps` of the variation as the LaunchDarkly SDK returned it (`_ldMeta` included), 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\""`), which also covers `graph()` roots. Otherwise absent (native-graph spans) |
| `feature_flag.set.id` | environment id, only when present |
| `feature_flag.context.id` | canonical context key, only when `variables.ldContext` has a usable identity |
| `feature_flag.contextKeys` | compact JSON of per-kind keys, only when identity is present |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,7 @@ async def _call_impl(
th = tool_handlers or {}
vs = variables or {}

span = start_root_span(config, vs)
span = start_root_span(config, vs, capture_content=capture_content)
parent = parent_context_of(span)
# Cleared by whichever path ends the root, so the finally below can tell an open root
# from a closed one without asking the span. A mock span answers `is_recording()`
Expand Down Expand Up @@ -1086,7 +1086,7 @@ async def _stream_gen(
exit abandons it, and asyncio's finalizer later raises ``RuntimeError`` when it is suspended
inside a real await in the SDK.
"""
span = start_root_span(config, variables)
span = start_root_span(config, variables, capture_content=capture_content)
parent = parent_context_of(span)

prompt, system_prompt = _build_prompt(config, user_input, variables, history)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ def tool_display_name(provider_name: str) -> str:
# ─── Span starts ─────────────────────────────────────────────────────────────


def start_root_span(config: AiConfigRep, variables: dict[str, Any]) -> Any:
def start_root_span(
config: AiConfigRep, variables: dict[str, Any], *, capture_content: bool = False
) -> Any:
"""Opens the ``invoke_agent`` root and returns it, or ``None`` when OTel is absent.

The root is the only span carrying ``launchdarkly.*`` and the ``feature_flag`` event, so it is
Expand All @@ -92,7 +94,7 @@ def start_root_span(config: AiConfigRep, variables: dict[str, Any]) -> Any:
span = trace.get_tracer(TRACER_NAME).start_span("invoke_agent")
span.set_attribute("gen_ai.operation.name", "invoke_agent")
set_model_identity_attributes(span, PROVIDER, model_name(config))
set_ld_span_attributes(span, variables)
set_ld_span_attributes(span, variables, capture_content=capture_content)
return span


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ async def _call_impl(
th = tool_handlers or {}
vs = variables or {}

span = start_root_span(config, vs)
span = start_root_span(config, vs, capture_content=capture_content)
parent = parent_context_of(span)
# Cleared by whichever path ends the root, so the `finally` can tell an open root from a
# closed one without asking the span. A mock span answers `is_recording()` truthily, and the
Expand Down Expand Up @@ -713,7 +713,7 @@ async def _stream_gen(

``ended`` stops the success, failure and abandonment paths from ending the same span twice.
"""
span = start_root_span(config, variables)
span = start_root_span(config, variables, capture_content=capture_content)
parent = parent_context_of(span)

messages, system = _build_messages(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ def model_name(config: AiConfigRep) -> str:
# ─── Span starts ─────────────────────────────────────────────────────────────


def start_root_span(config: AiConfigRep, variables: dict[str, Any]) -> Any:
def start_root_span(
config: AiConfigRep, variables: dict[str, Any], *, capture_content: bool = False
) -> Any:
"""Opens the ``invoke_agent`` root and returns it, or ``None`` when OTel is absent.

The root is the only span carrying ``launchdarkly.*`` and the ``feature_flag`` event, so it is
Expand All @@ -57,7 +59,7 @@ def start_root_span(config: AiConfigRep, variables: dict[str, Any]) -> Any:
span = trace.get_tracer(TRACER_NAME).start_span("invoke_agent")
span.set_attribute("gen_ai.operation.name", "invoke_agent")
set_model_identity_attributes(span, PROVIDER, model_name(config))
set_ld_span_attributes(span, variables)
set_ld_span_attributes(span, variables, capture_content=capture_content)
return span


Expand Down
9 changes: 5 additions & 4 deletions packages/client/api-report/launchdarkly_ai_server.txt
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ EvaluationsModule [class]
sdk_key: str | None [property]
tools: ToolsClient [property]
ui_base_uri: str [property]
execute_and_stream(*, config_key: str, config: AiConfigRep, meta: VariationMeta, user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[ExecuteStreamEvent, None] [async]
execute_and_track(*, config_key: str, config: AiConfigRep, meta: VariationMeta, user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None) -> dict[str, Any] [async]
execute_and_stream(*, config_key: str, config: AiConfigRep, meta: VariationMeta, user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None, evaluated_value: Any = None) -> AsyncGenerator[ExecuteStreamEvent, None] [async]
execute_and_track(*, config_key: str, config: AiConfigRep, meta: VariationMeta, user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None, evaluated_value: Any = None) -> dict[str, Any] [async]
ExecuteStreamDoneEvent = dict[str, Any]
ExecuteStreamEvent = dict[str, Any]
extract_variation(config_key: str, context: dict[str, Any]) -> dict[str, Any] [async]
Expand Down Expand Up @@ -173,7 +173,7 @@ JudgeRunResult [class, @dataclass]
track_data: TrackData
usage: UsageDict
JudgeTask [class, @dataclass]
__init__(config_key: str, judge_config: AiConfigRep, judge_meta: VariationMeta, actual_output: str, user_context: LDContext, judge_provider: str | None, judge_mode: str, collapse_messages: bool, parent_track_data: TrackData, variables: dict[str, Any] | None = None, evaluation_metric_key: str | None = None, user_input: str | None = None, trajectory: str = '')
__init__(config_key: str, judge_config: AiConfigRep, judge_meta: VariationMeta, actual_output: str, user_context: LDContext, judge_provider: str | None, judge_mode: str, collapse_messages: bool, parent_track_data: TrackData, variables: dict[str, Any] | None = None, evaluation_metric_key: str | None = None, user_input: str | None = None, trajectory: str = '', judge_value: Any = None)
actual_output: str
collapse_messages: bool
config_key: str
Expand All @@ -182,6 +182,7 @@ JudgeTask [class, @dataclass]
judge_meta: VariationMeta
judge_mode: str
judge_provider: str | None
judge_value: Any = None
parent_track_data: TrackData
trajectory: str = ''
user_context: LDContext
Expand Down Expand Up @@ -317,7 +318,7 @@ SDKOptions [class, @dataclass(frozen=True)]
capture_content: bool = True
set_conversation_id_if_absent(span: Any, conversation: str) -> None
set_input_content_attributes(span: Any, capture: bool, *, system_instructions: str | None = None, messages: list[SpanMessage] | None = None, tool_definitions: list[ToolDefinitionInput] | None = None) -> None
set_ld_span_attributes(span: Any, variables: dict[str, Any] | None) -> None
set_ld_span_attributes(span: Any, variables: dict[str, Any] | None, *, capture_content: bool | None = None) -> None
set_model_identity_attributes(span: Any, provider_name: str, request_model: str, legacy_system: str | None = None) -> None
set_output_content_attributes(span: Any, capture: bool, messages: list[SpanMessage]) -> None
set_skill_store(store: SkillStore | None) -> None
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/launchdarkly_ai_server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ async def _invoke(
tool_handlers=resolved_tools,
variables=variables,
history=history,
evaluated_value=variation.get("value"),
)

raw_response = result["response"]
Expand Down Expand Up @@ -240,6 +241,7 @@ async def _execute_stream(
tool_handlers=resolved_tools,
variables=variables,
history=history,
evaluated_value=variation.get("value"),
)
) as source:
async for event in source:
Expand Down
44 changes: 33 additions & 11 deletions packages/client/src/launchdarkly_ai_server/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,16 @@ async def _fetch_graph_variation(
topology = variation if isinstance(variation, dict) else None

if not topology or not topology.get("root"):
return {"enabled": False, "meta": meta}
return {"enabled": False, "meta": meta, "value": variation}

return {"enabled": True, "topology": topology, "meta": meta}
return {"enabled": True, "topology": topology, "meta": meta, "value": variation}


async def _build_graph(
key: str,
context: LDContext,
options: dict[str, Any],
) -> tuple[GraphDefinition, TrackData, Callable[..., Any]]:
) -> tuple[GraphDefinition, TrackData, Callable[..., Any], Any]:
from .judges import run_judges
from .lifecycle import extract_variation, get_client
from .tracking import _try_get_environment_id, execute_and_track
Expand All @@ -127,6 +127,9 @@ async def _build_graph(
enabled: bool = result["enabled"]
topology: dict[str, Any] | None = result.get("topology")
meta = result.get("meta", {})
# The graph variation as the LD SDK returned it, for the root span's
# feature_flag.result.value. Kept off GraphDefinition, which is public.
graph_value = result.get("value")

graph_track_data: TrackData = {
"runId": str(uuid.uuid4()),
Expand All @@ -147,6 +150,7 @@ async def _build_graph(
_disabled_definition(key),
graph_track_data,
_disabled_stream_route(key),
graph_value,
)

raw_edges_map: dict[str, list[dict[str, Any]]] = topology.get("edges") or {}
Expand All @@ -171,11 +175,14 @@ def edges_from(node_key: str) -> list[GraphEdge]:
return [e for e in edges if e.source_key == node_key]

nodes: dict[str, GraphNode] = {}
# Raw variations, kept off the public GraphNode.
node_values: dict[str, Any] = {}
try:
for node_key in all_keys:
variation = await extract_variation(node_key, context)
node_config: AiConfigRep = variation["config"]
node_meta: VariationMeta = variation["meta"]
node_values[node_key] = variation.get("value")
node_edges = edges_from(node_key)
nodes[node_key] = GraphNode(
key=node_key,
Expand All @@ -190,6 +197,7 @@ def edges_from(node_key: str) -> list[GraphEdge]:
_disabled_definition(key),
graph_track_data,
_disabled_stream_route(key),
graph_value,
)

root_node = nodes.get(topology["root"])
Expand Down Expand Up @@ -238,6 +246,7 @@ async def run_node(
try:
result = await execute_and_track(
config_key=node.key,
evaluated_value=node_values.get(node.key),
config=node.config,
meta=node.meta,
user_context=context,
Expand Down Expand Up @@ -415,6 +424,7 @@ async def route(
try:
result = await execute_and_track(
config_key=node.key,
evaluated_value=node_values.get(node.key),
config=routed_config,
meta=node.meta,
user_context=context,
Expand Down Expand Up @@ -530,6 +540,7 @@ async def stream_node(
projector = StableProjector()
async for event in execute_and_stream(
config_key=node.key,
evaluated_value=node_values.get(node.key),
config=node.config,
meta=node.meta,
user_context=context,
Expand Down Expand Up @@ -670,6 +681,7 @@ async def stream_route(
projector = StableProjector()
async for event in execute_and_stream(
config_key=node.key,
evaluated_value=node_values.get(node.key),
config=routed_config,
meta=node.meta,
user_context=context,
Expand Down Expand Up @@ -829,7 +841,7 @@ async def visit(node_key: str) -> None:
reverse_traverse=reverse_traverse,
)

return graph_def, graph_track_data, stream_route
return graph_def, graph_track_data, stream_route, graph_value


async def resolve_graph(
Expand Down Expand Up @@ -857,7 +869,7 @@ async def resolve_graph(
"tool_handlers": resolved_tools,
"registry": registry,
}
graph_def, _, _ = await _build_graph(key, context, options)
graph_def, _, _, _ = await _build_graph(key, context, options)
return graph_def


Expand Down Expand Up @@ -895,7 +907,7 @@ async def _invoke(
from opentelemetry import trace

from .judges import run_judges
from .lifecycle import get_client
from .lifecycle import effective_capture_content, get_client

ld_ctx = to_ld_context(get_client(), context)

Expand All @@ -918,7 +930,7 @@ async def _invoke(
)

built = await _build_graph(self._key, context, resolved_options)
graph_def, built_track_data, _ = built
graph_def, built_track_data, _, graph_value = built

if not graph_def.enabled:
raise ValueError(f'Agent graph "{self._key}" is disabled')
Expand All @@ -928,7 +940,13 @@ async def _invoke(
tracer = trace.get_tracer("@launchdarkly/ai-server")
with tracer.start_as_current_span("launchdarkly.graph") as span:
set_ld_span_attributes(
span, {"__ld": graph_track_data, "ldContext": context}
span,
{
"__ld": graph_track_data,
"ldContext": context,
"__ldValue": graph_value,
},
capture_content=effective_capture_content(None),
)

start_time = time.monotonic()
Expand Down Expand Up @@ -1120,7 +1138,7 @@ async def _stream_events(
from opentelemetry.trace import Status, StatusCode, set_span_in_context

from .judges import run_judges
from .lifecycle import get_client
from .lifecycle import effective_capture_content, get_client

resolved_input = user_input or ""
ld_ctx = to_ld_context(get_client(), context)
Expand All @@ -1144,7 +1162,7 @@ async def _stream_events(
)

built = await _build_graph(self._key, context, resolved_options)
graph_def, built_track_data, stream_route = built
graph_def, built_track_data, stream_route, graph_value = built

if not graph_def.enabled:
raise ValueError(f'Agent graph "{self._key}" is disabled')
Expand All @@ -1153,7 +1171,11 @@ async def _stream_events(

tracer = trace.get_tracer("@launchdarkly/ai-server")
span = tracer.start_span("launchdarkly.graph", context=caller_context)
set_ld_span_attributes(span, {"__ld": graph_track_data, "ldContext": context})
set_ld_span_attributes(
span,
{"__ld": graph_track_data, "ldContext": context, "__ldValue": graph_value},
capture_content=effective_capture_content(None),
)
span_context = set_span_in_context(span, caller_context)
ended: set[int] = set()
start_time = time.monotonic()
Expand Down
3 changes: 3 additions & 0 deletions packages/client/src/launchdarkly_ai_server/judges.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ async def run_judges(
"response_to_evaluate": llm_response,
**({"input": user_input} if user_input else {}),
},
evaluated_value=variation.get("value"),
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
)

usage = to_usage_dict(result["usage"])
Expand Down Expand Up @@ -386,6 +387,7 @@ async def _build_judge_tasks(
collapse_messages=collapse_messages,
parent_track_data=base_track_data,
evaluation_metric_key=evaluation_metric_key,
judge_value=variation.get("value"),
)
)
except Exception as exc:
Expand Down Expand Up @@ -459,6 +461,7 @@ async def run_judge(
"response_to_evaluate": task.actual_output,
**({"input": task.user_input} if task.user_input else {}),
},
evaluated_value=task.judge_value,
)

usage = to_usage_dict(result["usage"])
Expand Down
7 changes: 5 additions & 2 deletions packages/client/src/launchdarkly_ai_server/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ async def extract_variation(
) -> dict[str, Any]:
"""
Fetches an AI config variation from the LD client and parses it.
Returns ``{"config": AiConfigRep, "meta": VariationMeta}``.
Returns ``{"config": AiConfigRep, "meta": VariationMeta, "value": Any}``.
Raises on disabled or invalid variations.

Lazily initializes the LD client when ``LD_SDK_KEY`` is set, matching
Expand Down Expand Up @@ -644,4 +644,7 @@ async def extract_variation(
f"Invalid AI config variation for '{config_key}': {result.error['message']}"
)

return {"config": result.data, "meta": meta}
# ``value`` is the variation exactly as the LD SDK returned it, ``_ldMeta``
# included. It stays off ``meta``, which surfaces publicly as
# ``GraphNode.meta`` and ``JudgeTask.judge_meta``.
return {"config": result.data, "meta": meta, "value": raw}
Loading
Loading