Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/strands/event_loop/event_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions src/strands/telemetry/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions tests/strands/event_loop/test_event_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
80 changes: 75 additions & 5 deletions tests/strands/telemetry/test_tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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


Expand All @@ -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"
Expand All @@ -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(
Expand All @@ -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()
Expand Down