1313 EventLoggerProvider , # pyright: ignore[reportPrivateImportUsage]
1414 get_event_logger_provider , # pyright: ignore[reportPrivateImportUsage]
1515)
16+ from opentelemetry .metrics import MeterProvider , get_meter_provider
1617from opentelemetry .trace import Span , Tracer , TracerProvider , get_tracer_provider
1718from opentelemetry .util .types import AttributeValue
1819from pydantic import TypeAdapter
4950
5051ANY_ADAPTER = TypeAdapter [Any ](Any )
5152
53+ # These are in the spec:
54+ # https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/#metric-gen_aiclienttokenusage
55+ TOKEN_HISTOGRAM_BOUNDARIES = (1 , 4 , 16 , 64 , 256 , 1024 , 4096 , 16384 , 65536 , 262144 , 1048576 , 4194304 , 16777216 , 67108864 )
56+
5257
5358def instrument_model (model : Model , instrument : InstrumentationSettings | bool ) -> Model :
5459 """Instrument a model with OpenTelemetry/logfire."""
@@ -84,6 +89,7 @@ def __init__(
8489 * ,
8590 event_mode : Literal ['attributes' , 'logs' ] = 'attributes' ,
8691 tracer_provider : TracerProvider | None = None ,
92+ meter_provider : MeterProvider | None = None ,
8793 event_logger_provider : EventLoggerProvider | None = None ,
8894 include_binary_content : bool = True ,
8995 ):
@@ -95,6 +101,9 @@ def __init__(
95101 tracer_provider: The OpenTelemetry tracer provider to use.
96102 If not provided, the global tracer provider is used.
97103 Calling `logfire.configure()` sets the global tracer provider, so most users don't need this.
104+ meter_provider: The OpenTelemetry meter provider to use.
105+ If not provided, the global meter provider is used.
106+ Calling `logfire.configure()` sets the global meter provider, so most users don't need this.
98107 event_logger_provider: The OpenTelemetry event logger provider to use.
99108 If not provided, the global event logger provider is used.
100109 Calling `logfire.configure()` sets the global event logger provider, so most users don't need this.
@@ -104,12 +113,33 @@ def __init__(
104113 from pydantic_ai import __version__
105114
106115 tracer_provider = tracer_provider or get_tracer_provider ()
116+ meter_provider = meter_provider or get_meter_provider ()
107117 event_logger_provider = event_logger_provider or get_event_logger_provider ()
108- self .tracer = tracer_provider .get_tracer ('pydantic-ai' , __version__ )
109- self .event_logger = event_logger_provider .get_event_logger ('pydantic-ai' , __version__ )
118+ scope_name = 'pydantic-ai'
119+ self .tracer = tracer_provider .get_tracer (scope_name , __version__ )
120+ self .meter = meter_provider .get_meter (scope_name , __version__ )
121+ self .event_logger = event_logger_provider .get_event_logger (scope_name , __version__ )
110122 self .event_mode = event_mode
111123 self .include_binary_content = include_binary_content
112124
125+ # As specified in the OpenTelemetry GenAI metrics spec:
126+ # https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/#metric-gen_aiclienttokenusage
127+ tokens_histogram_kwargs = dict (
128+ name = 'gen_ai.client.token.usage' ,
129+ unit = '{token}' ,
130+ description = 'Measures number of input and output tokens used' ,
131+ )
132+ try :
133+ self .tokens_histogram = self .meter .create_histogram (
134+ ** tokens_histogram_kwargs ,
135+ explicit_bucket_boundaries_advisory = TOKEN_HISTOGRAM_BOUNDARIES ,
136+ )
137+ except TypeError :
138+ # Older OTel/logfire versions don't support explicit_bucket_boundaries_advisory
139+ self .tokens_histogram = self .meter .create_histogram (
140+ ** tokens_histogram_kwargs , # pyright: ignore
141+ )
142+
113143 def messages_to_otel_events (self , messages : list [ModelMessage ]) -> list [Event ]:
114144 """Convert a list of model messages to OpenTelemetry events.
115145
@@ -224,38 +254,74 @@ def _instrument(
224254 if isinstance (value := model_settings .get (key ), (float , int )):
225255 attributes [f'gen_ai.request.{ key } ' ] = value
226256
227- with self .settings .tracer .start_as_current_span (span_name , attributes = attributes ) as span :
228-
229- def finish (response : ModelResponse ):
230- if not span .is_recording ():
231- return
232-
233- events = self .settings .messages_to_otel_events (messages )
234- for event in self .settings .messages_to_otel_events ([response ]):
235- events .append (
236- Event (
237- 'gen_ai.choice' ,
238- body = {
239- # TODO finish_reason
240- 'index' : 0 ,
241- 'message' : event .body ,
242- },
257+ record_metrics : Callable [[], None ] | None = None
258+ try :
259+ with self .settings .tracer .start_as_current_span (span_name , attributes = attributes ) as span :
260+
261+ def finish (response : ModelResponse ):
262+ # FallbackModel updates these span attributes.
263+ attributes .update (getattr (span , 'attributes' , {}))
264+ request_model = attributes [GEN_AI_REQUEST_MODEL_ATTRIBUTE ]
265+ system = attributes [GEN_AI_SYSTEM_ATTRIBUTE ]
266+
267+ response_model = response .model_name or request_model
268+
269+ def _record_metrics ():
270+ metric_attributes = {
271+ GEN_AI_SYSTEM_ATTRIBUTE : system ,
272+ 'gen_ai.operation.name' : operation ,
273+ 'gen_ai.request.model' : request_model ,
274+ 'gen_ai.response.model' : response_model ,
275+ }
276+ if response .usage .request_tokens : # pragma: no branch
277+ self .settings .tokens_histogram .record (
278+ response .usage .request_tokens ,
279+ {** metric_attributes , 'gen_ai.token.type' : 'input' },
280+ )
281+ if response .usage .response_tokens : # pragma: no branch
282+ self .settings .tokens_histogram .record (
283+ response .usage .response_tokens ,
284+ {** metric_attributes , 'gen_ai.token.type' : 'output' },
285+ )
286+
287+ nonlocal record_metrics
288+ record_metrics = _record_metrics
289+
290+ if not span .is_recording ():
291+ return
292+
293+ events = self .settings .messages_to_otel_events (messages )
294+ for event in self .settings .messages_to_otel_events ([response ]):
295+ events .append (
296+ Event (
297+ 'gen_ai.choice' ,
298+ body = {
299+ # TODO finish_reason
300+ 'index' : 0 ,
301+ 'message' : event .body ,
302+ },
303+ )
243304 )
305+ span .set_attributes (
306+ {
307+ ** response .usage .opentelemetry_attributes (),
308+ 'gen_ai.response.model' : response_model ,
309+ }
244310 )
245- new_attributes : dict [ str , AttributeValue ] = response . usage . opentelemetry_attributes () # pyright: ignore[reportAssignmentType]
246- attributes . update ( getattr ( span , 'attributes' , {}))
247- request_model = attributes [ GEN_AI_REQUEST_MODEL_ATTRIBUTE ]
248- new_attributes [ 'gen_ai.response.model' ] = response . model_name or request_model
249- span . set_attributes ( new_attributes )
250- span . update_name ( f' { operation } { request_model } ' )
251- for event in events :
252- event . attributes = {
253- GEN_AI_SYSTEM_ATTRIBUTE : attributes [ GEN_AI_SYSTEM_ATTRIBUTE ],
254- ** ( event . attributes or {}),
255- }
256- self . _emit_events ( span , events )
257-
258- yield finish
311+ span . update_name ( f' { operation } { request_model } ' )
312+ for event in events :
313+ event . attributes = {
314+ GEN_AI_SYSTEM_ATTRIBUTE : system ,
315+ ** ( event . attributes or {}),
316+ }
317+ self . _emit_events ( span , events )
318+
319+ yield finish
320+ finally :
321+ if record_metrics :
322+ # We only want to record metrics after the span is finished,
323+ # to prevent them from being redundantly recorded in the span itself by logfire.
324+ record_metrics ()
259325
260326 def _emit_events (self , span : Span , events : list [Event ]) -> None :
261327 if self .settings .event_mode == 'logs' :
0 commit comments