diff --git a/src/strands/event_loop/event_loop.py b/src/strands/event_loop/event_loop.py index 3113ddb79..52b29b303 100644 --- a/src/strands/event_loop/event_loop.py +++ b/src/strands/event_loop/event_loop.py @@ -313,6 +313,8 @@ async def _handle_model_execution( parent_span=cycle_span, model_id=model_id, custom_trace_attributes=agent.trace_attributes, + system_prompt=agent.system_prompt, + system_prompt_content=agent._system_prompt_content, ) with trace_api.use_span(model_invoke_span, end_on_exit=True): await agent.hooks.invoke_callbacks_async( diff --git a/src/strands/telemetry/tracer.py b/src/strands/telemetry/tracer.py index 0471a7fcc..3ed409141 100644 --- a/src/strands/telemetry/tracer.py +++ b/src/strands/telemetry/tracer.py @@ -285,6 +285,8 @@ def start_model_invoke_span( parent_span: Span | None = None, model_id: str | None = None, custom_trace_attributes: Mapping[str, AttributeValue] | None = None, + system_prompt: str | None = None, + system_prompt_content: list | None = None, **kwargs: Any, ) -> Span: """Start a new span for a model invocation. @@ -294,6 +296,8 @@ def start_model_invoke_span( parent_span: Optional parent span to link this span to. model_id: Optional identifier for the model being invoked. custom_trace_attributes: Optional mapping of custom trace attributes to include in the span. + system_prompt: Optional system prompt string provided to the model. + system_prompt_content: Optional list of system prompt content blocks. **kwargs: Additional attributes to add to the span. Returns: @@ -311,6 +315,7 @@ def start_model_invoke_span( attributes.update({k: v for k, v in kwargs.items() if isinstance(v, (str, int, float, bool))}) span = self._start_span("chat", parent_span, attributes=attributes, span_kind=trace_api.SpanKind.INTERNAL) + self._add_system_prompt_event(span, system_prompt, system_prompt_content) self._add_event_messages(span, messages) return span @@ -813,6 +818,44 @@ def _get_common_attributes( ) return dict(common_attributes) + def _add_system_prompt_event( + self, + span: Span, + system_prompt: str | None = None, + system_prompt_content: list | None = None, + ) -> None: + """Emit system prompt as a span event per OTel GenAI semantic conventions. + + In legacy mode (v1.36), emits a ``gen_ai.system.message`` event. + In latest experimental mode, emits ``gen_ai.system_instructions`` on the + ``gen_ai.client.inference.operation.details`` event, since Strands passes + system instructions separately from chat history. + + Args: + span: The span to add the event to. + system_prompt: Optional system prompt string. + system_prompt_content: Optional list of system prompt content blocks. + """ + if not system_prompt and not system_prompt_content: + return + + content_blocks = system_prompt_content if system_prompt_content else [{"text": system_prompt}] + + if self.use_latest_genai_conventions: + parts = self._map_content_blocks_to_otel_parts(content_blocks) + self._add_event( + span, + "gen_ai.client.inference.operation.details", + {"gen_ai.system_instructions": serialize(parts)}, + to_span_attributes=self.is_langfuse, + ) + else: + self._add_event( + span, + "gen_ai.system.message", + {"content": serialize(content_blocks)}, + ) + def _add_event_messages(self, span: Span, messages: Messages) -> None: """Adds messages as event to the provided span based on the current GenAI conventions. diff --git a/tests/strands/event_loop/test_event_loop.py b/tests/strands/event_loop/test_event_loop.py index 8c6155e20..226d42440 100644 --- a/tests/strands/event_loop/test_event_loop.py +++ b/tests/strands/event_loop/test_event_loop.py @@ -545,6 +545,9 @@ async def test_event_loop_cycle_creates_spans( mock_get_tracer.assert_called_once() mock_tracer.start_event_loop_cycle_span.assert_called_once() mock_tracer.start_model_invoke_span.assert_called_once() + call_kwargs = mock_tracer.start_model_invoke_span.call_args[1] + assert call_kwargs["system_prompt"] == agent.system_prompt + assert call_kwargs["system_prompt_content"] == agent._system_prompt_content mock_tracer.end_model_invoke_span.assert_called_once() mock_tracer.end_event_loop_cycle_span.assert_called_once() diff --git a/tests/strands/telemetry/test_tracer.py b/tests/strands/telemetry/test_tracer.py index 410db0c0c..9176ce4ae 100644 --- a/tests/strands/telemetry/test_tracer.py +++ b/tests/strands/telemetry/test_tracer.py @@ -140,9 +140,14 @@ def test_start_model_invoke_span(mock_tracer): messages = [{"role": "user", "content": [{"text": "Hello"}]}] model_id = "test-model" custom_attrs = {"custom_key": "custom_value", "user_id": "12345"} + system_prompt = "You are a helpful assistant" span = tracer.start_model_invoke_span( - messages=messages, agent_name="TestAgent", model_id=model_id, custom_trace_attributes=custom_attrs + messages=messages, + agent_name="TestAgent", + model_id=model_id, + custom_trace_attributes=custom_attrs, + system_prompt=system_prompt, ) mock_tracer.start_span.assert_called_once() @@ -158,9 +163,14 @@ def test_start_model_invoke_span(mock_tracer): "agent_name": "TestAgent", } ) - mock_span.add_event.assert_called_with( - "gen_ai.user.message", attributes={"content": json.dumps(messages[0]["content"])} + + calls = mock_span.add_event.call_args_list + assert len(calls) == 2 + assert calls[0] == mock.call( + "gen_ai.system.message", + attributes={"content": serialize([{"text": system_prompt}])}, ) + assert calls[1] == mock.call("gen_ai.user.message", attributes={"content": json.dumps(messages[0]["content"])}) assert span is not None @@ -184,8 +194,11 @@ def test_start_model_invoke_span_latest_conventions(mock_tracer, monkeypatch): }, ] model_id = "test-model" + system_prompt = "You are a calculator assistant" - span = tracer.start_model_invoke_span(messages=messages, agent_name="TestAgent", model_id=model_id) + span = tracer.start_model_invoke_span( + messages=messages, agent_name="TestAgent", model_id=model_id, system_prompt=system_prompt + ) mock_tracer.start_span.assert_called_once() assert mock_tracer.start_span.call_args[1]["name"] == "chat" @@ -199,7 +212,16 @@ def test_start_model_invoke_span_latest_conventions(mock_tracer, monkeypatch): "agent_name": "TestAgent", } ) - mock_span.add_event.assert_called_with( + + calls = mock_span.add_event.call_args_list + assert len(calls) == 2 + assert calls[0] == mock.call( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.system_instructions": serialize([{"type": "text", "content": system_prompt}]), + }, + ) + assert calls[1] == mock.call( "gen_ai.client.inference.operation.details", attributes={ "gen_ai.input.messages": serialize( @@ -226,6 +248,54 @@ def test_start_model_invoke_span_latest_conventions(mock_tracer, monkeypatch): assert span is not None +def test_start_model_invoke_span_without_system_prompt(mock_tracer): + """Test that no system prompt event is emitted when system_prompt is None.""" + with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): + tracer = Tracer() + tracer.tracer = mock_tracer + + mock_span = mock.MagicMock() + mock_tracer.start_span.return_value = mock_span + + messages = [{"role": "user", "content": [{"text": "Hello"}]}] + + span = tracer.start_model_invoke_span(messages=messages, model_id="test-model") + + assert mock_span.add_event.call_count == 1 + mock_span.add_event.assert_called_once_with( + "gen_ai.user.message", attributes={"content": json.dumps(messages[0]["content"])} + ) + assert span is not None + + +def test_start_model_invoke_span_with_system_prompt_content(mock_tracer): + """Test that system_prompt_content takes priority over system_prompt string.""" + with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): + tracer = Tracer() + tracer.tracer = mock_tracer + + mock_span = mock.MagicMock() + mock_tracer.start_span.return_value = mock_span + + messages = [{"role": "user", "content": [{"text": "Hello"}]}] + system_prompt_content = [{"text": "You are helpful"}, {"text": "Be concise"}] + + span = tracer.start_model_invoke_span( + messages=messages, + model_id="test-model", + system_prompt="ignored string", + system_prompt_content=system_prompt_content, + ) + + calls = mock_span.add_event.call_args_list + assert len(calls) == 2 + assert calls[0] == mock.call( + "gen_ai.system.message", + attributes={"content": serialize(system_prompt_content)}, + ) + assert span is not None + + def test_end_model_invoke_span(mock_span): """Test ending a model invoke span.""" tracer = Tracer()