diff --git a/src/sap_cloud_sdk/core/telemetry/telemetry.py b/src/sap_cloud_sdk/core/telemetry/telemetry.py index e3211d2..c105109 100644 --- a/src/sap_cloud_sdk/core/telemetry/telemetry.py +++ b/src/sap_cloud_sdk/core/telemetry/telemetry.py @@ -31,6 +31,11 @@ # Context variable for per-request tenant ID _tenant_id_var: ContextVar[str] = ContextVar("tenant_id", default="") +# Context variable for propagated span attributes +_propagated_attrs_var: ContextVar[Dict[str, Any]] = ContextVar( + "propagated_attrs", default={} +) + def set_tenant_id(tenant_id: str) -> None: """Set the tenant ID for the current request context. @@ -78,6 +83,16 @@ def get_tenant_id() -> str: return _tenant_id_var.get() +def get_propagated_attributes() -> Dict[str, Any]: + """Get the propagated span attributes from the current context. + + Returns: + Dict of attributes propagated from an ancestor span with propagate=True, + or an empty dict if none are set. + """ + return _propagated_attrs_var.get() + + def record_request_metric( module: Module, source: Optional[Module], operation: str, deprecated: bool = False ) -> None: diff --git a/src/sap_cloud_sdk/core/telemetry/tracer.py b/src/sap_cloud_sdk/core/telemetry/tracer.py index 0faa4b1..5cca857 100644 --- a/src/sap_cloud_sdk/core/telemetry/tracer.py +++ b/src/sap_cloud_sdk/core/telemetry/tracer.py @@ -6,14 +6,18 @@ by auto_instrument(). """ -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from typing import Optional, Dict, Any from opentelemetry import trace from opentelemetry.trace import Status, StatusCode, Span from sap_cloud_sdk.core.telemetry.genai_operation import GenAIOperation -from sap_cloud_sdk.core.telemetry.telemetry import get_tenant_id +from sap_cloud_sdk.core.telemetry.telemetry import ( + get_tenant_id, + get_propagated_attributes, + _propagated_attrs_var, +) from sap_cloud_sdk.core.telemetry.constants import ATTR_SAP_TENANT_ID # OpenTelemetry GenAI semantic attribute names (avoid duplicate string literals) @@ -30,22 +34,29 @@ _ATTR_SERVER_ADDRESS = "server.address" +@contextmanager +def _propagate_attributes(attrs: Dict[str, Any]): + """Push attrs onto the propagation stack for the duration of the context.""" + current = _propagated_attrs_var.get() + merged = {**current, **attrs} + token = _propagated_attrs_var.set(merged) + try: + yield + finally: + _propagated_attrs_var.reset(token) + + @contextmanager def context_overlay( name: GenAIOperation, *, attributes: Optional[Dict[str, Any]] = None, kind: trace.SpanKind = trace.SpanKind.INTERNAL, + propagate: bool = False, ): """ Create a context overlay for tracing GenAI operations. - Works in both sync and async code. The span is automatically closed - when exiting the context, and exceptions are automatically recorded. - This context manager integrates seamlessly with the auto-instrumentation - provided by auto_instrument(), allowing you to create parent spans that - wrap auto-instrumented AI framework calls. - Args: name: GenAI operation name following OpenTelemetry semantic conventions. Example: GenAIOperation.CHAT, GenAIOperation.EMBEDDINGS @@ -53,73 +64,43 @@ def context_overlay( (e.g., {"user.id": "123", "session.id": "abc"}) kind: Span kind - usually INTERNAL for application code. Other options: SERVER, CLIENT, PRODUCER, CONSUMER + propagate: If True, this span's attributes are passed to all nested spans + within its scope as the lowest-priority layer. Yields: The created span (available for advanced use cases like adding events) - Examples: - Basic GenAI operation: - ```python - from sap_cloud_sdk.core.telemetry import context_overlay, GenAIOperation - - with context_overlay(GenAIOperation.CHAT): - response = llm.chat(message) - ``` - - With custom attributes: + Example: ```python - with context_overlay( - name=GenAIOperation.CHAT, - attributes={"user.id": "123", "session.id": "abc"} - ): + with context_overlay(GenAIOperation.CHAT, attributes={"user.id": "123"}): response = llm.chat(message) ``` - - In async code (works the same): - ```python - async def handle_request(): - with context_overlay(GenAIOperation.CHAT): - result = await llm.chat_async(message) - ``` - - Nested spans: - ```python - with context_overlay(GenAIOperation.RETRIEVAL): - documents = retrieve_documents(query) - - with context_overlay(GenAIOperation.CHAT): - response = llm.chat(documents) - ``` - - Advanced usage with span events: - ```python - with context_overlay(GenAIOperation.EMBEDDINGS) as span: - span.add_event("processing_started") - embeddings = generate_embeddings(text) - span.add_event("processing_completed") - ``` """ tracer = trace.get_tracer(__name__) # Convert enum to string if needed span_name = str(name) - # Add tenant_id if set - span_attrs = attributes.copy() if attributes else {} + # Merge propagated attrs (lowest priority), then user attrs, then required attrs + propagated = get_propagated_attributes() + span_attrs = {**propagated, **(attributes or {})} + span_attrs[_ATTR_GEN_AI_OPERATION_NAME] = span_name tenant_id = get_tenant_id() if tenant_id: span_attrs[ATTR_SAP_TENANT_ID] = tenant_id - with tracer.start_as_current_span( - span_name, kind=kind, attributes=span_attrs - ) as span: - try: - yield span - except Exception as e: - # Record the exception in the span - span.set_status(Status(StatusCode.ERROR, str(e))) - span.record_exception(e) - raise + ctx = _propagate_attributes(span_attrs) if propagate else nullcontext() + with ctx: + with tracer.start_as_current_span( + span_name, kind=kind, attributes=span_attrs + ) as span: + try: + yield span + except Exception as e: + # Record the exception in the span + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise @contextmanager @@ -130,6 +111,7 @@ def chat_span( conversation_id: Optional[str] = None, server_address: Optional[str] = None, attributes: Optional[Dict[str, Any]] = None, + propagate: bool = False, ): """ Create a span for LLM chat/completion API calls (OpenTelemetry GenAI Inference span). @@ -147,28 +129,17 @@ def chat_span( (e.g. thread or session ID). Set as gen_ai.conversation.id when provided. server_address: Optional server address. If None, server.address is not set. attributes: Optional dict of extra attributes to add or override on the span. + propagate: If True, this span's attributes are passed to all nested spans + within its scope as the lowest-priority layer. Yields: The created Span (e.g. to set gen_ai.usage.input_tokens, gen_ai.response.finish_reason). - Examples: - Agentic workflow with chat and tool execution: + Example: ```python - from sap_cloud_sdk.core.telemetry import chat_span, execute_tool_span - - with chat_span(model="gpt-4", provider="openai") as span: - response = client.chat.completions.create( - messages=[{"role": "user", "content": "What's the weather?"}], - tools=[weather_tool] - ) + with chat_span(model="gpt-4", provider="openai", conversation_id="cid") as span: + response = client.chat.completions.create(...) span.set_attribute("gen_ai.usage.input_tokens", response.usage.prompt_tokens) - span.set_attribute("gen_ai.response.finish_reason", response.choices[0].finish_reason) - - if response.choices[0].message.tool_calls: - tool_call = response.choices[0].message.tool_calls[0] - with execute_tool_span(tool_name=tool_call.function.name) as tool_span: - result = execute_function(tool_call.function.name, tool_call.function.arguments) - tool_span.set_attribute("gen_ai.tool.call.result", result) ``` """ tracer = trace.get_tracer(__name__) @@ -186,20 +157,23 @@ def chat_span( tenant_id = get_tenant_id() if tenant_id: base_attrs[ATTR_SAP_TENANT_ID] = tenant_id - # User attributes first, then base_attrs so required semantic keys are never overridden - span_attrs = {**(attributes or {}), **base_attrs} - - with tracer.start_as_current_span( - span_name, - kind=trace.SpanKind.CLIENT, - attributes=span_attrs, - ) as span: - try: - yield span - except Exception as e: - span.set_status(Status(StatusCode.ERROR, str(e))) - span.record_exception(e) - raise + # Propagated attrs (lowest), user attrs, required semantic keys (highest) + propagated = get_propagated_attributes() + span_attrs = {**propagated, **(attributes or {}), **base_attrs} + + ctx = _propagate_attributes(span_attrs) if propagate else nullcontext() + with ctx: + with tracer.start_as_current_span( + span_name, + kind=trace.SpanKind.CLIENT, + attributes=span_attrs, + ) as span: + try: + yield span + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise @contextmanager @@ -209,6 +183,7 @@ def execute_tool_span( tool_type: Optional[str] = None, tool_description: Optional[str] = None, attributes: Optional[Dict[str, Any]] = None, + propagate: bool = False, ): """ Create a span for tool execution in agentic workflows (OpenTelemetry GenAI Execute Tool span). @@ -222,6 +197,8 @@ def execute_tool_span( tool_type: Optional tool type (e.g. "function"). tool_description: Optional tool description. attributes: Optional dict of extra attributes to add or override on the span. + propagate: If True, this span's attributes are passed to all nested spans + within its scope as the lowest-priority layer. Yields: The created Span (e.g. to set gen_ai.tool.call.result after execution). @@ -250,20 +227,23 @@ def execute_tool_span( tenant_id = get_tenant_id() if tenant_id: base_attrs[ATTR_SAP_TENANT_ID] = tenant_id - # User attributes first, then base_attrs so required semantic keys are never overridden - span_attrs = {**(attributes or {}), **base_attrs} - - with tracer.start_as_current_span( - span_name, - kind=trace.SpanKind.INTERNAL, - attributes=span_attrs, - ) as span: - try: - yield span - except Exception as e: - span.set_status(Status(StatusCode.ERROR, str(e))) - span.record_exception(e) - raise + # Propagated attrs (lowest), user attrs, required semantic keys (highest) + propagated = get_propagated_attributes() + span_attrs = {**propagated, **(attributes or {}), **base_attrs} + + ctx = _propagate_attributes(span_attrs) if propagate else nullcontext() + with ctx: + with tracer.start_as_current_span( + span_name, + kind=trace.SpanKind.INTERNAL, + attributes=span_attrs, + ) as span: + try: + yield span + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise @contextmanager @@ -277,6 +257,7 @@ def invoke_agent_span( server_address: Optional[str] = None, kind: trace.SpanKind = trace.SpanKind.CLIENT, attributes: Optional[Dict[str, Any]] = None, + propagate: bool = False, ): """ Create a span for GenAI agent invocation (OpenTelemetry GenAI Invoke agent span). @@ -298,6 +279,8 @@ def invoke_agent_span( server_address: Optional server address. If None, server.address is not set. kind: Span kind; CLIENT for remote agents, INTERNAL for in-process. attributes: Optional dict of extra attributes to add or override on the span. + propagate: If True, this span's attributes are passed to all nested spans + within its scope as the lowest-priority layer. Yields: The created Span (e.g. to set usage, response attributes). @@ -337,20 +320,23 @@ def invoke_agent_span( tenant_id = get_tenant_id() if tenant_id: base_attrs[ATTR_SAP_TENANT_ID] = tenant_id - # User attributes first, then base_attrs so required semantic keys are never overridden - span_attrs = {**(attributes or {}), **base_attrs} - - with tracer.start_as_current_span( - span_name, - kind=kind, - attributes=span_attrs, - ) as span: - try: - yield span - except Exception as e: - span.set_status(Status(StatusCode.ERROR, str(e))) - span.record_exception(e) - raise + # Propagated attrs (lowest), user attrs, required semantic keys (highest) + propagated = get_propagated_attributes() + span_attrs = {**propagated, **(attributes or {}), **base_attrs} + + ctx = _propagate_attributes(span_attrs) if propagate else nullcontext() + with ctx: + with tracer.start_as_current_span( + span_name, + kind=kind, + attributes=span_attrs, + ) as span: + try: + yield span + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise def get_current_span() -> Span: diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index f8cedc6..dc88f52 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -1,15 +1,22 @@ # Telemetry User Guide -## What it does +## How it works -- **Auto-instruments AI frameworks** - automatic tracing -- **Creates custom spans** - wrap your code to trace operations and add context -- **Records metrics** - token usage for LLM calls -- **Tracks tenant IDs** - per-request tenant tracking in traces and metrics +Telemetry has two layers that work together: -## How to use it +- **Auto-instrumentation** handles the *what*: LLM calls, token counts, latency, model names — automatically, by calling auto_instrument() once. +- **Custom spans** handle the *who* and *why*: which agent, which user, which operation — context that autoinstrumentation can't infer. -### Auto-instrument AI frameworks +The primary pattern is to wrap autoinstrumented calls in a parent span that carries your business context: + +``` +invoke_agent span ← you create this (agent name, tenant, session, operation type) + └─ chat span ← autoinstrumentation creates this (model, tokens, latency) +``` + +## Quick start + +### 1. Enable auto-instrumentation Call before importing AI libraries: @@ -19,181 +26,192 @@ from sap_cloud_sdk.core.telemetry import auto_instrument auto_instrument() from litellm import completion -# All LLM calls are now automatically traced +# LLM calls are now automatically traced ``` -### Create custom spans +### 2. Add business context with a parent span -Wrap operations to trace them: +Wrap your LLM calls to add the context autoinstrumentation can't provide: ```python -from sap_cloud_sdk.core.telemetry import context_overlay, GenAIOperation +from sap_cloud_sdk.core.telemetry import invoke_agent_span -with context_overlay(GenAIOperation.CHAT): - response = llm.chat(message) +with invoke_agent_span(provider="openai", agent_name="SupportBot", conversation_id="conv-123"): + # autoinstrumented LLM call is a child of this span + response = client.chat.completions.create(...) ``` -Add custom attributes: +### 3. Set tenant ID at the request boundary ```python -with context_overlay( - GenAIOperation.CHAT, - attributes={"user.id": "123", "feature": "support"} -): - response = llm.chat(message) -``` - -Available operations: +from sap_cloud_sdk.core.telemetry import set_tenant_id -```python -GenAIOperation.CHAT # Chat completion -GenAIOperation.TEXT_COMPLETION # Text completion -GenAIOperation.EMBEDDINGS # Embedding generation -GenAIOperation.GENERATE_CONTENT # Multimodal content generation -GenAIOperation.RETRIEVAL # Document/context retrieval -GenAIOperation.EXECUTE_TOOL # Tool execution -GenAIOperation.CREATE_AGENT # Agent creation -GenAIOperation.INVOKE_AGENT # Agent invocation +def handle_request(request): + set_tenant_id(extract_tenant_from_jwt(request)) ``` -Nest spans for complex workflows: +--- -```python -with context_overlay(GenAIOperation.RETRIEVAL): - documents = retrieve_documents(query) - - with context_overlay(GenAIOperation.CHAT): - response = llm.chat(messages=[ - {"role": "system", "content": f"Context: {documents}"}, - {"role": "user", "content": query} - ]) -``` +## Span functions -Add events to spans: - -```python -with context_overlay(GenAIOperation.EMBEDDINGS) as span: - span.add_event("preprocessing_started") - text = preprocess_text(raw_text) - - span.add_event("embedding_generation_started") - embeddings = generate_embeddings(text) - - span.add_event("completed", attributes={ - "embedding_dim": len(embeddings) - }) -``` - -### GenAI-specific spans - -For LLM calls following [OpenTelemetry GenAI conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/): +For operations following [OpenTelemetry GenAI conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/): ```python from sap_cloud_sdk.core.telemetry import chat_span, execute_tool_span, invoke_agent_span -# LLM chat calls +# Agent invocation — top-level parent span for an agent turn +with invoke_agent_span(provider="openai", agent_name="SupportBot", conversation_id="cid"): + response = client.beta.threads.runs.create(...) + +# LLM chat call — use when autoinstrumentation is not available with chat_span(model="gpt-4", provider="openai", conversation_id="cid") as span: response = client.chat.completions.create(...) # Tool execution with execute_tool_span(tool_name="get_weather", tool_type="mcp", tool_description="weather mcp server"): result = call_weather_api(location) - -# Agent invocation -with invoke_agent_span(provider="openai", agent_name="SupportBot", agent_id="id", agent_description="support agent", conversation_id="cid"): - response = client.beta.threads.runs.create(...) ``` -### Track tenant ID +### Generic spans -Set at request entry point: +Use `context_overlay` for operations without a dedicated function: ```python -from sap_cloud_sdk.core.telemetry import set_tenant_id +from sap_cloud_sdk.core.telemetry import context_overlay, GenAIOperation -def handle_request(request): - tenant_id = extract_tenant_from_jwt(request) - set_tenant_id(tenant_id) +with context_overlay(GenAIOperation.RETRIEVAL, attributes={"index": "knowledge-base"}): + documents = retrieve_documents(query) ``` -Thread-safe and async-safe. Automatic Propagation. - -### Access current span +Available operations: ```python -from sap_cloud_sdk.core.telemetry import get_current_span, add_span_attribute - -span = get_current_span() -if span.is_recording(): - span.set_attribute("custom.key", "value") - -# Or use the helper -add_span_attribute("request.id", request_id) +GenAIOperation.CHAT +GenAIOperation.TEXT_COMPLETION +GenAIOperation.EMBEDDINGS +GenAIOperation.GENERATE_CONTENT +GenAIOperation.RETRIEVAL +GenAIOperation.EXECUTE_TOOL +GenAIOperation.CREATE_AGENT +GenAIOperation.INVOKE_AGENT ``` -## Configuration +--- -### Production +## Adding attributes -For production environments, you should ensure that `OTEL_EXPORTER_OTLP_ENDPOINT` is configured and points to the expected OTLP endpoint. This variable is a standard environment variable from the OpenTelemetry libraries. +### To the current span -### Local Development +Add attributes to whichever span is currently active — including autoinstrumented ones: -To print traces directly to the console without an OTLP collector, set: +```python +from sap_cloud_sdk.core.telemetry import add_span_attribute -```bash -export OTEL_TRACES_EXPORTER=console +with invoke_agent_span(provider="openai", agent_name="SupportBot"): + response = client.chat.completions.create(...) + add_span_attribute("response.length", len(response.choices[0].message.content)) ``` -Then call `auto_instrument()` as usual — traces will be printed to stdout. +### To a specific span -To use an OTLP collector instead: +Every span function yields the span for direct access: -```bash -export OTEL_EXPORTER_OTLP_ENDPOINT="https://otel-collector.example.com" +```python +with invoke_agent_span(provider="openai", agent_name="SupportBot") as span: + span.add_event("tool_selected", attributes={"tool": "search"}) + response = client.chat.completions.create(...) ``` -### System Role +### Propagating parent attributes to child spans -Set via environment variable: +By default, attributes set on a parent span stay on that span. If you need attributes to also appear on child spans — for example, to filter by `user.id` at the LLM span level in your observability backend — use `propagate=True`: -```bash -export APPFND_CONHOS_SYSTEM_ROLE="S4HC" +```python +with invoke_agent_span( + provider="openai", + agent_name="SupportBot", + attributes={"user.id": "u-456"}, + propagate=True +): + # child spans automatically receive user.id + with execute_tool_span("search"): + ... + with chat_span("gpt-4", "openai"): + ... ``` -## Complete Example +> **Note:** `propagate=True` is specific for backends that require attributes to appear on every span individually. In most cases, querying by the parent span is sufficient and preferred. + +**Priority rules** — child span values always win (highest to lowest): +1. Required semantic keys set by the span function (e.g. `gen_ai.operation.name`) +2. User-provided `attributes` on the child span +3. Propagated attributes from ancestors + +Propagation is scoped: once the parent span exits, its attributes stop propagating to subsequent spans. + +--- + +## Complete example ```python from sap_cloud_sdk.core.telemetry import ( auto_instrument, - context_overlay, - GenAIOperation, + invoke_agent_span, + execute_tool_span, set_tenant_id, - add_span_attribute + add_span_attribute, ) auto_instrument() from litellm import completion -async def handle_customer_query(query: str, user_id: str): +async def handle_request(query: str, user_id: str): set_tenant_id("bh7sjh...") - - with context_overlay( - GenAIOperation.RETRIEVAL, - attributes={"user.id": user_id, "query.type": "support"} + + # Parent span carries business context for the whole agent turn. + # Autoinstrumentation creates the child LLM span automatically. + with invoke_agent_span( + provider="openai", + agent_name="SupportBot", + attributes={"user.id": user_id} ): documents = await retrieve_knowledge_base(query) - add_span_attribute("documents.count", len(documents)) - - with context_overlay(GenAIOperation.CHAT): - response = completion( - model="gpt-4", - messages=[ - {"role": "system", "content": f"Context: {documents}"}, - {"role": "user", "content": query} - ] - ) - add_span_attribute("response.length", len(response.choices[0].message.content)) - + add_span_attribute("documents.retrieved", len(documents)) + + response = completion( + model="gpt-4", + messages=[ + {"role": "system", "content": f"Context: {documents}"}, + {"role": "user", "content": query} + ] + ) + return response +``` + +## Configuration + +### Production + +Ensure `OTEL_EXPORTER_OTLP_ENDPOINT` points to your OTLP endpoint. + +### Local development + +Print traces to console: + +```bash +export OTEL_TRACES_EXPORTER=console +``` + +Use an OTLP collector: + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT="https://otel-collector.example.com" +``` + +### System role + +```bash +export APPFND_CONHOS_SYSTEM_ROLE="S4HC" +``` diff --git a/tests/core/unit/telemetry/test_tracer.py b/tests/core/unit/telemetry/test_tracer.py index c9a420c..4468a98 100644 --- a/tests/core/unit/telemetry/test_tracer.py +++ b/tests/core/unit/telemetry/test_tracer.py @@ -54,7 +54,9 @@ def mock_start_as_current_span(name, kind=None, attributes=None): with context_overlay(GenAIOperation.CHAT, attributes=custom_attrs): pass - assert captured_attributes == custom_attrs + assert captured_attributes["user.id"] == "123" + assert captured_attributes["session.id"] == "abc" + assert captured_attributes["gen_ai.operation.name"] == "chat" def test_context_overlay_with_different_span_kinds(self): """Test context overlay with different span kinds.""" @@ -870,3 +872,112 @@ def mock_start_as_current_span(name, kind=None, attributes=None): span.add_event("agent_step") mock_span.add_event.assert_called_once_with("agent_step") + + +class TestPropagate: + """Test suite for propagate=True attribute propagation across nested spans.""" + + def _make_tracer(self, mock_span, capture_list): + """Helper: return a mock tracer that records (name, attributes) into capture_list.""" + mock_tracer = MagicMock() + + @contextmanager + def mock_start_as_current_span(name, kind=None, attributes=None): + capture_list.append({"name": name, "attributes": dict(attributes or {})}) + yield mock_span + + mock_tracer.start_as_current_span = mock_start_as_current_span + return mock_tracer + + def test_context_overlay_propagate_passes_attributes_to_child_span(self): + """Parent context_overlay(propagate=True) attrs appear on nested context_overlay.""" + mock_span = MagicMock() + captures = [] + mock_tracer = self._make_tracer(mock_span, captures) + + with patch('opentelemetry.trace.get_tracer', return_value=mock_tracer): + with context_overlay(GenAIOperation.CHAT, attributes={"x": 1}, propagate=True): + with context_overlay(GenAIOperation.EMBEDDINGS): + pass + + child_attrs = captures[1]["attributes"] + assert child_attrs.get("x") == 1 + + def test_chat_span_propagate_passes_attributes_to_child_span(self): + """chat_span(propagate=True) attrs appear on a nested context_overlay.""" + mock_span = MagicMock() + captures = [] + mock_tracer = self._make_tracer(mock_span, captures) + + with patch('opentelemetry.trace.get_tracer', return_value=mock_tracer): + with chat_span("gpt-4", "openai", attributes={"agent.name": "bot"}, propagate=True): + with context_overlay(GenAIOperation.CHAT): + pass + + child_attrs = captures[1]["attributes"] + assert child_attrs.get("agent.name") == "bot" + + def test_propagate_does_not_override_child_base_attrs(self): + """Propagated gen_ai.operation.name from parent does not override child's own value.""" + mock_span = MagicMock() + captures = [] + mock_tracer = self._make_tracer(mock_span, captures) + + with patch('opentelemetry.trace.get_tracer', return_value=mock_tracer): + with context_overlay(GenAIOperation.CHAT, propagate=True): + # parent sets gen_ai.operation.name="chat"; child should keep its own + with chat_span("gpt-4", "openai"): + pass + + child_attrs = captures[1]["attributes"] + assert child_attrs["gen_ai.operation.name"] == "chat" + assert child_attrs["gen_ai.request.model"] == "gpt-4" + + def test_propagate_false_does_not_pollute_sibling_spans(self): + """After a propagate=True span exits, its attrs don't appear on a sibling span.""" + mock_span = MagicMock() + captures = [] + mock_tracer = self._make_tracer(mock_span, captures) + + with patch('opentelemetry.trace.get_tracer', return_value=mock_tracer): + with context_overlay(GenAIOperation.CHAT, attributes={"secret": "yes"}, propagate=True): + pass + # sibling — propagation should be reset after the first span exits + with context_overlay(GenAIOperation.EMBEDDINGS): + pass + + sibling_attrs = captures[1]["attributes"] + assert "secret" not in sibling_attrs + + def test_propagate_accumulates_across_nested_levels(self): + """Three levels deep, each with propagate=True; deepest gets attrs from all parents.""" + mock_span = MagicMock() + captures = [] + mock_tracer = self._make_tracer(mock_span, captures) + + with patch('opentelemetry.trace.get_tracer', return_value=mock_tracer): + with context_overlay(GenAIOperation.CHAT, attributes={"level": "1", "from_l1": "yes"}, propagate=True): + with context_overlay(GenAIOperation.EMBEDDINGS, attributes={"level": "2", "from_l2": "yes"}, propagate=True): + with context_overlay(GenAIOperation.RETRIEVAL): + pass + + deepest_attrs = captures[2]["attributes"] + # from_l1 comes from level 1, from_l2 comes from level 2 + assert deepest_attrs.get("from_l1") == "yes" + assert deepest_attrs.get("from_l2") == "yes" + # level was set by both; level 2 overwrites level 1 in propagation stack + assert deepest_attrs.get("level") == "2" + + def test_propagate_default_is_false(self): + """Without propagate param, child spans do not receive parent custom attrs.""" + mock_span = MagicMock() + captures = [] + mock_tracer = self._make_tracer(mock_span, captures) + + with patch('opentelemetry.trace.get_tracer', return_value=mock_tracer): + with context_overlay(GenAIOperation.CHAT, attributes={"custom": "val"}): + with context_overlay(GenAIOperation.EMBEDDINGS): + pass + + child_attrs = captures[1]["attributes"] + assert "custom" not in child_attrs