Skip to content

Commit 194c69b

Browse files
sanjeed5lizradway
andauthored
feat(telemetry): emit system prompt on chat spans per GenAI semconv (#1818)
Co-authored-by: sanjeed5 <sanjeed5@users.noreply.github.com> Co-authored-by: Liz <91279165+lizradway@users.noreply.github.com>
1 parent 521c4d7 commit 194c69b

File tree

4 files changed

+123
-5
lines changed

4 files changed

+123
-5
lines changed

src/strands/event_loop/event_loop.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,8 @@ async def _handle_model_execution(
313313
parent_span=cycle_span,
314314
model_id=model_id,
315315
custom_trace_attributes=agent.trace_attributes,
316+
system_prompt=agent.system_prompt,
317+
system_prompt_content=agent._system_prompt_content,
316318
)
317319
with trace_api.use_span(model_invoke_span, end_on_exit=True):
318320
await agent.hooks.invoke_callbacks_async(

src/strands/telemetry/tracer.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,8 @@ def start_model_invoke_span(
285285
parent_span: Span | None = None,
286286
model_id: str | None = None,
287287
custom_trace_attributes: Mapping[str, AttributeValue] | None = None,
288+
system_prompt: str | None = None,
289+
system_prompt_content: list | None = None,
288290
**kwargs: Any,
289291
) -> Span:
290292
"""Start a new span for a model invocation.
@@ -294,6 +296,8 @@ def start_model_invoke_span(
294296
parent_span: Optional parent span to link this span to.
295297
model_id: Optional identifier for the model being invoked.
296298
custom_trace_attributes: Optional mapping of custom trace attributes to include in the span.
299+
system_prompt: Optional system prompt string provided to the model.
300+
system_prompt_content: Optional list of system prompt content blocks.
297301
**kwargs: Additional attributes to add to the span.
298302
299303
Returns:
@@ -311,6 +315,7 @@ def start_model_invoke_span(
311315
attributes.update({k: v for k, v in kwargs.items() if isinstance(v, (str, int, float, bool))})
312316

313317
span = self._start_span("chat", parent_span, attributes=attributes, span_kind=trace_api.SpanKind.INTERNAL)
318+
self._add_system_prompt_event(span, system_prompt, system_prompt_content)
314319
self._add_event_messages(span, messages)
315320

316321
return span
@@ -813,6 +818,44 @@ def _get_common_attributes(
813818
)
814819
return dict(common_attributes)
815820

821+
def _add_system_prompt_event(
822+
self,
823+
span: Span,
824+
system_prompt: str | None = None,
825+
system_prompt_content: list | None = None,
826+
) -> None:
827+
"""Emit system prompt as a span event per OTel GenAI semantic conventions.
828+
829+
In legacy mode (v1.36), emits a ``gen_ai.system.message`` event.
830+
In latest experimental mode, emits ``gen_ai.system_instructions`` on the
831+
``gen_ai.client.inference.operation.details`` event, since Strands passes
832+
system instructions separately from chat history.
833+
834+
Args:
835+
span: The span to add the event to.
836+
system_prompt: Optional system prompt string.
837+
system_prompt_content: Optional list of system prompt content blocks.
838+
"""
839+
if system_prompt is None and system_prompt_content is None:
840+
return
841+
842+
content_blocks = system_prompt_content if system_prompt_content else [{"text": system_prompt}]
843+
844+
if self.use_latest_genai_conventions:
845+
parts = self._map_content_blocks_to_otel_parts(content_blocks)
846+
self._add_event(
847+
span,
848+
"gen_ai.client.inference.operation.details",
849+
{"gen_ai.system_instructions": serialize(parts)},
850+
to_span_attributes=self.is_langfuse,
851+
)
852+
else:
853+
self._add_event(
854+
span,
855+
"gen_ai.system.message",
856+
{"content": serialize(content_blocks)},
857+
)
858+
816859
def _add_event_messages(self, span: Span, messages: Messages) -> None:
817860
"""Adds messages as event to the provided span based on the current GenAI conventions.
818861

tests/strands/event_loop/test_event_loop.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,9 @@ async def test_event_loop_cycle_creates_spans(
547547
mock_get_tracer.assert_called_once()
548548
mock_tracer.start_event_loop_cycle_span.assert_called_once()
549549
mock_tracer.start_model_invoke_span.assert_called_once()
550+
call_kwargs = mock_tracer.start_model_invoke_span.call_args[1]
551+
assert call_kwargs["system_prompt"] == agent.system_prompt
552+
assert call_kwargs["system_prompt_content"] == agent._system_prompt_content
550553
mock_tracer.end_model_invoke_span.assert_called_once()
551554
mock_tracer.end_event_loop_cycle_span.assert_called_once()
552555

tests/strands/telemetry/test_tracer.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,14 @@ def test_start_model_invoke_span(mock_tracer):
140140
messages = [{"role": "user", "content": [{"text": "Hello"}]}]
141141
model_id = "test-model"
142142
custom_attrs = {"custom_key": "custom_value", "user_id": "12345"}
143+
system_prompt = "You are a helpful assistant"
143144

144145
span = tracer.start_model_invoke_span(
145-
messages=messages, agent_name="TestAgent", model_id=model_id, custom_trace_attributes=custom_attrs
146+
messages=messages,
147+
agent_name="TestAgent",
148+
model_id=model_id,
149+
custom_trace_attributes=custom_attrs,
150+
system_prompt=system_prompt,
146151
)
147152

148153
mock_tracer.start_span.assert_called_once()
@@ -158,9 +163,14 @@ def test_start_model_invoke_span(mock_tracer):
158163
"agent_name": "TestAgent",
159164
}
160165
)
161-
mock_span.add_event.assert_called_with(
162-
"gen_ai.user.message", attributes={"content": json.dumps(messages[0]["content"])}
166+
167+
calls = mock_span.add_event.call_args_list
168+
assert len(calls) == 2
169+
assert calls[0] == mock.call(
170+
"gen_ai.system.message",
171+
attributes={"content": serialize([{"text": system_prompt}])},
163172
)
173+
assert calls[1] == mock.call("gen_ai.user.message", attributes={"content": json.dumps(messages[0]["content"])})
164174
assert span is not None
165175

166176

@@ -184,8 +194,11 @@ def test_start_model_invoke_span_latest_conventions(mock_tracer, monkeypatch):
184194
},
185195
]
186196
model_id = "test-model"
197+
system_prompt = "You are a calculator assistant"
187198

188-
span = tracer.start_model_invoke_span(messages=messages, agent_name="TestAgent", model_id=model_id)
199+
span = tracer.start_model_invoke_span(
200+
messages=messages, agent_name="TestAgent", model_id=model_id, system_prompt=system_prompt
201+
)
189202

190203
mock_tracer.start_span.assert_called_once()
191204
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):
199212
"agent_name": "TestAgent",
200213
}
201214
)
202-
mock_span.add_event.assert_called_with(
215+
216+
calls = mock_span.add_event.call_args_list
217+
assert len(calls) == 2
218+
assert calls[0] == mock.call(
219+
"gen_ai.client.inference.operation.details",
220+
attributes={
221+
"gen_ai.system_instructions": serialize([{"type": "text", "content": system_prompt}]),
222+
},
223+
)
224+
assert calls[1] == mock.call(
203225
"gen_ai.client.inference.operation.details",
204226
attributes={
205227
"gen_ai.input.messages": serialize(
@@ -226,6 +248,54 @@ def test_start_model_invoke_span_latest_conventions(mock_tracer, monkeypatch):
226248
assert span is not None
227249

228250

251+
def test_start_model_invoke_span_without_system_prompt(mock_tracer):
252+
"""Test that no system prompt event is emitted when system_prompt is None."""
253+
with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer):
254+
tracer = Tracer()
255+
tracer.tracer = mock_tracer
256+
257+
mock_span = mock.MagicMock()
258+
mock_tracer.start_span.return_value = mock_span
259+
260+
messages = [{"role": "user", "content": [{"text": "Hello"}]}]
261+
262+
span = tracer.start_model_invoke_span(messages=messages, model_id="test-model")
263+
264+
assert mock_span.add_event.call_count == 1
265+
mock_span.add_event.assert_called_once_with(
266+
"gen_ai.user.message", attributes={"content": json.dumps(messages[0]["content"])}
267+
)
268+
assert span is not None
269+
270+
271+
def test_start_model_invoke_span_with_system_prompt_content(mock_tracer):
272+
"""Test that system_prompt_content takes priority over system_prompt string."""
273+
with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer):
274+
tracer = Tracer()
275+
tracer.tracer = mock_tracer
276+
277+
mock_span = mock.MagicMock()
278+
mock_tracer.start_span.return_value = mock_span
279+
280+
messages = [{"role": "user", "content": [{"text": "Hello"}]}]
281+
system_prompt_content = [{"text": "You are helpful"}, {"text": "Be concise"}]
282+
283+
span = tracer.start_model_invoke_span(
284+
messages=messages,
285+
model_id="test-model",
286+
system_prompt="ignored string",
287+
system_prompt_content=system_prompt_content,
288+
)
289+
290+
calls = mock_span.add_event.call_args_list
291+
assert len(calls) == 2
292+
assert calls[0] == mock.call(
293+
"gen_ai.system.message",
294+
attributes={"content": serialize(system_prompt_content)},
295+
)
296+
assert span is not None
297+
298+
229299
def test_end_model_invoke_span(mock_span):
230300
"""Test ending a model invoke span."""
231301
tracer = Tracer()

0 commit comments

Comments
 (0)