Skip to content

Commit 11687ce

Browse files
committed
Add metadata to the Agent class.
Metadata is attached to the logfire.agent.metadata span attribute. It can either be a string, dict, or a callable taking the RunContext and returning a string or a dict.
1 parent 359c6d2 commit 11687ce

File tree

3 files changed

+111
-3
lines changed

3 files changed

+111
-3
lines changed

docs/logfire.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ including how to instrument other libraries like [HTTPX](https://logfire.pydanti
9292

9393
Since Logfire is built on [OpenTelemetry](https://opentelemetry.io/), you can use the Logfire Python SDK to send data to any OpenTelemetry collector, see [below](#using-opentelemetry).
9494

95+
When instrumentation is enabled, the resolved metadata is recorded (JSON encoded) on the run span under the `logfire.agent.metadata` attribute.
96+
9597
### Debugging
9698

9799
To demonstrate how Logfire can let you visualise the flow of a Pydantic AI run, here's the view you get from Logfire while running the [chat app examples](examples/chat-app.md):
@@ -356,3 +358,18 @@ Agent.instrument_all(instrumentation_settings)
356358
```
357359

358360
This setting is particularly useful in production environments where compliance requirements or data sensitivity concerns make it necessary to limit what content is sent to your observability platform.
361+
362+
### Adding Custom Metadata
363+
364+
Use the agent's `metadata` parameter to attach additional data to the agent's span.
365+
Metadata can be provided as a string, a dictionary, or a callable that reads the [`RunContext`][pydantic_ai.tools.RunContext] to compute values on each run.
366+
367+
```python {hl_lines="4-5"}
368+
from pydantic_ai import Agent
369+
370+
agent = Agent(
371+
'openai:gpt-5',
372+
instrument=True,
373+
metadata=lambda ctx: {'deployment': 'staging', 'tenant': ctx.deps.tenant},
374+
)
375+
```

pydantic_ai_slim/pydantic_ai/agent/__init__.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
HistoryProcessor,
3333
ModelRequestNode,
3434
UserPromptNode,
35+
build_run_context,
3536
capture_run_messages,
3637
)
3738
from .._output import OutputToolset
@@ -89,6 +90,8 @@
8990
S = TypeVar('S')
9091
NoneType = type(None)
9192

93+
AgentMetadataValue = str | dict[str, str] | Callable[[RunContext[AgentDepsT]], str | dict[str, str]]
94+
9295

9396
@dataclasses.dataclass(init=False)
9497
class Agent(AbstractAgent[AgentDepsT, OutputDataT]):
@@ -130,6 +133,7 @@ class Agent(AbstractAgent[AgentDepsT, OutputDataT]):
130133
"""Options to automatically instrument with OpenTelemetry."""
131134

132135
_instrument_default: ClassVar[InstrumentationSettings | bool] = False
136+
_metadata: AgentMetadataValue[AgentDepsT] | None = dataclasses.field(repr=False)
133137

134138
_deps_type: type[AgentDepsT] = dataclasses.field(repr=False)
135139
_output_schema: _output.OutputSchema[OutputDataT] = dataclasses.field(repr=False)
@@ -175,6 +179,7 @@ def __init__(
175179
defer_model_check: bool = False,
176180
end_strategy: EndStrategy = 'early',
177181
instrument: InstrumentationSettings | bool | None = None,
182+
metadata: AgentMetadataValue[AgentDepsT] | None = None,
178183
history_processors: Sequence[HistoryProcessor[AgentDepsT]] | None = None,
179184
event_stream_handler: EventStreamHandler[AgentDepsT] | None = None,
180185
) -> None: ...
@@ -201,6 +206,7 @@ def __init__(
201206
defer_model_check: bool = False,
202207
end_strategy: EndStrategy = 'early',
203208
instrument: InstrumentationSettings | bool | None = None,
209+
metadata: AgentMetadataValue[AgentDepsT] | None = None,
204210
history_processors: Sequence[HistoryProcessor[AgentDepsT]] | None = None,
205211
event_stream_handler: EventStreamHandler[AgentDepsT] | None = None,
206212
) -> None: ...
@@ -225,6 +231,7 @@ def __init__(
225231
defer_model_check: bool = False,
226232
end_strategy: EndStrategy = 'early',
227233
instrument: InstrumentationSettings | bool | None = None,
234+
metadata: AgentMetadataValue[AgentDepsT] | None = None,
228235
history_processors: Sequence[HistoryProcessor[AgentDepsT]] | None = None,
229236
event_stream_handler: EventStreamHandler[AgentDepsT] | None = None,
230237
**_deprecated_kwargs: Any,
@@ -276,6 +283,10 @@ def __init__(
276283
[`Agent.instrument_all()`][pydantic_ai.Agent.instrument_all]
277284
will be used, which defaults to False.
278285
See the [Debugging and Monitoring guide](https://ai.pydantic.dev/logfire/) for more info.
286+
metadata: Optional metadata to attach to telemetry for this agent.
287+
Provide a string literal, a dict of string keys and values, or a callable returning one of those values
288+
computed from the [`RunContext`][pydantic_ai.tools.RunContext] on each run.
289+
Metadata is only recorded when instrumentation is enabled.
279290
history_processors: Optional list of callables to process the message history before sending it to the model.
280291
Each processor takes a list of messages and returns a modified list of messages.
281292
Processors can be sync or async and are applied in sequence.
@@ -292,6 +303,7 @@ def __init__(
292303

293304
self._output_type = output_type
294305
self.instrument = instrument
306+
self._metadata = metadata
295307
self._deps_type = deps_type
296308

297309
if mcp_servers := _deprecated_kwargs.pop('mcp_servers', None):
@@ -349,6 +361,9 @@ def __init__(
349361
self._override_instructions: ContextVar[
350362
_utils.Option[list[str | _system_prompt.SystemPromptFunc[AgentDepsT]]]
351363
] = ContextVar('_override_instructions', default=None)
364+
self._override_metadata: ContextVar[_utils.Option[AgentMetadataValue[AgentDepsT]]] = ContextVar(
365+
'_override_metadata', default=None
366+
)
352367

353368
self._enter_lock = Lock()
354369
self._entered_count = 0
@@ -645,6 +660,7 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None:
645660
},
646661
)
647662

663+
run_metadata: str | dict[str, str] | None = None
648664
try:
649665
async with graph.iter(
650666
inputs=user_prompt_node,
@@ -656,8 +672,9 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None:
656672
async with toolset:
657673
agent_run = AgentRun(graph_run)
658674
yield agent_run
659-
if (final_result := agent_run.result) is not None and run_span.is_recording():
660-
if instrumentation_settings and instrumentation_settings.include_content:
675+
if instrumentation_settings and run_span.is_recording():
676+
run_metadata = self._compute_agent_metadata(build_run_context(agent_run.ctx))
677+
if instrumentation_settings.include_content and (final_result := agent_run.result) is not None:
661678
run_span.set_attribute(
662679
'final_result',
663680
(
@@ -671,18 +688,32 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None:
671688
if instrumentation_settings and run_span.is_recording():
672689
run_span.set_attributes(
673690
self._run_span_end_attributes(
674-
instrumentation_settings, usage, state.message_history, graph_deps.new_message_index
691+
instrumentation_settings,
692+
usage,
693+
state.message_history,
694+
graph_deps.new_message_index,
695+
run_metadata,
675696
)
676697
)
677698
finally:
678699
run_span.end()
679700

701+
def _compute_agent_metadata(self, ctx: RunContext[AgentDepsT]) -> str | dict[str, str] | None:
702+
metadata_override = self._override_metadata.get()
703+
metadata_config = metadata_override.value if metadata_override is not None else self._metadata
704+
if metadata_config is None:
705+
return None
706+
707+
metadata = metadata_config(ctx) if callable(metadata_config) else metadata_config
708+
return metadata
709+
680710
def _run_span_end_attributes(
681711
self,
682712
settings: InstrumentationSettings,
683713
usage: _usage.RunUsage,
684714
message_history: list[_messages.ModelMessage],
685715
new_message_index: int,
716+
metadata: str | dict[str, str] | None = None,
686717
):
687718
if settings.version == 1:
688719
attrs = {
@@ -716,6 +747,12 @@ def _run_span_end_attributes(
716747
):
717748
attrs['pydantic_ai.variable_instructions'] = True
718749

750+
if metadata is not None:
751+
if isinstance(metadata, dict):
752+
attrs['logfire.agent.metadata'] = json.dumps(metadata)
753+
else:
754+
attrs['logfire.agent.metadata'] = metadata
755+
719756
return {
720757
**usage.opentelemetry_attributes(),
721758
**attrs,
@@ -740,6 +777,7 @@ def override(
740777
toolsets: Sequence[AbstractToolset[AgentDepsT]] | _utils.Unset = _utils.UNSET,
741778
tools: Sequence[Tool[AgentDepsT] | ToolFuncEither[AgentDepsT, ...]] | _utils.Unset = _utils.UNSET,
742779
instructions: Instructions[AgentDepsT] | _utils.Unset = _utils.UNSET,
780+
metadata: AgentMetadataValue[AgentDepsT] | _utils.Unset = _utils.UNSET,
743781
) -> Iterator[None]:
744782
"""Context manager to temporarily override agent name, dependencies, model, toolsets, tools, or instructions.
745783
@@ -753,6 +791,7 @@ def override(
753791
toolsets: The toolsets to use instead of the toolsets passed to the agent constructor and agent run.
754792
tools: The tools to use instead of the tools registered with the agent.
755793
instructions: The instructions to use instead of the instructions registered with the agent.
794+
metadata: The metadata to use instead of the metadata passed to the agent constructor.
756795
"""
757796
if _utils.is_set(name):
758797
name_token = self._override_name.set(_utils.Some(name))
@@ -785,6 +824,11 @@ def override(
785824
else:
786825
instructions_token = None
787826

827+
if _utils.is_set(metadata):
828+
metadata_token = self._override_metadata.set(_utils.Some(metadata))
829+
else:
830+
metadata_token = None
831+
788832
try:
789833
yield
790834
finally:
@@ -800,6 +844,8 @@ def override(
800844
self._override_tools.reset(tools_token)
801845
if instructions_token is not None:
802846
self._override_instructions.reset(instructions_token)
847+
if metadata_token is not None:
848+
self._override_metadata.reset(metadata_token)
803849

804850
@overload
805851
def instructions(

tests/test_logfire.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ async def my_ret(x: int) -> str:
120120
model=TestModel(),
121121
toolsets=[toolset],
122122
instrument=instrument,
123+
metadata={'env': 'test'},
123124
)
124125

125126
result = my_agent.run_sync('Hello')
@@ -314,12 +315,14 @@ async def my_ret(x: int) -> str:
314315
]
315316
)
316317
),
318+
'logfire.agent.metadata': '{"env": "test"}',
317319
'logfire.json_schema': IsJson(
318320
snapshot(
319321
{
320322
'type': 'object',
321323
'properties': {
322324
'pydantic_ai.all_messages': {'type': 'array'},
325+
'logfire.agent.metadata': {'type': 'array'},
323326
'final_result': {'type': 'object'},
324327
},
325328
}
@@ -379,12 +382,14 @@ async def my_ret(x: int) -> str:
379382
)
380383
),
381384
'final_result': '{"my_ret":"1"}',
385+
'logfire.agent.metadata': '{"env": "test"}',
382386
'logfire.json_schema': IsJson(
383387
snapshot(
384388
{
385389
'type': 'object',
386390
'properties': {
387391
'all_messages_events': {'type': 'array'},
392+
'logfire.agent.metadata': {'type': 'array'},
388393
'final_result': {'type': 'object'},
389394
},
390395
}
@@ -569,6 +574,46 @@ async def my_ret(x: int) -> str:
569574
)
570575

571576

577+
def _test_logfire_metadata_values_callable_dict(ctx: RunContext[Any]) -> dict[str, str]:
578+
return {'model_name': ctx.model.model_name}
579+
580+
581+
def _test_logfire_metadata_values_callable_string(_ctx: RunContext[Any]) -> str:
582+
return 'callable-str'
583+
584+
585+
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
586+
@pytest.mark.parametrize(
587+
('metadata', 'expected'),
588+
[
589+
pytest.param({'env': 'test'}, '{"env": "test"}', id='dict'),
590+
pytest.param('staging', 'staging', id='literal-string'),
591+
pytest.param(_test_logfire_metadata_values_callable_dict, '{"model_name": "test"}', id='callable-dict'),
592+
pytest.param(_test_logfire_metadata_values_callable_string, 'callable-str', id='callable-string'),
593+
],
594+
)
595+
def test_logfire_metadata_values(
596+
get_logfire_summary: Callable[[], LogfireSummary],
597+
metadata: str | dict[str, str] | Callable[[RunContext[Any]], str | dict[str, str]],
598+
expected: str | dict[str, str],
599+
) -> None:
600+
agent = Agent(model=TestModel(), instrument=InstrumentationSettings(version=2), metadata=metadata)
601+
agent.run_sync('Hello')
602+
603+
summary = get_logfire_summary()
604+
assert summary.attributes[0]['logfire.agent.metadata'] == expected
605+
606+
607+
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
608+
def test_logfire_metadata_override(get_logfire_summary: Callable[[], LogfireSummary]) -> None:
609+
agent = Agent(model=TestModel(), instrument=InstrumentationSettings(version=2), metadata='base')
610+
with agent.override(metadata={'env': 'override'}):
611+
agent.run_sync('Hello')
612+
613+
summary = get_logfire_summary()
614+
assert summary.attributes[0]['logfire.agent.metadata'] == '{"env": "override"}'
615+
616+
572617
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
573618
@pytest.mark.parametrize(
574619
'instrument',

0 commit comments

Comments
 (0)