Skip to content

Commit 34bc07d

Browse files
authored
Add gen_ai.system_instructions attribute to agent run spans (#2699)
1 parent a2f0eab commit 34bc07d

File tree

3 files changed

+164
-13
lines changed

3 files changed

+164
-13
lines changed

pydantic_ai_slim/pydantic_ai/agent/__init__.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -678,22 +678,28 @@ def _run_span_end_attributes(
678678
self, state: _agent_graph.GraphAgentState, usage: _usage.RunUsage, settings: InstrumentationSettings
679679
):
680680
if settings.version == 1:
681-
attr_name = 'all_messages_events'
682-
value = [
683-
InstrumentedModel.event_to_dict(e) for e in settings.messages_to_otel_events(state.message_history)
684-
]
681+
attrs = {
682+
'all_messages_events': json.dumps(
683+
[
684+
InstrumentedModel.event_to_dict(e)
685+
for e in settings.messages_to_otel_events(state.message_history)
686+
]
687+
)
688+
}
685689
else:
686-
attr_name = 'pydantic_ai.all_messages'
687-
value = settings.messages_to_otel_messages(state.message_history)
690+
attrs = {
691+
'pydantic_ai.all_messages': json.dumps(settings.messages_to_otel_messages(state.message_history)),
692+
**settings.system_instructions_attributes(self._instructions),
693+
}
688694

689695
return {
690696
**usage.opentelemetry_attributes(),
691-
attr_name: json.dumps(value),
697+
**attrs,
692698
'logfire.json_schema': json.dumps(
693699
{
694700
'type': 'object',
695701
'properties': {
696-
attr_name: {'type': 'array'},
702+
**{attr: {'type': 'array'} for attr in attrs.keys()},
697703
'final_result': {'type': 'object'},
698704
},
699705
}

pydantic_ai_slim/pydantic_ai/models/instrumented.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -236,27 +236,36 @@ def handle_messages(self, input_messages: list[ModelMessage], response: ModelRes
236236
if response.provider_details and 'finish_reason' in response.provider_details:
237237
output_message['finish_reason'] = response.provider_details['finish_reason']
238238
instructions = InstrumentedModel._get_instructions(input_messages) # pyright: ignore [reportPrivateUsage]
239+
system_instructions_attributes = self.system_instructions_attributes(instructions)
239240
attributes = {
240241
'gen_ai.input.messages': json.dumps(self.messages_to_otel_messages(input_messages)),
241242
'gen_ai.output.messages': json.dumps([output_message]),
243+
**system_instructions_attributes,
242244
'logfire.json_schema': json.dumps(
243245
{
244246
'type': 'object',
245247
'properties': {
246248
'gen_ai.input.messages': {'type': 'array'},
247249
'gen_ai.output.messages': {'type': 'array'},
248-
**({'gen_ai.system_instructions': {'type': 'array'}} if instructions else {}),
250+
**(
251+
{'gen_ai.system_instructions': {'type': 'array'}}
252+
if system_instructions_attributes
253+
else {}
254+
),
249255
'model_request_parameters': {'type': 'object'},
250256
},
251257
}
252258
),
253259
}
254-
if instructions is not None:
255-
attributes['gen_ai.system_instructions'] = json.dumps(
256-
[_otel_messages.TextPart(type='text', content=instructions)]
257-
)
258260
span.set_attributes(attributes)
259261

262+
def system_instructions_attributes(self, instructions: str | None) -> dict[str, str]:
263+
if instructions and self.include_content:
264+
return {
265+
'gen_ai.system_instructions': json.dumps([_otel_messages.TextPart(type='text', content=instructions)]),
266+
}
267+
return {}
268+
260269
def _emit_events(self, span: Span, events: list[Event]) -> None:
261270
if self.event_mode == 'logs':
262271
for event in events:

tests/test_logfire.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,12 +551,14 @@ class MyOutput:
551551
)
552552
),
553553
'final_result': '{"content": "a"}',
554+
'gen_ai.system_instructions': '[{"type": "text", "content": "Here are some instructions"}]',
554555
'logfire.json_schema': IsJson(
555556
snapshot(
556557
{
557558
'type': 'object',
558559
'properties': {
559560
'pydantic_ai.all_messages': {'type': 'array'},
561+
'gen_ai.system_instructions': {'type': 'array'},
560562
'final_result': {'type': 'object'},
561563
},
562564
}
@@ -692,6 +694,140 @@ class MyOutput:
692694
)
693695

694696

697+
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
698+
def test_instructions_with_structured_output_exclude_content_v2(
699+
get_logfire_summary: Callable[[], LogfireSummary],
700+
) -> None:
701+
@dataclass
702+
class MyOutput:
703+
content: str
704+
705+
settings: InstrumentationSettings = InstrumentationSettings(include_content=False, version=2)
706+
707+
my_agent = Agent(model=TestModel(), instructions='Here are some instructions', instrument=settings)
708+
709+
result = my_agent.run_sync('Hello', output_type=MyOutput)
710+
assert result.output == snapshot(MyOutput(content='a'))
711+
712+
summary = get_logfire_summary()
713+
assert summary.attributes[0] == snapshot(
714+
{
715+
'model_name': 'test',
716+
'agent_name': 'my_agent',
717+
'logfire.msg': 'my_agent run',
718+
'logfire.span_type': 'span',
719+
'gen_ai.usage.input_tokens': 51,
720+
'gen_ai.usage.output_tokens': 5,
721+
'pydantic_ai.all_messages': IsJson(
722+
snapshot(
723+
[
724+
{'role': 'user', 'parts': [{'type': 'text'}]},
725+
{
726+
'role': 'assistant',
727+
'parts': [
728+
{
729+
'type': 'tool_call',
730+
'id': IsStr(),
731+
'name': 'final_result',
732+
}
733+
],
734+
},
735+
{
736+
'role': 'user',
737+
'parts': [
738+
{
739+
'type': 'tool_call_response',
740+
'id': IsStr(),
741+
'name': 'final_result',
742+
}
743+
],
744+
},
745+
]
746+
)
747+
),
748+
'logfire.json_schema': IsJson(
749+
snapshot(
750+
{
751+
'type': 'object',
752+
'properties': {
753+
'pydantic_ai.all_messages': {'type': 'array'},
754+
'final_result': {'type': 'object'},
755+
},
756+
}
757+
)
758+
),
759+
}
760+
)
761+
chat_span_attributes = summary.attributes[1]
762+
assert chat_span_attributes == snapshot(
763+
{
764+
'gen_ai.operation.name': 'chat',
765+
'gen_ai.system': 'test',
766+
'gen_ai.request.model': 'test',
767+
'model_request_parameters': IsJson(
768+
snapshot(
769+
{
770+
'function_tools': [],
771+
'builtin_tools': [],
772+
'output_mode': 'tool',
773+
'output_object': None,
774+
'output_tools': [
775+
{
776+
'name': 'final_result',
777+
'parameters_json_schema': {
778+
'properties': {'content': {'type': 'string'}},
779+
'required': ['content'],
780+
'title': 'MyOutput',
781+
'type': 'object',
782+
},
783+
'description': 'The final response which ends this conversation',
784+
'outer_typed_dict_key': None,
785+
'strict': None,
786+
'kind': 'output',
787+
}
788+
],
789+
'allow_text_output': False,
790+
}
791+
)
792+
),
793+
'logfire.span_type': 'span',
794+
'logfire.msg': 'chat test',
795+
'gen_ai.input.messages': IsJson(snapshot([{'role': 'user', 'parts': [{'type': 'text'}]}])),
796+
'gen_ai.output.messages': IsJson(
797+
snapshot(
798+
[
799+
{
800+
'role': 'assistant',
801+
'parts': [
802+
{
803+
'type': 'tool_call',
804+
'id': IsStr(),
805+
'name': 'final_result',
806+
}
807+
],
808+
}
809+
]
810+
)
811+
),
812+
'logfire.json_schema': IsJson(
813+
snapshot(
814+
{
815+
'type': 'object',
816+
'properties': {
817+
'gen_ai.input.messages': {'type': 'array'},
818+
'gen_ai.output.messages': {'type': 'array'},
819+
'model_request_parameters': {'type': 'object'},
820+
},
821+
}
822+
)
823+
),
824+
'gen_ai.usage.input_tokens': 51,
825+
'gen_ai.usage.output_tokens': 5,
826+
'gen_ai.response.model': 'test',
827+
}
828+
)
829+
830+
695831
def test_instrument_all():
696832
model = TestModel()
697833
agent = Agent()

0 commit comments

Comments
 (0)