mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
fix(otel): export v2 gen_ai client metrics to the configured meter provider (#30549)
* fix(otel): export v2 gen_ai client metrics to the configured meter provider The V2 OpenTelemetry integration recorded the six gen_ai.client.* histograms into a MeterProvider it built locally in _init_metrics and never published. The recording code ran fine; the metrics simply landed in a provider disconnected from the global pipeline, so an operator's configured readers/exporters (and the server-metric instrumentation bound to the global meter provider) never saw them. Resolve the meter provider the OTel-idiomatic way instead: reuse the operator's globally configured MeterProvider when one is set so its readers receive the GenAI histograms, build and register one as the global only when none is set so V2 owns metrics export (mirroring how V2 owns trace export), and keep the injected meter_provider as an explicit override for DI and tests. * refactor(otel): hoist meter imports and harden global resolution Move the opentelemetry metrics and sdk MeterProvider imports to module top instead of importing inside resolve_meter_provider/build_meter_provider; the SDK is already a top-level dependency for tracing, so the per-call imports added nothing. resolve_meter_provider now reuses an explicit NoOpMeterProvider as well as a real SDK provider, so an operator opt-out is honored, and the built provider is always the one returned so its reader thread is never orphaned. Drive the regression test through the public metrics.get_meter_provider via monkeypatch rather than writing opentelemetry's private _METER_PROVIDER slot, and add focused tests for the injected and no-op resolution branches. * fix(otel): type resolve_meter_provider as the api MeterProvider base mypy flagged the return as incompatible because honoring an explicit NoOpMeterProvider returns a value of the opentelemetry api MeterProvider base rather than the sdk subclass. Annotate the resolver in terms of the api base and keep the sdk class for construction and the reuse isinstance check.
This commit is contained in:
parent
b8b0d458af
commit
f444539ea9
4 changed files with 99 additions and 16 deletions
|
|
@ -216,7 +216,13 @@ lives in [`plumbing/`](./plumbing):
|
|||
`TracerProvider` so one logger serves many tenants. The cache is a bounded LRU
|
||||
that flushes + shuts down evicted providers, since the key derives from
|
||||
request-supplied credentials and must not grow (or leak threads) without limit.
|
||||
- [`metrics.py`](./plumbing/metrics.py) — GenAI client metric instruments.
|
||||
- [`metrics.py`](./plumbing/metrics.py) — GenAI client metric instruments. The
|
||||
six `gen_ai.client.*` histograms are recorded through the meter resolved by
|
||||
`providers.resolve_meter_provider`: an injected provider wins (tests/DI),
|
||||
otherwise the operator's globally configured `MeterProvider` is reused so its
|
||||
readers/exporters receive them alongside the server metrics, and one is built
|
||||
and registered as the global only when none is set (mirroring how V2 owns trace
|
||||
export).
|
||||
|
||||
### Adapter
|
||||
|
||||
|
|
|
|||
|
|
@ -42,9 +42,10 @@ from litellm.integrations.otel.plumbing.metrics import (
|
|||
create_genai_metrics,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.providers import (
|
||||
build_meter_provider,
|
||||
build_tracer_provider,
|
||||
get_meter,
|
||||
get_tracer,
|
||||
resolve_meter_provider,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.routing import TenantTracerCache
|
||||
from litellm.integrations.otel.model.spans import SpanRole, span_role_for_service
|
||||
|
|
@ -127,17 +128,15 @@ class OpenTelemetryV2(CustomLogger):
|
|||
def _init_metrics(self, meter_provider: Any | None) -> "GenAIMetricRecorder | None":
|
||||
"""Create the six GenAI histograms when metrics are enabled, else ``None``.
|
||||
|
||||
``meter_provider`` is an explicit override (tests inject one); otherwise a
|
||||
provider is built from the config's exporter selection.
|
||||
``meter_provider`` is an explicit override (tests inject one); otherwise the
|
||||
provider is resolved from the OTel global so the operator's configured
|
||||
readers/exporters receive the metrics, building and registering one only
|
||||
when no global provider is set.
|
||||
"""
|
||||
if not self.config.enable_metrics:
|
||||
return None
|
||||
provider = (
|
||||
meter_provider
|
||||
if meter_provider is not None
|
||||
else build_meter_provider(self.config)
|
||||
)
|
||||
meter = provider.get_meter(LITELLM_TRACER_NAME)
|
||||
provider = resolve_meter_provider(self.config, meter_provider)
|
||||
meter = get_meter(provider, LITELLM_TRACER_NAME)
|
||||
return GenAIMetricRecorder(create_genai_metrics(meter), self.callback_name)
|
||||
|
||||
# ====================================================================== #
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable
|
||||
|
||||
from opentelemetry import baggage
|
||||
from opentelemetry import baggage, metrics
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.metrics import MeterProvider, NoOpMeterProvider
|
||||
from opentelemetry.sdk.metrics import MeterProvider as SDKMeterProvider
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider
|
||||
from opentelemetry.sdk.trace.export import (
|
||||
|
|
@ -26,7 +28,7 @@ from litellm.integrations.otel.model.spans import LiteLLMSpanKind
|
|||
from litellm.integrations.otel.model.utils import parse_headers as parse_headers
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.metrics import Meter
|
||||
from opentelemetry.sdk.metrics.export import MetricReader
|
||||
|
||||
_SPAN_KIND_BY_ROLE_KIND: dict[LiteLLMSpanKind, SpanKind] = {
|
||||
|
|
@ -233,17 +235,46 @@ def build_metric_reader(config: OpenTelemetryV2Config) -> "MetricReader":
|
|||
def build_meter_provider(
|
||||
config: OpenTelemetryV2Config,
|
||||
metric_reader: "MetricReader | None" = None,
|
||||
) -> "MeterProvider":
|
||||
) -> SDKMeterProvider:
|
||||
"""Build the :class:`MeterProvider` for GenAI metrics.
|
||||
|
||||
``metric_reader`` is an explicit override (tests inject an
|
||||
``InMemoryMetricReader``); otherwise the reader is selected from the config's
|
||||
exporter kind via :func:`build_metric_reader`.
|
||||
"""
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
|
||||
reader = metric_reader if metric_reader is not None else build_metric_reader(config)
|
||||
return MeterProvider(metric_readers=[reader], resource=build_resource(config))
|
||||
return SDKMeterProvider(metric_readers=[reader], resource=build_resource(config))
|
||||
|
||||
|
||||
def resolve_meter_provider(
|
||||
config: OpenTelemetryV2Config,
|
||||
meter_provider: MeterProvider | None = None,
|
||||
) -> MeterProvider:
|
||||
"""Resolve the :class:`MeterProvider` GenAI metrics record through.
|
||||
|
||||
An injected provider wins (DI/tests). Otherwise reuse whatever the operator has
|
||||
configured as the global, whether a real SDK provider or an explicit
|
||||
``NoOpMeterProvider``, so the GenAI histograms ride the operator's
|
||||
readers/exporters and an explicit opt-out is honored. Only when the global is
|
||||
still the default proxy placeholder does V2 build one from the config and
|
||||
publish it as the global, mirroring how V2 owns trace export. The built
|
||||
provider is the one returned, so its reader thread is always live, never
|
||||
orphaned.
|
||||
"""
|
||||
if meter_provider is not None:
|
||||
return meter_provider
|
||||
|
||||
existing = metrics.get_meter_provider()
|
||||
if isinstance(existing, (SDKMeterProvider, NoOpMeterProvider)):
|
||||
return existing
|
||||
|
||||
provider = build_meter_provider(config)
|
||||
metrics.set_meter_provider(provider)
|
||||
return provider
|
||||
|
||||
|
||||
def get_meter(provider: MeterProvider, name: str = "litellm") -> "Meter":
|
||||
return provider.get_meter(name, litellm_version)
|
||||
|
||||
|
||||
def build_resource(config: OpenTelemetryV2Config) -> Resource:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ from litellm.integrations.otel.plumbing.metrics import ( # noqa: E402
|
|||
GenAIMetricRecorder,
|
||||
create_genai_metrics,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.providers import ( # noqa: E402
|
||||
resolve_meter_provider,
|
||||
)
|
||||
|
||||
OPERATION_DURATION = "gen_ai.client.operation.duration"
|
||||
TOKEN_USAGE = "gen_ai.client.token.usage"
|
||||
|
|
@ -250,6 +253,50 @@ def test_no_filter_keeps_high_cardinality_keys():
|
|||
assert expected.issubset(set(dp.attributes.keys()))
|
||||
|
||||
|
||||
def test_metrics_reach_operator_configured_global_provider(monkeypatch):
|
||||
"""Regression: with no meter provider injected, the six gen_ai.client.*
|
||||
histograms must record through the operator's globally configured
|
||||
MeterProvider so its readers/exporters receive them. Before the fix the logger
|
||||
built an isolated provider and the operator's reader saw nothing."""
|
||||
from opentelemetry import metrics
|
||||
|
||||
reader = InMemoryMetricReader()
|
||||
operator_provider = MeterProvider(metric_readers=[reader])
|
||||
monkeypatch.setattr(metrics, "get_meter_provider", lambda: operator_provider)
|
||||
|
||||
logger = OpenTelemetryV2(
|
||||
config=OpenTelemetryV2Config(exporter="in_memory", enable_metrics=True),
|
||||
)
|
||||
kwargs, response_obj, start, end = _build_call()
|
||||
asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end))
|
||||
|
||||
assert set(_metrics_by_name(reader).keys()) == set(ALL_METRICS)
|
||||
operator_provider.shutdown()
|
||||
|
||||
|
||||
def test_resolve_meter_provider_prefers_injected():
|
||||
"""An injected provider is used verbatim, never replaced by the global."""
|
||||
injected = MeterProvider(metric_readers=[InMemoryMetricReader()])
|
||||
resolved = resolve_meter_provider(
|
||||
OpenTelemetryV2Config(exporter="in_memory"), injected
|
||||
)
|
||||
assert resolved is injected
|
||||
injected.shutdown()
|
||||
|
||||
|
||||
def test_resolve_meter_provider_honors_operator_noop(monkeypatch):
|
||||
"""An operator that disabled metrics with a NoOpMeterProvider is not silently
|
||||
overridden by a freshly built provider."""
|
||||
from opentelemetry import metrics
|
||||
from opentelemetry.metrics import NoOpMeterProvider
|
||||
|
||||
noop = NoOpMeterProvider()
|
||||
monkeypatch.setattr(metrics, "get_meter_provider", lambda: noop)
|
||||
|
||||
resolved = resolve_meter_provider(OpenTelemetryV2Config(exporter="in_memory"))
|
||||
assert resolved is noop
|
||||
|
||||
|
||||
def _recorder(monkeypatch, attributes):
|
||||
"""A recorder wired to a fresh in-memory meter, with callback_settings carrying
|
||||
`attributes`. record() resolves the filter lazily from there, so a misconfig
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue