Skip to content

Commit b3971f0

Browse files
feat: context propagation and operation type attributes
1 parent 49da0a0 commit b3971f0

File tree

4 files changed

+365
-235
lines changed

4 files changed

+365
-235
lines changed

src/sap_cloud_sdk/core/telemetry/telemetry.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
# Context variable for per-request tenant ID
3232
_tenant_id_var: ContextVar[str] = ContextVar("tenant_id", default="")
3333

34+
# Context variable for propagated span attributes
35+
_propagated_attrs_var: ContextVar[Dict[str, Any]] = ContextVar(
36+
"propagated_attrs", default={}
37+
)
38+
3439

3540
def set_tenant_id(tenant_id: str) -> None:
3641
"""Set the tenant ID for the current request context.
@@ -78,6 +83,16 @@ def get_tenant_id() -> str:
7883
return _tenant_id_var.get()
7984

8085

86+
def get_propagated_attributes() -> Dict[str, Any]:
87+
"""Get the propagated span attributes from the current context.
88+
89+
Returns:
90+
Dict of attributes propagated from an ancestor span with propagate=True,
91+
or an empty dict if none are set.
92+
"""
93+
return _propagated_attrs_var.get()
94+
95+
8196
def record_request_metric(
8297
module: Module, source: Optional[Module], operation: str, deprecated: bool = False
8398
) -> None:

src/sap_cloud_sdk/core/telemetry/tracer.py

Lines changed: 102 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@
66
by auto_instrument().
77
"""
88

9-
from contextlib import contextmanager
9+
from contextlib import contextmanager, nullcontext
1010
from typing import Optional, Dict, Any
1111

1212
from opentelemetry import trace
1313
from opentelemetry.trace import Status, StatusCode, Span
1414

1515
from sap_cloud_sdk.core.telemetry.genai_operation import GenAIOperation
16-
from sap_cloud_sdk.core.telemetry.telemetry import get_tenant_id
16+
from sap_cloud_sdk.core.telemetry.telemetry import (
17+
get_tenant_id,
18+
get_propagated_attributes,
19+
_propagated_attrs_var,
20+
)
1721
from sap_cloud_sdk.core.telemetry.constants import ATTR_SAP_TENANT_ID
1822

1923
# OpenTelemetry GenAI semantic attribute names (avoid duplicate string literals)
@@ -30,96 +34,73 @@
3034
_ATTR_SERVER_ADDRESS = "server.address"
3135

3236

37+
@contextmanager
38+
def _propagate_attributes(attrs: Dict[str, Any]):
39+
"""Push attrs onto the propagation stack for the duration of the context."""
40+
current = _propagated_attrs_var.get()
41+
merged = {**current, **attrs}
42+
token = _propagated_attrs_var.set(merged)
43+
try:
44+
yield
45+
finally:
46+
_propagated_attrs_var.reset(token)
47+
48+
3349
@contextmanager
3450
def context_overlay(
3551
name: GenAIOperation,
3652
*,
3753
attributes: Optional[Dict[str, Any]] = None,
3854
kind: trace.SpanKind = trace.SpanKind.INTERNAL,
55+
propagate: bool = False,
3956
):
4057
"""
4158
Create a context overlay for tracing GenAI operations.
4259
43-
Works in both sync and async code. The span is automatically closed
44-
when exiting the context, and exceptions are automatically recorded.
45-
This context manager integrates seamlessly with the auto-instrumentation
46-
provided by auto_instrument(), allowing you to create parent spans that
47-
wrap auto-instrumented AI framework calls.
48-
4960
Args:
5061
name: GenAI operation name following OpenTelemetry semantic conventions.
5162
Example: GenAIOperation.CHAT, GenAIOperation.EMBEDDINGS
5263
attributes: Optional custom attributes to add to the span
5364
(e.g., {"user.id": "123", "session.id": "abc"})
5465
kind: Span kind - usually INTERNAL for application code.
5566
Other options: SERVER, CLIENT, PRODUCER, CONSUMER
67+
propagate: If True, this span's attributes are passed to all nested spans
68+
within its scope as the lowest-priority layer.
5669
5770
Yields:
5871
The created span (available for advanced use cases like adding events)
5972
60-
Examples:
61-
Basic GenAI operation:
62-
```python
63-
from sap_cloud_sdk.core.telemetry import context_overlay, GenAIOperation
64-
65-
with context_overlay(GenAIOperation.CHAT):
66-
response = llm.chat(message)
67-
```
68-
69-
With custom attributes:
73+
Example:
7074
```python
71-
with context_overlay(
72-
name=GenAIOperation.CHAT,
73-
attributes={"user.id": "123", "session.id": "abc"}
74-
):
75+
with context_overlay(GenAIOperation.CHAT, attributes={"user.id": "123"}):
7576
response = llm.chat(message)
7677
```
77-
78-
In async code (works the same):
79-
```python
80-
async def handle_request():
81-
with context_overlay(GenAIOperation.CHAT):
82-
result = await llm.chat_async(message)
83-
```
84-
85-
Nested spans:
86-
```python
87-
with context_overlay(GenAIOperation.RETRIEVAL):
88-
documents = retrieve_documents(query)
89-
90-
with context_overlay(GenAIOperation.CHAT):
91-
response = llm.chat(documents)
92-
```
93-
94-
Advanced usage with span events:
95-
```python
96-
with context_overlay(GenAIOperation.EMBEDDINGS) as span:
97-
span.add_event("processing_started")
98-
embeddings = generate_embeddings(text)
99-
span.add_event("processing_completed")
100-
```
10178
"""
10279
tracer = trace.get_tracer(__name__)
10380

10481
# Convert enum to string if needed
10582
span_name = str(name)
10683

107-
# Add tenant_id if set
108-
span_attrs = attributes.copy() if attributes else {}
84+
# Merge propagated attrs (lowest priority), then user attrs, then required attrs
85+
propagated = get_propagated_attributes()
86+
span_attrs = {**propagated, **(attributes or {})}
87+
span_attrs[_ATTR_GEN_AI_OPERATION_NAME] = span_name
10988
tenant_id = get_tenant_id()
11089
if tenant_id:
11190
span_attrs[ATTR_SAP_TENANT_ID] = tenant_id
11291

113-
with tracer.start_as_current_span(
114-
span_name, kind=kind, attributes=span_attrs
115-
) as span:
116-
try:
117-
yield span
118-
except Exception as e:
119-
# Record the exception in the span
120-
span.set_status(Status(StatusCode.ERROR, str(e)))
121-
span.record_exception(e)
122-
raise
92+
ctx = _propagate_attributes(span_attrs) if propagate else nullcontext()
93+
with ctx:
94+
with tracer.start_as_current_span(
95+
span_name, kind=kind, attributes=span_attrs
96+
) as span:
97+
try:
98+
yield span
99+
except Exception as e:
100+
# Record the exception in the span
101+
span.set_status(Status(StatusCode.ERROR, str(e)))
102+
span.record_exception(e)
103+
raise
123104

124105

125106
@contextmanager
@@ -130,6 +111,7 @@ def chat_span(
130111
conversation_id: Optional[str] = None,
131112
server_address: Optional[str] = None,
132113
attributes: Optional[Dict[str, Any]] = None,
114+
propagate: bool = False,
133115
):
134116
"""
135117
Create a span for LLM chat/completion API calls (OpenTelemetry GenAI Inference span).
@@ -147,28 +129,17 @@ def chat_span(
147129
(e.g. thread or session ID). Set as gen_ai.conversation.id when provided.
148130
server_address: Optional server address. If None, server.address is not set.
149131
attributes: Optional dict of extra attributes to add or override on the span.
132+
propagate: If True, this span's attributes are passed to all nested spans
133+
within its scope as the lowest-priority layer.
150134
151135
Yields:
152136
The created Span (e.g. to set gen_ai.usage.input_tokens, gen_ai.response.finish_reason).
153137
154-
Examples:
155-
Agentic workflow with chat and tool execution:
138+
Example:
156139
```python
157-
from sap_cloud_sdk.core.telemetry import chat_span, execute_tool_span
158-
159-
with chat_span(model="gpt-4", provider="openai") as span:
160-
response = client.chat.completions.create(
161-
messages=[{"role": "user", "content": "What's the weather?"}],
162-
tools=[weather_tool]
163-
)
140+
with chat_span(model="gpt-4", provider="openai", conversation_id="cid") as span:
141+
response = client.chat.completions.create(...)
164142
span.set_attribute("gen_ai.usage.input_tokens", response.usage.prompt_tokens)
165-
span.set_attribute("gen_ai.response.finish_reason", response.choices[0].finish_reason)
166-
167-
if response.choices[0].message.tool_calls:
168-
tool_call = response.choices[0].message.tool_calls[0]
169-
with execute_tool_span(tool_name=tool_call.function.name) as tool_span:
170-
result = execute_function(tool_call.function.name, tool_call.function.arguments)
171-
tool_span.set_attribute("gen_ai.tool.call.result", result)
172143
```
173144
"""
174145
tracer = trace.get_tracer(__name__)
@@ -186,20 +157,23 @@ def chat_span(
186157
tenant_id = get_tenant_id()
187158
if tenant_id:
188159
base_attrs[ATTR_SAP_TENANT_ID] = tenant_id
189-
# User attributes first, then base_attrs so required semantic keys are never overridden
190-
span_attrs = {**(attributes or {}), **base_attrs}
191-
192-
with tracer.start_as_current_span(
193-
span_name,
194-
kind=trace.SpanKind.CLIENT,
195-
attributes=span_attrs,
196-
) as span:
197-
try:
198-
yield span
199-
except Exception as e:
200-
span.set_status(Status(StatusCode.ERROR, str(e)))
201-
span.record_exception(e)
202-
raise
160+
# Propagated attrs (lowest), user attrs, required semantic keys (highest)
161+
propagated = get_propagated_attributes()
162+
span_attrs = {**propagated, **(attributes or {}), **base_attrs}
163+
164+
ctx = _propagate_attributes(span_attrs) if propagate else nullcontext()
165+
with ctx:
166+
with tracer.start_as_current_span(
167+
span_name,
168+
kind=trace.SpanKind.CLIENT,
169+
attributes=span_attrs,
170+
) as span:
171+
try:
172+
yield span
173+
except Exception as e:
174+
span.set_status(Status(StatusCode.ERROR, str(e)))
175+
span.record_exception(e)
176+
raise
203177

204178

205179
@contextmanager
@@ -209,6 +183,7 @@ def execute_tool_span(
209183
tool_type: Optional[str] = None,
210184
tool_description: Optional[str] = None,
211185
attributes: Optional[Dict[str, Any]] = None,
186+
propagate: bool = False,
212187
):
213188
"""
214189
Create a span for tool execution in agentic workflows (OpenTelemetry GenAI Execute Tool span).
@@ -222,6 +197,8 @@ def execute_tool_span(
222197
tool_type: Optional tool type (e.g. "function").
223198
tool_description: Optional tool description.
224199
attributes: Optional dict of extra attributes to add or override on the span.
200+
propagate: If True, this span's attributes are passed to all nested spans
201+
within its scope as the lowest-priority layer.
225202
226203
Yields:
227204
The created Span (e.g. to set gen_ai.tool.call.result after execution).
@@ -250,20 +227,23 @@ def execute_tool_span(
250227
tenant_id = get_tenant_id()
251228
if tenant_id:
252229
base_attrs[ATTR_SAP_TENANT_ID] = tenant_id
253-
# User attributes first, then base_attrs so required semantic keys are never overridden
254-
span_attrs = {**(attributes or {}), **base_attrs}
255-
256-
with tracer.start_as_current_span(
257-
span_name,
258-
kind=trace.SpanKind.INTERNAL,
259-
attributes=span_attrs,
260-
) as span:
261-
try:
262-
yield span
263-
except Exception as e:
264-
span.set_status(Status(StatusCode.ERROR, str(e)))
265-
span.record_exception(e)
266-
raise
230+
# Propagated attrs (lowest), user attrs, required semantic keys (highest)
231+
propagated = get_propagated_attributes()
232+
span_attrs = {**propagated, **(attributes or {}), **base_attrs}
233+
234+
ctx = _propagate_attributes(span_attrs) if propagate else nullcontext()
235+
with ctx:
236+
with tracer.start_as_current_span(
237+
span_name,
238+
kind=trace.SpanKind.INTERNAL,
239+
attributes=span_attrs,
240+
) as span:
241+
try:
242+
yield span
243+
except Exception as e:
244+
span.set_status(Status(StatusCode.ERROR, str(e)))
245+
span.record_exception(e)
246+
raise
267247

268248

269249
@contextmanager
@@ -277,6 +257,7 @@ def invoke_agent_span(
277257
server_address: Optional[str] = None,
278258
kind: trace.SpanKind = trace.SpanKind.CLIENT,
279259
attributes: Optional[Dict[str, Any]] = None,
260+
propagate: bool = False,
280261
):
281262
"""
282263
Create a span for GenAI agent invocation (OpenTelemetry GenAI Invoke agent span).
@@ -298,6 +279,8 @@ def invoke_agent_span(
298279
server_address: Optional server address. If None, server.address is not set.
299280
kind: Span kind; CLIENT for remote agents, INTERNAL for in-process.
300281
attributes: Optional dict of extra attributes to add or override on the span.
282+
propagate: If True, this span's attributes are passed to all nested spans
283+
within its scope as the lowest-priority layer.
301284
302285
Yields:
303286
The created Span (e.g. to set usage, response attributes).
@@ -337,20 +320,23 @@ def invoke_agent_span(
337320
tenant_id = get_tenant_id()
338321
if tenant_id:
339322
base_attrs[ATTR_SAP_TENANT_ID] = tenant_id
340-
# User attributes first, then base_attrs so required semantic keys are never overridden
341-
span_attrs = {**(attributes or {}), **base_attrs}
342-
343-
with tracer.start_as_current_span(
344-
span_name,
345-
kind=kind,
346-
attributes=span_attrs,
347-
) as span:
348-
try:
349-
yield span
350-
except Exception as e:
351-
span.set_status(Status(StatusCode.ERROR, str(e)))
352-
span.record_exception(e)
353-
raise
323+
# Propagated attrs (lowest), user attrs, required semantic keys (highest)
324+
propagated = get_propagated_attributes()
325+
span_attrs = {**propagated, **(attributes or {}), **base_attrs}
326+
327+
ctx = _propagate_attributes(span_attrs) if propagate else nullcontext()
328+
with ctx:
329+
with tracer.start_as_current_span(
330+
span_name,
331+
kind=kind,
332+
attributes=span_attrs,
333+
) as span:
334+
try:
335+
yield span
336+
except Exception as e:
337+
span.set_status(Status(StatusCode.ERROR, str(e)))
338+
span.record_exception(e)
339+
raise
354340

355341

356342
def get_current_span() -> Span:

0 commit comments

Comments
 (0)