Skip to content

Commit ab2a67f

Browse files
committed
feat: annotate active OTel spans with AI SDK metrics and config metadata
Every tracker method (track_duration, track_tokens, track_success, etc.) now writes the same metrics onto the active OpenTelemetry span in addition to firing LD analytics events. This gives users correlated LLM observability data in their tracing backend without any extra code. Key additions: - `LDAIObserveConfig` dataclass — controls span annotation behaviour (`annotate_spans`, `create_span_if_none`), passed to `LDAIClient`. - `observe.py` — span annotation helpers, baggage helpers, and `LDAIBaggageSpanProcessor` for propagating AI Config metadata through OTel context (useful with auto-instrumented LLM libraries). - `LDAIClient.config_scope()` — context manager that evaluates an AI Config and scopes its metadata as OTel baggage for the duration of the block, so downstream spans inherit AI Config identity. - `opentelemetry-api` added as an optional dependency (`pip install launchdarkly-server-sdk-ai[otel]`). All OTel code is guarded behind availability checks — zero impact when the package is not installed. - Both `LDAIConfigTracker` and `AIGraphTracker` annotate spans for every metric they track: duration, tokens, TTFT, success/error, feedback, eval scores, judge responses, graph invocation, handoffs, etc. Made-with: Cursor
1 parent 0458a6d commit ab2a67f

8 files changed

Lines changed: 874 additions & 45 deletions

File tree

packages/sdk/server-ai/pyproject.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ packages = [{ include = "ldai", from = "src" }]
2727
python = ">=3.9,<4"
2828
launchdarkly-server-sdk = ">=9.4.0"
2929
chevron = "=0.14.0"
30+
opentelemetry-api = {version = ">=1.0.0", optional = true}
31+
32+
[tool.poetry.extras]
33+
# Install with: pip install launchdarkly-server-sdk-ai[otel]
34+
# Enables span annotation in LDAIConfigTracker and the config_scope() context
35+
# manager on LDAIClient. LDAIBaggageSpanProcessor additionally requires
36+
# opentelemetry-sdk to be installed by the application.
37+
otel = ["opentelemetry-api"]
3038

3139

3240
[tool.poetry.group.dev.dependencies]
@@ -37,6 +45,8 @@ pytest-asyncio = ">=0.21.0"
3745
mypy = "==1.18.2"
3846
pycodestyle = "^2.12.1"
3947
isort = ">=5.13.2,<7.0.0"
48+
opentelemetry-api = "^1.40.0"
49+
opentelemetry-sdk = "^1.40.0"
4050

4151

4252
[tool.poetry.group.docs]

packages/sdk/server-ai/src/ldai/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Edge, JudgeConfiguration, LDAIAgent, LDAIAgentConfig, LDAIAgentDefaults,
1414
LDMessage, ModelConfig, ProviderConfig)
1515
from ldai.providers.types import EvalScore, JudgeResponse
16+
from ldai.observe import LDAIBaggageSpanProcessor, LDAIObserveConfig
1617
from ldai.tracker import AIGraphTracker
1718

1819
__all__ = [
@@ -23,6 +24,8 @@
2324
'AIAgents',
2425
'AIAgentGraphConfig',
2526
'AIGraphTracker',
27+
'LDAIBaggageSpanProcessor',
28+
'LDAIObserveConfig',
2629
'Edge',
2730
'AICompletionConfig',
2831
'AICompletionConfigDefault',

packages/sdk/server-ai/src/ldai/chat/__init__.py

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ldai import log
77
from ldai.judge import Judge
88
from ldai.models import AICompletionConfig, LDMessage
9+
from ldai.observe import _span_scope, annotate_span_with_ai_config_metadata
910
from ldai.providers.ai_provider import AIProvider
1011
from ldai.providers.types import ChatResponse, JudgeResponse
1112
from ldai.tracker import LDAIConfigTracker
@@ -50,29 +51,44 @@ async def invoke(self, prompt: str) -> ChatResponse:
5051
:param prompt: The user prompt to send to the chat model
5152
:return: ChatResponse containing the model's response and metrics
5253
"""
53-
# Convert prompt string to LDMessage with role 'user' and add to conversation history
5454
user_message: LDMessage = LDMessage(role='user', content=prompt)
5555
self._messages.append(user_message)
5656

57-
# Prepend config messages to conversation history for model invocation
5857
config_messages = self._ai_config.messages or []
5958
all_messages = config_messages + self._messages
6059

61-
# Delegate to provider-specific implementation with tracking
62-
response = await self._tracker.track_metrics_of(
63-
lambda: self._provider.invoke_model(all_messages),
64-
lambda result: result.metrics,
65-
)
66-
67-
# Start judge evaluations as async tasks (don't await them)
68-
if (
69-
self._ai_config.judge_configuration
70-
and self._ai_config.judge_configuration.judges
71-
and len(self._ai_config.judge_configuration.judges) > 0
72-
):
73-
response.evaluations = self._start_judge_evaluations(self._messages, response)
74-
75-
# Add the response message to conversation history
60+
observe_config = self._tracker._observe_config
61+
create_if_none = observe_config.annotate_spans and observe_config.create_span_if_none
62+
63+
# Open (or reuse) a span for the full invoke — LLM call AND judge task
64+
# creation must happen inside this block so that asyncio.create_task()
65+
# captures the active span in its context copy. Judge spans created
66+
# later in those tasks will then be correctly parented to this span.
67+
with _span_scope("ld.ai.completion", create_if_none=create_if_none):
68+
if observe_config.annotate_spans:
69+
annotate_span_with_ai_config_metadata(
70+
self._ai_config.key,
71+
self._tracker._variation_key,
72+
self._tracker._model_name,
73+
self._tracker._provider_name,
74+
version=self._tracker._version,
75+
context_key=self._tracker._context.key,
76+
enabled=self._tracker._enabled,
77+
)
78+
79+
response = await self._tracker.track_metrics_of(
80+
lambda: self._provider.invoke_model(all_messages),
81+
lambda result: result.metrics,
82+
)
83+
84+
# Create judge tasks INSIDE the span scope so asyncio.create_task()
85+
# snapshots the context while the completion span is still active.
86+
if (
87+
self._ai_config.judge_configuration
88+
and self._ai_config.judge_configuration.judges
89+
):
90+
response.evaluations = self._start_judge_evaluations(self._messages, response)
91+
7692
self._messages.append(response.message)
7793
return response
7894

@@ -113,9 +129,18 @@ async def evaluate_judge(judge_config):
113129

114130
return eval_result
115131

132+
observe_config = self._tracker._observe_config
133+
create_judge_span = observe_config.annotate_spans and observe_config.create_span_if_none
134+
135+
async def evaluate_judge_with_span(judge_config):
136+
# Open the ld.ai.judge span BEFORE the judge LLM call so the
137+
# judge's openai.chat span is nested inside it, not beside it.
138+
with _span_scope("ld.ai.judge", create_if_none=create_judge_span):
139+
return await evaluate_judge(judge_config)
140+
116141
# Create tasks for each judge evaluation
117142
tasks = [
118-
asyncio.create_task(evaluate_judge(judge_config))
143+
asyncio.create_task(evaluate_judge_with_span(judge_config))
119144
for judge_config in judge_configs
120145
]
121146

packages/sdk/server-ai/src/ldai/client.py

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
from typing import Any, Dict, List, Optional, Tuple
1+
from contextlib import contextmanager
2+
from typing import Any, Dict, Generator, List, Optional, Tuple
23

34
import chevron
45
from ldclient import Context
56
from ldclient.client import LDClient
67

78
from ldai import log
9+
from ldai.observe import LDAIObserveConfig, detach_ai_config_baggage, set_ai_config_baggage
810
from ldai.agent_graph import AgentGraphDefinition
911
from ldai.chat import Chat
1012
from ldai.judge import Judge
@@ -32,8 +34,9 @@
3234
class LDAIClient:
3335
"""The LaunchDarkly AI SDK client object."""
3436

35-
def __init__(self, client: LDClient):
37+
def __init__(self, client: LDClient, observe: Optional[LDAIObserveConfig] = None):
3638
self._client = client
39+
self._observe_config = observe if observe is not None else LDAIObserveConfig()
3740
self._client.track(
3841
_TRACK_SDK_INFO,
3942
_INIT_TRACK_CONTEXT,
@@ -91,6 +94,60 @@ def completion_config(
9194
key, context, default or AICompletionConfigDefault.disabled(), variables
9295
)
9396

97+
@contextmanager
98+
def config_scope(
99+
self,
100+
key: str,
101+
context: Context,
102+
default: Optional[AICompletionConfigDefault] = None,
103+
variables: Optional[Dict[str, Any]] = None,
104+
) -> Generator[AICompletionConfig, None, None]:
105+
"""
106+
Context manager that evaluates an AI Config and scopes its metadata to
107+
the OTel context for the duration of the block.
108+
109+
While inside the block, any OTel span that is started (including spans
110+
created automatically by OpenLLMetry or other auto-instrumentation) will
111+
have the AI Config key, variation key, model, and provider stamped on it
112+
as span attributes by LDAIBaggageSpanProcessor, if that processor is
113+
registered.
114+
115+
This solves the context propagation problem: when completion_config() is
116+
called at one point in the code and the LLM call happens later, deep in
117+
the call stack, the baggage propagates automatically so the two can be
118+
correlated in LaunchDarkly.
119+
120+
Example::
121+
122+
with aiclient.config_scope("my-ai-config", context) as config:
123+
if config.enabled:
124+
# LLM call can be anywhere inside this block, even in a
125+
# helper function several layers down. OpenLLMetry's
126+
# auto-instrumented span will carry ld.ai_config.key.
127+
response = openai_client.chat.completions.create(
128+
model=config.model.name,
129+
messages=build_messages(config.messages, history),
130+
)
131+
config.tracker.track_openai_metrics(lambda: response)
132+
133+
:param key: The key of the completion configuration.
134+
:param context: The context to evaluate the completion configuration in.
135+
:param default: The default value of the completion configuration.
136+
:param variables: Additional variables for the completion configuration.
137+
:return: Generator yielding the evaluated AICompletionConfig.
138+
"""
139+
config = self.completion_config(key, context, default, variables)
140+
141+
model_name = config.model.name if config.model else ""
142+
provider_name = config.provider.name if config.provider else ""
143+
variation_key = config.tracker._variation_key if config.tracker else ""
144+
145+
_, token = set_ai_config_baggage(key, variation_key, model_name, provider_name)
146+
try:
147+
yield config
148+
finally:
149+
detach_ai_config_baggage(token)
150+
94151
def config(
95152
self,
96153
key: str,
@@ -661,18 +718,21 @@ def __evaluate(
661718
custom=custom
662719
)
663720

721+
ld_meta = variation.get('_ldMeta', {})
722+
enabled = ld_meta.get('enabled', False)
723+
664724
tracker = LDAIConfigTracker(
665725
self._client,
666-
variation.get('_ldMeta', {}).get('variationKey', ''),
726+
ld_meta.get('variationKey', ''),
667727
key,
668-
int(variation.get('_ldMeta', {}).get('version', 1)),
728+
int(ld_meta.get('version', 1)),
669729
model.name if model else '',
670730
provider_config.name if provider_config else '',
671731
context,
732+
observe_config=self._observe_config,
733+
enabled=bool(enabled),
672734
)
673735

674-
enabled = variation.get('_ldMeta', {}).get('enabled', False)
675-
676736
judge_configuration = None
677737
if 'judgeConfiguration' in variation and isinstance(variation['judgeConfiguration'], dict):
678738
judge_config = variation['judgeConfiguration']

0 commit comments

Comments
 (0)