1010
1111from dataclasses import dataclass
1212from datetime import datetime
13- from typing import Any , Final , FrozenSet , Mapping , Optional
13+ from typing import Any , Final , FrozenSet , Mapping , Optional , TypeAlias
1414
1515from opentelemetry .metrics import Histogram , Meter
1616
1717import litellm
18+ from litellm ._logging import verbose_logger
1819from litellm .integrations .opentelemetry import (
1920 METRIC_METADATA_KEYS ,
2021 TOKEN_TYPE_ATTRIBUTE ,
@@ -72,20 +73,29 @@ def create_genai_metrics(meter: Meter) -> GenAIMetrics:
7273 )
7374
7475
76+ # A metric datapoint's attributes. Values are the strings the recorder builds, except
77+ # the request model, which is whatever the caller passed and may be absent.
78+ MetricAttributes : TypeAlias = Mapping [str , "str | None" ]
79+
7580ERROR_TYPE_FALLBACK : Final = "_OTHER"
7681
77- # The attributes a failure datapoint may carry: what operation failed, and whose
78- # key/team/user it was. Every one is either a fixed enum or an operator-provisioned
79- # identifier, so the failure series count is bounded by the deployment's own
80- # key/team/user count. Deliberately excluded is everything the *client* supplies or
81- # that varies per request -- ``metadata.requester_metadata``,
82- # ``metadata.spend_logs_metadata``, ``metadata.user_api_key_end_user_id``,
83- # ``metadata.requester_ip_address`` and the ``hidden_params`` blob (which carries the
84- # provider's per-request response headers). A failed request costs the caller no
85- # provider spend, so nothing would rate-limit a caller who minted a fresh series per
86- # request out of a field they control. ``metadata.user_api_key_user_email`` is left
87- # out too: it is bounded, but it is PII duplicating the user id already here.
88- FAILURE_ATTRIBUTE_KEYS : Final [frozenset [str ]] = frozenset (
82+ # Every attribute a metric datapoint may carry, on either path. A label value that
83+ # is unique per request is a new time series that will never be written to again, so
84+ # this set is what keeps the series count bounded by the deployment's own
85+ # key/team/user/deployment count rather than by its traffic. Each entry is a fixed
86+ # enum or an operator-provisioned identifier.
87+ #
88+ # Deliberately excluded is everything the *client* supplies or that moves per
89+ # request: ``metadata.requester_metadata`` and ``metadata.spend_logs_metadata`` (both
90+ # free-form from the request body), ``metadata.user_api_key_end_user_id`` (the body's
91+ # ``user`` field), and ``metadata.requester_ip_address``. Those stay on the span,
92+ # where cardinality is free and where they already are.
93+ # ``metadata.user_api_key_user_email`` is left out too: it is bounded, but it is PII
94+ # duplicating the user id already here.
95+ #
96+ # This is a CEILING, applied before the operator's own include/exclude filter, so an
97+ # operator can narrow it but never widen it back to an unbounded attribute.
98+ METRIC_ATTRIBUTE_CEILING : Final [frozenset [str ]] = frozenset (
8999 (
90100 "gen_ai.operation.name" ,
91101 "gen_ai.system" ,
@@ -97,9 +107,23 @@ def create_genai_metrics(meter: Meter) -> GenAIMetrics:
97107 "metadata.user_api_key_team_alias" ,
98108 "metadata.user_api_key_org_id" ,
99109 "metadata.user_api_key_user_id" ,
110+ "hidden_params" ,
100111 )
101112)
102113
114+ # The only ``hidden_params`` field that becomes part of the ``hidden_params`` label.
115+ # The object as a whole is per-request by construction -- ``response_cost``,
116+ # ``litellm_overhead_time_ms``, ``cache_key``, ``usage_object`` and the provider's
117+ # ``additional_headers`` rate-limit counters all move on every call -- so dumping it
118+ # whole made one series per request out of every instrument.
119+ #
120+ # ``model_id`` is the router's own deployment id, so it is bounded by the deployment
121+ # list and is what a per-deployment panel joins on. ``api_base`` is deliberately NOT
122+ # here even though it names the same thing: it is a documented per-call parameter, so
123+ # in SDK use it is chosen by the caller rather than provisioned by the operator, and a
124+ # caller varying it would put the per-request cardinality straight back.
125+ BOUNDED_HIDDEN_PARAM_KEYS : Final [tuple [str , ...]] = ("model_id" ,)
126+
103127
104128def resolve_error_type (kwargs : Mapping [str , Any ]) -> str :
105129 """The ``error.type`` value for a failed request.
@@ -147,7 +171,7 @@ def record(
147171 start_time : datetime ,
148172 end_time : datetime ,
149173 ) -> None :
150- common_attrs = self ._filter_attributes (self ._common_attributes (kwargs ))
174+ common_attrs = self ._filter_attributes (self ._bounded_attributes (kwargs ))
151175 duration_s = (end_time - start_time ).total_seconds ()
152176
153177 self ._metrics .operation_duration .record (duration_s , attributes = common_attrs )
@@ -177,19 +201,18 @@ def record_failure(
177201 zeroes ``response_cost`` on failure. Recording them anyway would put a
178202 fabricated zero into series that dashboards average.
179203
180- The attribute set is the bounded ``FAILURE_ATTRIBUTE_KEYS`` allowlist, not
181- the success path's full set: a failure needs no provider spend, so a caller
182- who can put a unique value into a client-supplied attribute could mint one
183- histogram series per request for free. The operator's own filter still
184- applies on top, so it can narrow this further but never widen it .
204+ The attribute set is :data:`METRIC_ATTRIBUTE_CEILING`, the same cap the
205+ success path uses. A failure needs no provider spend, so a caller who can put
206+ a unique value into a client-supplied attribute could mint one histogram
207+ series per request for free; the cap is what makes that impossible on either
208+ path .
185209
186210 ``error.type`` is stamped after both filters, exactly like
187211 ``gen_ai.token.type``, so an operator's include/exclude list cannot strip
188212 the discriminator and silently merge failures back into the success series.
189213 """
190- bounded = {k : v for k , v in self ._common_attributes (kwargs ).items () if k in FAILURE_ATTRIBUTE_KEYS }
191214 attributes = {
192- ** self ._filter_attributes (bounded ),
215+ ** self ._filter_attributes (self . _bounded_attributes ( kwargs ) ),
193216 Error .TYPE : resolve_error_type (kwargs ),
194217 }
195218 self ._metrics .operation_duration .record ((end_time - start_time ).total_seconds (), attributes = attributes )
@@ -220,11 +243,25 @@ def _common_attributes(self, kwargs: Mapping[str, Any]) -> dict:
220243 common_attrs [f"metadata.{ key } " ] = str (value )
221244
222245 hidden_params = getattr (std_log , "hidden_params" , None ) or (std_log or {}).get ("hidden_params" , {})
223- if hidden_params :
224- common_attrs ["hidden_params" ] = safe_dumps (hidden_params )
246+ bounded_hidden_params = {
247+ key : hidden_params [key ]
248+ for key in BOUNDED_HIDDEN_PARAM_KEYS
249+ if isinstance (hidden_params , Mapping ) and hidden_params .get (key ) is not None
250+ }
251+ if bounded_hidden_params :
252+ common_attrs ["hidden_params" ] = safe_dumps (bounded_hidden_params )
225253
226254 return common_attrs
227255
256+ def _bounded_attributes (self , kwargs : Mapping [str , Any ]) -> MetricAttributes :
257+ """The datapoint attributes, capped at :data:`METRIC_ATTRIBUTE_CEILING`.
258+
259+ The cap runs BEFORE the operator's include/exclude filter so the filter can
260+ only narrow it. An operator who names an excluded attribute in an include
261+ list gets nothing for it rather than reintroducing an unbounded label.
262+ """
263+ return {k : v for k , v in self ._common_attributes (kwargs ).items () if k in METRIC_ATTRIBUTE_CEILING }
264+
228265 def _ensure_filter (self ) -> None :
229266 if self ._filter_resolved :
230267 return
@@ -241,8 +278,29 @@ def _ensure_filter(self) -> None:
241278 # without reconstructing the recorder.
242279 self ._include , self ._exclude = _resolve_metric_attribute_filter (attributes )
243280 self ._filter_resolved = True
281+ self ._warn_about_metric_ineligible_names ()
244282
245- def _filter_attributes (self , attrs : dict ) -> dict :
283+ def _warn_about_metric_ineligible_names (self ) -> None :
284+ """Say so when the operator's filter names an attribute the ceiling removes.
285+
286+ The shared validator accepts every span attribute name, so a name that is
287+ legal on a span but metric-ineligible would otherwise be a silent no-op: an
288+ ``include_list`` naming it emits nothing for it and an ``exclude_list`` naming
289+ it looks like it worked. Logged once, when the filter resolves, rather than
290+ per request.
291+ """
292+ named = (self ._include or frozenset ()) | (self ._exclude or frozenset ())
293+ ineligible = sorted (named - METRIC_ATTRIBUTE_CEILING - {TOKEN_TYPE_ATTRIBUTE })
294+ if ineligible :
295+ verbose_logger .warning (
296+ "OTel metrics: %s cannot be a metric attribute and is being ignored; it varies "
297+ "per request or is client-supplied, so it would make one time series per request. "
298+ "It is still on the span. Metric attributes are limited to: %s" ,
299+ ", " .join (ineligible ),
300+ ", " .join (sorted (METRIC_ATTRIBUTE_CEILING )),
301+ )
302+
303+ def _filter_attributes (self , attrs : MetricAttributes ) -> MetricAttributes :
246304 self ._ensure_filter ()
247305 if self ._include is not None :
248306 return {k : v for k , v in attrs .items () if k in self ._include }
0 commit comments