66by auto_instrument().
77"""
88
9- from contextlib import contextmanager
9+ from contextlib import contextmanager , nullcontext
1010from typing import Optional , Dict , Any
1111
1212from opentelemetry import trace
1313from opentelemetry .trace import Status , StatusCode , Span
1414
1515from 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+ )
1721from sap_cloud_sdk .core .telemetry .constants import ATTR_SAP_TENANT_ID
1822
1923# OpenTelemetry GenAI semantic attribute names (avoid duplicate string literals)
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
3450def 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
356342def get_current_span () -> Span :
0 commit comments