From 97528e35d94bdc3cba621d2ffeb057b4f7eab60a Mon Sep 17 00:00:00 2001 From: Vega Date: Thu, 3 Sep 2026 14:21:14 -0500 Subject: [PATCH 1/6] feat: emit evaluation context identity on feature_flag spans --- AGENTS.md | 2 +- TELEMETRY-CONTRACT.md | 3 + .../src/launchdarkly_ai_server/utils.py | 83 ++++++++- .../client/tests/test_ld_span_attributes.py | 172 ++++++++++++++++++ tests/test_cross_handler_parity.py | 34 +++- 5 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 packages/client/tests/test_ld_span_attributes.py diff --git a/AGENTS.md b/AGENTS.md index e828b2c..f63fef0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -505,7 +505,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 | diff --git a/TELEMETRY-CONTRACT.md b/TELEMETRY-CONTRACT.md index 65a8364..a9d4cb0 100644 --- a/TELEMETRY-CONTRACT.md +++ b/TELEMETRY-CONTRACT.md @@ -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.` | 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 | @@ -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 diff --git a/packages/client/src/launchdarkly_ai_server/utils.py b/packages/client/src/launchdarkly_ai_server/utils.py index 676934d..ebbdaef 100644 --- a/packages/client/src/launchdarkly_ai_server/utils.py +++ b/packages/client/src/launchdarkly_ai_server/utils.py @@ -556,6 +556,68 @@ 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 + kind_value = ld_context.get("kind") + kind = kind_value if isinstance(kind_value, str) and kind_value else "user" + keys = {kind: key} + canonical = ( + _escape_canonical_part(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 @@ -564,7 +626,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): @@ -573,11 +636,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.`` = 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: @@ -597,6 +663,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) diff --git a/packages/client/tests/test_ld_span_attributes.py b/packages/client/tests/test_ld_span_attributes.py new file mode 100644 index 0000000..e8038b8 --- /dev/null +++ b/packages/client/tests/test_ld_span_attributes.py @@ -0,0 +1,172 @@ +"""Context identity on the root feature_flag span. TESTING.md §3.18.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from launchdarkly_ai_server.utils import set_ld_span_attributes + +LD_FIXTURE = { + "configKey": "test-config", + "variationKey": "variation-a", + "runId": "run-123", + "version": 1, + "modelName": "test-model", + "providerName": "TestProvider", +} + + +class FakeSpan: + def __init__(self) -> None: + self.attributes: dict[str, Any] = {} + self.events: list[tuple[str, dict[str, Any]]] = [] + + def set_attribute(self, key: str, value: Any) -> None: + self.attributes[key] = value + + def add_event(self, name: str, attributes: dict[str, Any] | None = None) -> None: + self.events.append((name, attributes or {})) + + +def _vars(ld_context: Any) -> dict[str, Any]: + return {"__ld": LD_FIXTURE, "ldContext": ld_context} + + +def _feature_flag(span: FakeSpan) -> dict[str, Any]: + for name, attrs in span.events: + if name == "feature_flag": + return attrs + return {} + + +def _assert_no_context_identity(span: FakeSpan) -> None: + assert [k for k in span.attributes if k.startswith("context.contextKeys.")] == [] + event = _feature_flag(span) + assert "feature_flag.context.id" not in event + assert "feature_flag.contextKeys" not in event + assert "feature_flag.context.key.user" not in event + + +class TestContextIdentity: + def test_legacy_user_with_no_kind_is_the_bare_key(self) -> None: + span = FakeSpan() + set_ld_span_attributes(span, _vars({"key": "u-1"})) + event = _feature_flag(span) + assert event["feature_flag.context.id"] == "u-1" + assert event["feature_flag.contextKeys"] == '{"user":"u-1"}' + assert span.attributes["context.contextKeys.user"] == "u-1" + + def test_kind_user_is_the_bare_key(self) -> None: + span = FakeSpan() + set_ld_span_attributes(span, _vars({"kind": "user", "key": "u-1"})) + event = _feature_flag(span) + assert event["feature_flag.context.id"] == "u-1" + assert event["feature_flag.contextKeys"] == '{"user":"u-1"}' + assert span.attributes["context.contextKeys.user"] == "u-1" + + def test_non_user_single_kind_is_prefixed(self) -> None: + span = FakeSpan() + set_ld_span_attributes(span, _vars({"kind": "org", "key": "o-1"})) + event = _feature_flag(span) + assert event["feature_flag.context.id"] == "org:o-1" + assert event["feature_flag.contextKeys"] == '{"org":"o-1"}' + assert span.attributes["context.contextKeys.org"] == "o-1" + + def test_multi_kind_is_sorted_by_kind_not_declaration_order(self) -> None: + # user before org is the reverse of sorted order (org < user). + span = FakeSpan() + set_ld_span_attributes( + span, + _vars({"kind": "multi", "user": {"key": "u-1"}, "org": {"key": "o-1"}}), + ) + event = _feature_flag(span) + assert event["feature_flag.context.id"] == "org:o-1:user:u-1" + assert event["feature_flag.contextKeys"] == '{"org":"o-1","user":"u-1"}' + assert span.attributes["context.contextKeys.user"] == "u-1" + assert span.attributes["context.contextKeys.org"] == "o-1" + + def test_percent_is_escaped_before_colon_and_the_map_stays_raw(self) -> None: + span = FakeSpan() + set_ld_span_attributes(span, _vars({"kind": "org", "key": "a%b:c"})) + event = _feature_flag(span) + assert event["feature_flag.context.id"] == "org:a%25b%3Ac" + assert event["feature_flag.contextKeys"] == '{"org":"a%b:c"}' + assert span.attributes["context.contextKeys.org"] == "a%b:c" + + def test_non_ascii_key_is_not_unicode_escaped(self) -> None: + span = FakeSpan() + set_ld_span_attributes(span, _vars({"kind": "user", "key": "José"})) + event = _feature_flag(span) + assert event["feature_flag.context.id"] == "José" + assert event["feature_flag.contextKeys"] == '{"user":"José"}' + assert span.attributes["context.contextKeys.user"] == "José" + + def test_integer_like_kinds_are_lexicographic_not_json_index_order(self) -> None: + span = FakeSpan() + set_ld_span_attributes( + span, + _vars({"kind": "multi", "2": {"key": "b"}, "10": {"key": "a"}}), + ) + event = _feature_flag(span) + assert event["feature_flag.context.id"] == "10:a:2:b" + assert event["feature_flag.contextKeys"] == '{"10":"a","2":"b"}' + assert span.attributes["context.contextKeys.10"] == "a" + assert span.attributes["context.contextKeys.2"] == "b" + + def test_multi_kind_with_one_usable_pair_keeps_the_prefixed_form(self) -> None: + span = FakeSpan() + set_ld_span_attributes( + span, + _vars({"kind": "multi", "user": {"key": "u-1"}, "org": {}}), + ) + event = _feature_flag(span) + assert event["feature_flag.context.id"] == "user:u-1" + assert event["feature_flag.contextKeys"] == '{"user":"u-1"}' + assert span.attributes["context.contextKeys.user"] == "u-1" + assert "context.contextKeys.org" not in span.attributes + + def test_emits_keys_only_never_context_attribute_values(self) -> None: + span = FakeSpan() + set_ld_span_attributes( + span, + _vars({"key": "u-1", "email": "ada@example.com", "name": "Ada"}), + ) + values = [*span.attributes.values(), *_feature_flag(span).values()] + assert "u-1" in values + assert "ada@example.com" not in values + assert "Ada" not in values + + +@pytest.mark.parametrize( + "variables", + [ + {"__ld": LD_FIXTURE}, + _vars(None), + _vars("user-123"), + _vars(123), + _vars({}), + _vars({"kind": "user", "key": 123}), + _vars({"kind": "user", "key": ""}), + _vars({"kind": "multi"}), + _vars({"kind": "multi", "user": {"name": "Ada"}}), + ], + ids=[ + "missing", + "none", + "string", + "number", + "empty-object", + "non-string-key", + "empty-key", + "empty-multi", + "multi-with-no-usable-key", + ], +) +def test_malformed_ld_context_emits_none_of_the_three_and_does_not_throw( + variables: dict[str, Any], +) -> None: + span = FakeSpan() + set_ld_span_attributes(span, variables) + _assert_no_context_identity(span) diff --git a/tests/test_cross_handler_parity.py b/tests/test_cross_handler_parity.py index ff47d17..8ab0db5 100644 --- a/tests/test_cross_handler_parity.py +++ b/tests/test_cross_handler_parity.py @@ -61,7 +61,8 @@ "runId": "run-1", "graphKey": "graph-1", "environmentId": "env-1", - } + }, + "ldContext": {"kind": "user", "key": "user-123"}, } @@ -71,12 +72,14 @@ def __init__(self, name: str, context: Any = None) -> None: self.context = context self.attributes: dict[str, Any] = {} self.events: list[str] = [] + self.event_attributes: dict[str, dict[str, Any]] = {} def set_attribute(self, key: str, value: Any) -> None: self.attributes[key] = value def add_event(self, name: str, attributes: dict[str, Any] | None = None) -> None: self.events.append(name) + self.event_attributes[name] = attributes or {} def set_status(self, code: Any, description: str | None = None) -> None: pass @@ -218,6 +221,15 @@ def test_the_root_emits_the_feature_flag_event(self, handler_spans: Any) -> None module.start_root_span(CONFIG, LD_VARIABLES) assert "feature_flag" in tracer.spans[0].events + def test_the_root_carries_context_identity(self, handler_spans: Any) -> None: + _, module, tracer = handler_spans + module.start_root_span(CONFIG, LD_VARIABLES) + span = tracer.spans[0] + event = span.event_attributes["feature_flag"] + assert event["feature_flag.context.id"] == "user-123" + assert event["feature_flag.contextKeys"] == '{"user":"user-123"}' + assert span.attributes["context.contextKeys.user"] == "user-123" + def test_a_tool_span_carries_no_launchdarkly_identity( self, handler_spans: Any ) -> None: @@ -227,6 +239,9 @@ def test_a_tool_span_carries_no_launchdarkly_identity( module.start_tool_span("get_weather", "call-1", None) span = tracer.spans[0] assert [k for k in span.attributes if k.startswith("launchdarkly.")] == [] + assert [ + k for k in span.attributes if k.startswith("context.contextKeys.") + ] == [] assert "feature_flag" not in span.events def test_a_model_span_carries_no_launchdarkly_identity( @@ -238,6 +253,9 @@ def test_a_model_span_carries_no_launchdarkly_identity( module.start_model_span(CONFIG, None) span = tracer.spans[0] assert [k for k in span.attributes if k.startswith("launchdarkly.")] == [] + assert [ + k for k in span.attributes if k.startswith("context.contextKeys.") + ] == [] assert "feature_flag" not in span.events @@ -358,6 +376,12 @@ def test_the_langchain_provider_name_is_binary_not_a_passthrough( "feature_flag.key", "feature_flag.provider.name", "feature_flag.set.id", + # AIC-3230: evaluation-context identity on the root feature_flag event / span. + # `context.contextKeys` is the f-string prefix; the keys actually emitted are + # `context.contextKeys.`. + "feature_flag.context.id", + "feature_flag.contextKeys", + "context.contextKeys", # Graph spans, unchanged from before the span work "ld.ai.graph", "ld.ai.graph.key", @@ -397,8 +421,11 @@ def _without_superseded(source: str) -> str: 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 - # add_event, so they never appear inside a set_attribute call. - r'|"(feature_flag\.[a-z_.]+)"' + # add_event, so they never appear inside a set_attribute call. camelCase `contextKeys` is + # intentional — the observability browser SDK already ships that name. + r'|"(feature_flag\.[a-zA-Z_.]+)"' + # Per-kind span attributes are interpolated: f"context.contextKeys.{kind}". + r'|f?"(context\.contextKeys)' ) @@ -413,6 +440,7 @@ def _emitted_vocabulary() -> set[str]: "launchdarkly", "feature_flag", "ld", + "context", ): found.add(key) return found From 227e222de9f6e1a028080cdbe2dd8cdce200f6ca Mon Sep 17 00:00:00 2001 From: Vega Date: Thu, 3 Sep 2026 14:46:47 -0500 Subject: [PATCH 2/6] test(AIC-3230): exercise multi-context examples --- examples/conversation.py | 9 +++++++-- examples/judge_example.py | 10 ++++++++-- examples/utils.py | 9 +++++++++ tests/test_example_utils.py | 12 ++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 tests/test_example_utils.py diff --git a/examples/conversation.py b/examples/conversation.py index 278afd9..73850a6 100644 --- a/examples/conversation.py +++ b/examples/conversation.py @@ -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 = [ @@ -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) diff --git a/examples/judge_example.py b/examples/judge_example.py index c68a793..1636157 100644 --- a/examples/judge_example.py +++ b/examples/judge_example.py @@ -20,10 +20,12 @@ from __future__ import annotations import asyncio +import json +import sys import threading from typing import Any -from examples.utils import new_context, write_output +from examples.utils import new_multi_context, write_output from launchdarkly_ai_server import ( JudgeRunResult, JudgeTask, @@ -68,7 +70,11 @@ async def run(key: str, user_input: str) -> None: 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 diff --git a/examples/utils.py b/examples/utils.py index 191a94c..f07f14f 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -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. diff --git a/tests/test_example_utils.py b/tests/test_example_utils.py new file mode 100644 index 0000000..ef9b037 --- /dev/null +++ b/tests/test_example_utils.py @@ -0,0 +1,12 @@ +from examples.utils import new_multi_context + + +def test_new_multi_context_is_complex_and_unique() -> None: + first = new_multi_context() + second = new_multi_context() + + assert first["kind"] == "multi" + assert first["organization"] == {"key": "example-org:west%region"} + assert first["user"]["key"].startswith("example-user-") + assert len(first["user"]["key"]) == len("example-user-") + 8 + assert second["user"]["key"] != first["user"]["key"] From 2fff1a593f8920f13c2aaa300908ff01df8cfa59 Mon Sep 17 00:00:00 2001 From: Vega Date: Thu, 3 Sep 2026 14:47:36 -0500 Subject: [PATCH 3/6] fix(AIC-3230): make example tests and judge registration work --- examples/judge_example.py | 5 +---- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/judge_example.py b/examples/judge_example.py index 1636157..194003d 100644 --- a/examples/judge_example.py +++ b/examples/judge_example.py @@ -25,6 +25,7 @@ import threading from typing import Any +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, @@ -66,10 +67,6 @@ def _judge_in_thread( async def run(key: str, user_input: str) -> None: - from examples.register import register_handlers - - register_handlers() - ctx = new_multi_context() print( f"[context] {json.dumps(ctx, ensure_ascii=False, separators=(',', ':'))}", diff --git a/pyproject.toml b/pyproject.toml index f8ae52e..83e3de6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ [tool.pytest.ini_options] asyncio_mode = "auto" addopts = "--import-mode=importlib" +pythonpath = ["."] [tool.mypy] strict = true From 1418f882562acbb7808f7631c28174e51d3b4ca1 Mon Sep 17 00:00:00 2001 From: Vega Date: Thu, 3 Sep 2026 14:52:43 -0500 Subject: [PATCH 4/6] fix(AIC-3230): persist mypy package bases and tighten test --- pyproject.toml | 1 + tests/test_example_utils.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 83e3de6..320b132 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ pythonpath = ["."] [tool.mypy] strict = true python_version = "3.12" +explicit_package_bases = true [tool.ruff] target-version = "py312" diff --git a/tests/test_example_utils.py b/tests/test_example_utils.py index ef9b037..e194fa7 100644 --- a/tests/test_example_utils.py +++ b/tests/test_example_utils.py @@ -1,3 +1,5 @@ +import re + from examples.utils import new_multi_context @@ -7,6 +9,5 @@ def test_new_multi_context_is_complex_and_unique() -> None: assert first["kind"] == "multi" assert first["organization"] == {"key": "example-org:west%region"} - assert first["user"]["key"].startswith("example-user-") - assert len(first["user"]["key"]) == len("example-user-") + 8 + assert re.fullmatch(r"example-user-[0-9a-f]{8}", first["user"]["key"]) assert second["user"]["key"] != first["user"]["key"] From 68a5c2b01a1cc64cb33e7cdc1e463c29ac4a41d6 Mon Sep 17 00:00:00 2001 From: Vega Date: Thu, 3 Sep 2026 15:06:08 -0500 Subject: [PATCH 5/6] fix(AIC-3230): align user context canonical identity --- .../src/launchdarkly_ai_server/utils.py | 11 ++++++--- .../client/tests/test_ld_span_attributes.py | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/client/src/launchdarkly_ai_server/utils.py b/packages/client/src/launchdarkly_ai_server/utils.py index ebbdaef..913ad40 100644 --- a/packages/client/src/launchdarkly_ai_server/utils.py +++ b/packages/client/src/launchdarkly_ai_server/utils.py @@ -605,11 +605,16 @@ def _context_identity_from_ld_context( key = _usable_context_key(ld_context.get("key")) if key is None: return None - kind_value = ld_context.get("kind") - kind = kind_value if isinstance(kind_value, str) and kind_value else "user" + 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 = ( - _escape_canonical_part(key) + key if kind == "user" else f"{_escape_canonical_part(kind)}:{_escape_canonical_part(key)}" ) diff --git a/packages/client/tests/test_ld_span_attributes.py b/packages/client/tests/test_ld_span_attributes.py index e8038b8..bd0b470 100644 --- a/packages/client/tests/test_ld_span_attributes.py +++ b/packages/client/tests/test_ld_span_attributes.py @@ -66,6 +66,24 @@ def test_kind_user_is_the_bare_key(self) -> None: assert event["feature_flag.contextKeys"] == '{"user":"u-1"}' assert span.attributes["context.contextKeys.user"] == "u-1" + @pytest.mark.parametrize( + "context", + [ + {"key": "u%1:west"}, + {"kind": "user", "key": "u%1:west"}, + ], + ids=["legacy-user", "explicit-user"], + ) + def test_user_percent_and_colon_stay_raw_in_all_identity_values( + self, context: dict[str, Any] + ) -> None: + span = FakeSpan() + set_ld_span_attributes(span, _vars(context)) + event = _feature_flag(span) + assert event["feature_flag.context.id"] == "u%1:west" + assert event["feature_flag.contextKeys"] == '{"user":"u%1:west"}' + assert span.attributes["context.contextKeys.user"] == "u%1:west" + def test_non_user_single_kind_is_prefixed(self) -> None: span = FakeSpan() set_ld_span_attributes(span, _vars({"kind": "org", "key": "o-1"})) @@ -149,6 +167,9 @@ def test_emits_keys_only_never_context_attribute_values(self) -> None: _vars({}), _vars({"kind": "user", "key": 123}), _vars({"kind": "user", "key": ""}), + _vars({"kind": "", "key": "u-1"}), + _vars({"kind": None, "key": "u-1"}), + _vars({"kind": 123, "key": "u-1"}), _vars({"kind": "multi"}), _vars({"kind": "multi", "user": {"name": "Ada"}}), ], @@ -160,6 +181,9 @@ def test_emits_keys_only_never_context_attribute_values(self) -> None: "empty-object", "non-string-key", "empty-key", + "empty-explicit-kind", + "none-explicit-kind", + "number-explicit-kind", "empty-multi", "multi-with-no-usable-key", ], From a371de83ba6a265304855995e7ac4ab218a86fee Mon Sep 17 00:00:00 2001 From: Vega Date: Thu, 3 Sep 2026 16:09:25 -0500 Subject: [PATCH 6/6] fix: restore workspace mypy module resolution --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 320b132..83e3de6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ pythonpath = ["."] [tool.mypy] strict = true python_version = "3.12" -explicit_package_bases = true [tool.ruff] target-version = "py312"