13
13
EventLoggerProvider , # pyright: ignore[reportPrivateImportUsage]
14
14
get_event_logger_provider , # pyright: ignore[reportPrivateImportUsage]
15
15
)
16
+ from opentelemetry .metrics import MeterProvider , get_meter_provider
16
17
from opentelemetry .trace import Span , Tracer , TracerProvider , get_tracer_provider
17
18
from opentelemetry .util .types import AttributeValue
18
19
from pydantic import TypeAdapter
49
50
50
51
ANY_ADAPTER = TypeAdapter [Any ](Any )
51
52
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
+
52
57
53
58
def instrument_model (model : Model , instrument : InstrumentationSettings | bool ) -> Model :
54
59
"""Instrument a model with OpenTelemetry/logfire."""
@@ -84,6 +89,7 @@ def __init__(
84
89
* ,
85
90
event_mode : Literal ['attributes' , 'logs' ] = 'attributes' ,
86
91
tracer_provider : TracerProvider | None = None ,
92
+ meter_provider : MeterProvider | None = None ,
87
93
event_logger_provider : EventLoggerProvider | None = None ,
88
94
include_binary_content : bool = True ,
89
95
):
@@ -95,6 +101,9 @@ def __init__(
95
101
tracer_provider: The OpenTelemetry tracer provider to use.
96
102
If not provided, the global tracer provider is used.
97
103
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.
98
107
event_logger_provider: The OpenTelemetry event logger provider to use.
99
108
If not provided, the global event logger provider is used.
100
109
Calling `logfire.configure()` sets the global event logger provider, so most users don't need this.
@@ -104,12 +113,33 @@ def __init__(
104
113
from pydantic_ai import __version__
105
114
106
115
tracer_provider = tracer_provider or get_tracer_provider ()
116
+ meter_provider = meter_provider or get_meter_provider ()
107
117
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__ )
110
122
self .event_mode = event_mode
111
123
self .include_binary_content = include_binary_content
112
124
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
+
113
143
def messages_to_otel_events (self , messages : list [ModelMessage ]) -> list [Event ]:
114
144
"""Convert a list of model messages to OpenTelemetry events.
115
145
@@ -224,38 +254,74 @@ def _instrument(
224
254
if isinstance (value := model_settings .get (key ), (float , int )):
225
255
attributes [f'gen_ai.request.{ key } ' ] = value
226
256
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
+ )
243
304
)
305
+ span .set_attributes (
306
+ {
307
+ ** response .usage .opentelemetry_attributes (),
308
+ 'gen_ai.response.model' : response_model ,
309
+ }
244
310
)
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 ()
259
325
260
326
def _emit_events (self , span : Span , events : list [Event ]) -> None :
261
327
if self .settings .event_mode == 'logs' :
0 commit comments