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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ Do not hand-write a `span.set_attribute` for anything a shared helper covers. Th
|---|---|
| `set_model_identity_attributes` | `gen_ai.system`, `gen_ai.provider.name`, `gen_ai.request.model` |
| `set_usage_span_attributes` | all seven `gen_ai.usage.*` keys, always, including zeros |
| `set_ld_span_attributes` | the `launchdarkly.*` identity and the `feature_flag` event |
| `set_ld_span_attributes` | the `launchdarkly.*` identity, per-kind `context.contextKeys.*`, and the `feature_flag` event |
| `set_input_content_attributes` | prompts, system instructions, tool catalog, gated |
| `set_output_content_attributes` | model output, gated |
| `set_tool_call_content_attributes` | tool arguments and results, gated |
Expand Down
3 changes: 3 additions & 0 deletions TELEMETRY-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ able to tell from the trace which path ran.
| `launchdarkly.variation.key` | `TrackData.variationKey` | `set_ld_span_attributes` |
| `launchdarkly.run.id` | `TrackData.runId` | `set_ld_span_attributes` |
| `launchdarkly.graph.key` | `TrackData.graphKey`, only when present | `set_ld_span_attributes` |
| `context.contextKeys.<kind>` | raw per-kind context key, only when `variables.ldContext` has a usable identity | `set_ld_span_attributes` |
| `launchdarkly.stream.abandoned` | `True`, only when abandoned | `end_span_once` |
| `gen_ai.evaluation.name` | judge config key, judge roots only | `with_judge_evaluation`, see section 4a |
| `gen_ai.evaluation.score.value` | numeric score, judge roots only | `with_judge_evaluation`, see section 4a |
Expand All @@ -105,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.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 |

The root is the only span that carries the config-association attributes and the `feature_flag`
event. Child spans carry neither. A test asserts this, so do not add them to children out of
Expand Down
9 changes: 7 additions & 2 deletions examples/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@

from __future__ import annotations

import json
import sys
from typing import Any

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

FOLLOW_UPS = [
Expand All @@ -38,7 +39,11 @@

async def run(key: str, user_input: str) -> None:
conversation = new_conversation_id("conversation-example")
ctx = new_context()
ctx = new_multi_context()
print(
f"[context] {json.dumps(ctx, ensure_ascii=False, separators=(',', ':'))}",
file=sys.stderr,
)
history: list[dict[str, Any]] = []

print(f"[conversation] {conversation}", file=sys.stderr)
Expand Down
15 changes: 9 additions & 6 deletions examples/judge_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@
from __future__ import annotations

import asyncio
import json
import sys
import threading
from typing import Any

from examples.utils import new_context, write_output
import examples.register # noqa: F401 – side-effect: populate global_registry
from examples.utils import new_multi_context, write_output
from launchdarkly_ai_server import (
JudgeRunResult,
JudgeTask,
Expand Down Expand Up @@ -64,11 +67,11 @@ def _judge_in_thread(


async def run(key: str, user_input: str) -> None:
from examples.register import register_handlers

register_handlers()

ctx = new_context()
ctx = new_multi_context()
print(
f"[context] {json.dumps(ctx, ensure_ascii=False, separators=(',', ':'))}",
file=sys.stderr,
)

# Single config() call — the caller never touches a judge key.
# skip_judges=True suppresses automatic inline evaluation so we control when
Expand Down
9 changes: 9 additions & 0 deletions examples/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ def new_context() -> dict[str, Any]:
return {"kind": "user", "key": key}


def new_multi_context() -> dict[str, Any]:
"""Returns a unique multi-context that exercises canonical-key escaping."""
return {
"kind": "multi",
"organization": {"key": "example-org:west%region"},
"user": {"key": f"example-user-{uuid4().hex[:8]}"},
}


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

Expand Down
88 changes: 85 additions & 3 deletions packages/client/src/launchdarkly_ai_server/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,73 @@ def make_track_data(node: GraphNode, graph_key: str, run_id: str) -> dict[str, A
}


def _usable_context_key(value: Any) -> str | None:
return value if isinstance(value, str) and value != "" else None


def _escape_canonical_part(value: str) -> str:
return value.replace("%", "%25").replace(":", "%3A")


def _compact_context_keys_json(keys: dict[str, str]) -> str:
"""Compact JSON of per-kind keys in lexicographic kind order."""
parts = [
f"{json.dumps(kind, ensure_ascii=False)}:{json.dumps(keys[kind], ensure_ascii=False)}"
for kind in sorted(keys)
]
return "{" + ",".join(parts) + "}"


def _context_identity_from_ld_context(
ld_context: Any,
) -> tuple[str, dict[str, str]] | None:
"""Canonical key plus per-kind map, or None when there is no usable identity.

Never raises.
"""
try:
if not isinstance(ld_context, dict):
return None

if ld_context.get("kind") == "multi":
raw: dict[str, str] = {}
for kind, value in ld_context.items():
if kind in ("kind", "_meta") or not isinstance(value, dict):
continue
key = _usable_context_key(value.get("key"))
if key is not None:
raw[kind] = key
kinds = sorted(raw)
if not kinds:
return None
keys = {kind: raw[kind] for kind in kinds}
canonical = ":".join(
f"{_escape_canonical_part(kind)}:{_escape_canonical_part(raw[kind])}"
for kind in kinds
)
return canonical, keys

key = _usable_context_key(ld_context.get("key"))
if key is None:
return None
if "kind" not in ld_context:
kind = "user"
else:
kind_value = ld_context["kind"]
if not isinstance(kind_value, str) or not kind_value:
return None
kind = kind_value
keys = {kind: key}
canonical = (
key
if kind == "user"
else f"{_escape_canonical_part(kind)}:{_escape_canonical_part(key)}"
)
return canonical, keys
except Exception:
return None


def set_ld_span_attributes(span: Any, variables: dict[str, Any] | None) -> None:
"""
Sets LaunchDarkly config-identifying attributes on an OTel span and emits
Expand All @@ -564,7 +631,8 @@ def set_ld_span_attributes(span: Any, variables: dict[str, Any] | None) -> None:

Reads the ``__ld`` entry injected into *variables* by
``execute_and_track`` / ``execute_and_stream``, so handlers never need to
receive ``TrackData`` directly.
receive ``TrackData`` directly. Context identity is read from
``variables.ldContext``, never from ``TrackData``.

Span attributes (LLM dashboard discovery and custom queries):

Expand All @@ -573,11 +641,14 @@ def set_ld_span_attributes(span: Any, variables: dict[str, Any] | None) -> None:
* ``launchdarkly.variation.key`` = variationKey
* ``launchdarkly.run.id`` = runId
* ``launchdarkly.graph.key`` = graphKey (only when present)
* ``context.contextKeys.<kind>`` = raw per-kind key (when ldContext has identity)

Span event (required for AI Config Monitoring Traces tab correlation):
``name='feature_flag'`` with ``feature_flag.key``,
``feature_flag.provider.name``, and ``feature_flag.set.id`` (when
``LD_ENVIRONMENT_ID`` is set or the TS SDK auto-resolved it).
``feature_flag.provider.name``, ``feature_flag.set.id`` (when
``LD_ENVIRONMENT_ID`` is set or the TS SDK auto-resolved it),
``feature_flag.context.id``, and ``feature_flag.contextKeys`` (when
``ldContext`` has a usable identity).
"""
span.set_attribute("launchdarkly.operation.type", "gen_ai")
if not variables:
Expand All @@ -597,6 +668,17 @@ def set_ld_span_attributes(span: Any, variables: dict[str, Any] | None) -> None:
}
if ld.get("environmentId"):
feature_flag_attrs["feature_flag.set.id"] = ld["environmentId"]

identity = _context_identity_from_ld_context(variables.get("ldContext"))
if identity is not None:
canonical, keys = identity
feature_flag_attrs["feature_flag.context.id"] = canonical
feature_flag_attrs["feature_flag.contextKeys"] = _compact_context_keys_json(
keys
)
for kind, key in keys.items():
span.set_attribute(f"context.contextKeys.{kind}", key)

span.add_event("feature_flag", feature_flag_attrs)


Expand Down
Loading
Loading