fix(otel): one v2 logger owns the global provider; scope tenant OTLP creds per exporter (#30590)

* fix(otel): one v2 logger owns the global provider; scope tenant creds per exporter

The proxy published the OTel global TracerProvider before callbacks were
initialized, so no preset logger existed yet and a second generic logger was
built that won the global provider. Server spans then exported through a
different provider than the preset's gen-ai spans, orphaning the LLM span on
the preset backend. Publish after callback init and reuse the already-built
logger instead.

Separately, per-request tenant OTLP credentials were stamped onto every OTLP
exporter, leaking one backend's key onto a co-configured backend. Tag each
exporter with the preset that contributed it and apply dynamic credentials
only to the matching owner.

* fix(otel): satisfy Any-discipline on changed lines

Type the logger-selection parameter as Sequence[object] (isinstance narrows
it), cast the list[Any] global at the single call site, and pass model_copy a
typed dict[str, str] update so no changed line carries an Any value.

* fix(otel): annotate the untyped-global boundary with any-ok

select_global_otel_v2_logger consumes litellm._in_memory_loggers, a shared
List[Any] global this change does not own. A cast doesn't satisfy the
Any-discipline checker (it inspects the inner expression), and re-annotating the
global is out of scope, so mark the single boundary line any-ok.

* test(otel): cover the startup global-provider publish via injectable helper

The publish step lived inline in proxy_startup_event (a FastAPI lifespan unit
tests do not execute), so its lines were uncovered though the selection logic
was tested. Extract publish_global_otel_v2_provider, which selects the single v2
logger and publishes its provider through an injected setter, and unit-test that
the published provider is the selected logger's. proxy_server delegates to it.

* refactor(otel): select global provider from the registered owner, not a list scan

The startup publish picked the global TracerProvider by scanning
_in_memory_loggers for the first OpenTelemetryV2, re-deriving an answer the
factory already settled: the first logger built registers itself as
proxy_server.open_telemetry_logger, and every other v2 path (guardrail, identity
seeding, phase spans) routes through that owner via _registered_v2_logger. Pass
that owner into select_global_otel_v2_logger so the global provider reuses the
same logger instead of an independent, order-dependent guess; the list scan
remains the SDK-path fallback. The owner is injected at the proxy call site to
keep the helper free of hidden global reads.

* refactor(otel): type ExporterSpec.owner as an ExporterOwner enum

The owner field carried free-form strings that had to match preset callback
names. Introduce a str-based ExporterOwner enum (values equal to the callback
names, so per-request credential routing's owner==callback_name comparison still
holds) and have each preset tag its exporter with the enum member.

* refactor(otel): rename ExporterOwner.ARIZE to ARIZE_AX

Distinguish the hosted Arize AX backend from Arize Phoenix at the member level
while keeping the value 'arize' (the public callback name routing compares
against). Add a comment noting AX and Phoenix are separate backends.
This commit is contained in:
yucheng-berri 2026-06-19 11:15:29 -07:00 • committed by GitHub
parent 1bd603d1ac
commit 1f9323792c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 358 additions and 43 deletions

View file

@ -3,7 +3,7 @@
from collections import OrderedDict
from contextlib import contextmanager
from datetime import datetime
from typing import TYPE_CHECKING, Any, Iterator, Mapping, cast
from typing import TYPE_CHECKING, Any, Callable, Iterator, Mapping, Sequence, cast
from opentelemetry.context import attach, get_current
from opentelemetry.sdk.trace import TracerProvider
@ -546,6 +546,58 @@ class OpenTelemetryV2(CustomLogger):
return span
def select_global_otel_v2_logger(
in_memory_loggers: Sequence[object],
registered: "OpenTelemetryV2 | None" = None,
) -> "OpenTelemetryV2":
"""The single ``OpenTelemetryV2`` whose provider should become the OTel global.
The callback factory designates one logger as canonical the moment it builds
the first one (``_init_otel_logger_on_litellm_proxy`` sets
``proxy_server.open_telemetry_logger``), and every other v2 entry point —
guardrail, identity seeding, phase spans — already routes through that same
``registered`` owner. Reuse it here too so the global provider has one source
of truth instead of a second, independently-derived guess; this is the logger
a preset (arize, langfuse, …) folds the ``OTEL_*`` base exporter and its own
exporter into, so the FastAPI server span and the gen-ai spans share one
provider and one trace.
Fall back to ``in_memory_loggers`` for the SDK path, where no proxy global is
set (selecting from there, not ``service_callback``, which a preset logger does
not always reach), and build a generic logger from ``OTEL_*`` only when none was
configured at all. Each fallback still avoids the second generic logger that
orphaned the gen-ai spans onto a different backend than the server span.
"""
if registered is not None:
return registered
existing = next(
(cb for cb in in_memory_loggers if isinstance(cb, OpenTelemetryV2)), None
)
return existing if existing is not None else OpenTelemetryV2()
def publish_global_otel_v2_provider(
in_memory_loggers: Sequence[object],
set_global_provider: Callable[[TracerProvider], None],
registered: "OpenTelemetryV2 | None" = None,
) -> "OpenTelemetryV2":
"""Select the single v2 logger and publish its provider as the OTel global.
The proxy calls this once at startup, after callbacks are initialized, so the
preset logger already exists; it passes ``registered`` (the canonical owner the
factory designated as ``proxy_server.open_telemetry_logger``) so the global
provider reuses the same logger the rest of the v2 code emits through (see
:func:`select_global_otel_v2_logger`). Both ``registered`` and
``set_global_provider`` (the proxy passes
``opentelemetry.trace.set_tracer_provider``) are injected so the publish step is
unit-testable without reading or mutating real global OTel state. Returns the
logger whose provider was published.
"""
logger = select_global_otel_v2_logger(in_memory_loggers, registered=registered)
set_global_provider(logger._tracer_provider)
return logger
def _registered_v2_logger() -> "OpenTelemetryV2 | None":
try:
from litellm.proxy import proxy_server

View file

@ -1,5 +1,6 @@
"""Typed configuration for the OpenTelemetry instrumentation."""
from enum import Enum
from typing import Any, List
from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator
@ -23,6 +24,23 @@ class CaptureMessageContent(str):
SPAN_AND_EVENT = "span_and_event"
class ExporterOwner(str, Enum):
"""The preset that contributed an exporter. Values match the callback names
in ``presets.PRESET_BY_CALLBACK`` so per-request dynamic-credential routing
can match an exporter's owner against the credential source's callback name.
A ``str`` enum so the value compares equal to the bare callback-name string."""
# Arize AX (the hosted platform) and Arize Phoenix (the open-source / Phoenix
# Cloud tracer) are distinct backends with separate config and auth, so they
# are separate owners. The member value stays the public callback name.
ARIZE_AX = "arize"
ARIZE_PHOENIX = "arize_phoenix"
LANGFUSE_OTEL = "langfuse_otel"
WEAVE_OTEL = "weave_otel"
LEVO = "levo"
AGENTOPS = "agentops"
class _OTelV2Flag(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
@ -49,6 +67,15 @@ class ExporterSpec(BaseModel):
)
endpoint: str | None = None
headers: str | None = None
owner: ExporterOwner | None = Field(
default=None,
description=(
"The preset that contributed this exporter. Per-request dynamic OTLP "
"credentials are applied only to the exporter whose owner matches the "
"credential source, so one tenant's vendor key never lands on a "
"different backend's exporter."
),
)
options: dict[str, str] | None = Field(
default=None,
description=(

View file

@ -88,13 +88,23 @@ class TenantTracerCache:
return get_tracer(provider, self._tracer_name)
def _config_with_headers(self, headers: Mapping[str, str]) -> OpenTelemetryV2Config:
"""Clone the config, replacing OTLP exporter headers with ``headers``."""
"""Clone the config, stamping ``headers`` onto the credential's own exporter.
``headers`` are the per-request credentials of ``self._callback_name`` (the
integration that built this cache), so they apply only to the exporter that
integration contributed (``spec.owner``). A request that carries one
tenant's Arize key must never rewrite the headers of a co-configured
Langfuse or self-hosted collector exporter, which would leak that key to a
different backend.
"""
header_str = ",".join(f"{key}={value}" for key, value in headers.items())
header_update: dict[str, str] = {"headers": header_str}
exporters = [
(
spec
if spec.kind.lower() in _NON_OTLP_KINDS
else spec.model_copy(update={"headers": header_str})
spec.model_copy(update=header_update)
if spec.owner == self._callback_name
and spec.kind.lower() not in _NON_OTLP_KINDS
else spec
)
for spec in self._config.exporters
]

View file

@ -16,7 +16,11 @@ from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from litellm._logging import verbose_logger
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
from litellm.integrations.otel.model.config import (
ExporterOwner,
ExporterSpec,
OpenTelemetryV2Config,
)
from litellm.integrations.otel.plumbing.providers import register_exporter_factory
_AGENTOPS_ENDPOINT = "https://otlp.agentops.cloud/v1/traces"
@ -59,6 +63,7 @@ def agentops_preset(
options=(
{"api_key": settings.api_key} if settings.api_key else None
),
owner=ExporterOwner.AGENTOPS,
),
],
"resource_attributes": {

View file

@ -4,7 +4,11 @@ from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from litellm.integrations.arize.arize import ArizeLogger as _V1ArizeLogger
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
from litellm.integrations.otel.model.config import (
ExporterOwner,
ExporterSpec,
OpenTelemetryV2Config,
)
from litellm.integrations.otel.presets.utils import ensure_mappers
from litellm.types.utils import StandardCallbackDynamicParams
@ -34,6 +38,7 @@ def arize_preset(
kind=arize_cfg.protocol or "otlp_grpc",
endpoint=arize_cfg.endpoint or "https://otlp.arize.com/v1",
headers=headers,
owner=ExporterOwner.ARIZE_AX,
),
],
"mapper_names": ensure_mappers(base.mapper_names, "openinference"),

View file

@ -3,7 +3,11 @@
from litellm.integrations.langfuse.langfuse_otel import (
LangfuseOtelLogger as _V1Langfuse,
)
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
from litellm.integrations.otel.model.config import (
ExporterOwner,
ExporterSpec,
OpenTelemetryV2Config,
)
from litellm.integrations.otel.presets.utils import ensure_mappers
from litellm.types.utils import StandardCallbackDynamicParams
@ -23,6 +27,7 @@ def langfuse_preset(
kind=kind,
endpoint=cfg.endpoint,
headers=cfg.headers,
owner=ExporterOwner.LANGFUSE_OTEL,
),
],
"mapper_names": ensure_mappers(base.mapper_names, "langfuse"),

View file

@ -1,7 +1,11 @@
"""Levo preset — OTLP/HTTP to a Levo collector with org+workspace headers."""
from litellm.integrations.levo.levo import LevoLogger as _V1Levo
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
from litellm.integrations.otel.model.config import (
ExporterOwner,
ExporterSpec,
OpenTelemetryV2Config,
)
def levo_preset(
@ -18,6 +22,7 @@ def levo_preset(
kind="otlp_http",
endpoint=cfg.endpoint,
headers=cfg.otlp_auth_headers,
owner=ExporterOwner.LEVO,
),
],
}

View file

@ -6,7 +6,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from litellm.integrations.arize.arize_phoenix import (
ArizePhoenixLogger as _V1Phoenix,
)
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
from litellm.integrations.otel.model.config import (
ExporterOwner,
ExporterSpec,
OpenTelemetryV2Config,
)
from litellm.integrations.otel.presets.utils import ensure_mappers
@ -37,6 +41,7 @@ def phoenix_preset(
kind=cfg.protocol if hasattr(cfg, "protocol") else "otlp_http",
endpoint=cfg.endpoint,
headers=headers,
owner=ExporterOwner.ARIZE_PHOENIX,
),
],
"mapper_names": ensure_mappers(base.mapper_names, "openinference"),

View file

@ -1,6 +1,10 @@
"""Weave (W&B) preset."""
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
from litellm.integrations.otel.model.config import (
ExporterOwner,
ExporterSpec,
OpenTelemetryV2Config,
)
from litellm.integrations.otel.presets.utils import ensure_mappers
from litellm.integrations.weave.weave_otel import (
_get_weave_authorization_header,
@ -23,6 +27,7 @@ def weave_preset(
kind=weave_cfg.protocol or "otlp_http",
endpoint=weave_cfg.endpoint,
headers=weave_cfg.otlp_auth_headers,
owner=ExporterOwner.WEAVE_OTEL,
),
],
# Weave consumes OpenInference + a small Weave-specific overlay.

View file

@ -843,37 +843,6 @@ async def proxy_startup_event(app: FastAPI):
if isinstance(worker_config, dict):
await initialize(**worker_config)
## V2 OTEL: now that config (and therefore the callbacks) is loaded, publish
## the chosen V2 logger's TracerProvider as the OTel global. The FastAPI
## instrumentation mounted at app-creation binds to the global provider, so
## this is what makes server spans and gen-ai spans share one provider and
## land in the same trace. Prefer an already-registered preset logger
## (arize, langfuse, …) so server spans export to that backend too; otherwise
## build a generic one from OTEL_* envs. ``set_tracer_provider`` only takes
## effect once, so the first configured logger wins.
try:
from litellm.integrations.otel.model.config import is_otel_v2_enabled
if is_otel_v2_enabled():
from opentelemetry import trace as _otel_trace
from litellm.integrations.otel.logger import OpenTelemetryV2
_otel_v2_logger = (
next(
(
cb
for cb in litellm.service_callback
if isinstance(cb, OpenTelemetryV2)
),
None,
)
or OpenTelemetryV2()
)
_otel_trace.set_tracer_provider(_otel_v2_logger._tracer_provider)
except Exception as e:
verbose_proxy_logger.debug("Skipping OTel V2 provider setup: %s", e)
# check if DATABASE_URL in environment - load from there
if prisma_client is None:
_db_url: Optional[str] = get_secret("DATABASE_URL", None) # type: ignore
@ -910,6 +879,42 @@ async def proxy_startup_event(app: FastAPI):
redis_usage_cache=transaction_buffer_redis_cache,
)
## V2 OTEL: publish the chosen V2 logger's TracerProvider as the OTel global.
## This MUST run after callback initialization above: a preset (arize, langfuse,
## …) builds its logger there, folding the OTEL_* base exporter and its own
## exporter into one logger. The FastAPI instrumentation mounted at app-creation
## binds to the global provider, so reusing that one logger is what makes the
## server span and the gen-ai spans share one provider and land in the same
## trace, exporting to every configured backend. Running before callback init
## (when no logger exists yet) would build a second, generic logger whose
## provider became the global, orphaning the gen-ai spans onto a different
## backend than the server span. A generic logger is built only when none was
## configured.
try:
from litellm.integrations.otel.model.config import is_otel_v2_enabled
if is_otel_v2_enabled():
from opentelemetry import trace as _otel_trace
from litellm.litellm_core_utils.litellm_logging import _in_memory_loggers
from litellm.integrations.otel.logger import (
OpenTelemetryV2,
publish_global_otel_v2_provider,
)
registered = (
open_telemetry_logger
if isinstance(open_telemetry_logger, OpenTelemetryV2)
else None
)
publish_global_otel_v2_provider(
_in_memory_loggers, # any-ok: pre-existing untyped List[Any] global
_otel_trace.set_tracer_provider,
registered=registered,
)
except Exception as e:
verbose_proxy_logger.debug("Skipping OTel V2 provider setup: %s", e)
## Validate use_redis_transaction_buffer requires Redis cache ##
ProxyStartupEvent._validate_redis_transaction_buffer_config(
general_settings=general_settings,

View file

@ -123,9 +123,53 @@ def test_non_participating_callback_uses_default_tracer():
def test_dynamic_headers_applied_to_otlp_exporter_only():
cache = _cache(
"arize",
exporters=[ExporterSpec(kind="otlp_http"), ExporterSpec(kind="in_memory")],
exporters=[
ExporterSpec(kind="otlp_http", owner="arize"),
ExporterSpec(kind="in_memory", owner="arize"),
],
)
new_cfg = cache._config_with_headers({"arize-space-id": "S", "api_key": "K"})
otlp, in_mem = new_cfg.exporters
assert otlp.headers == "arize-space-id=S,api_key=K"
assert in_mem.headers is None # console/in_memory left untouched
def test_dynamic_headers_do_not_leak_to_other_owners_exporter():
"""A tenant's Arize credentials must never be stamped onto a co-configured
exporter owned by a different backend (a self-hosted collector, Langfuse).
Regression for the cross-backend credential leak: ``_config_with_headers``
used to rewrite the headers of every OTLP exporter, so one request carrying
a team's Arize key clobbered the base collector's and Langfuse's headers
with that key.
"""
cache = _cache(
"arize",
exporters=[
ExporterSpec(
kind="otlp_http",
endpoint="http://self-hosted-collector:4318",
headers="x=base-collector",
owner=None,
),
ExporterSpec(
kind="otlp_http",
endpoint="https://cloud.langfuse.com/api/public/otel",
headers="Authorization=Basic base-langfuse",
owner="langfuse_otel",
),
ExporterSpec(
kind="otlp_grpc",
endpoint="https://otlp.arize.com/v1",
headers="space_id=base,api_key=base",
owner="arize",
),
],
)
new_cfg = cache._config_with_headers(
{"arize-space-id": "TEAMX", "api_key": "TEAMX_KEY"}
)
by_owner = {e.owner: e.headers for e in new_cfg.exporters}
assert by_owner["arize"] == "arize-space-id=TEAMX,api_key=TEAMX_KEY"
assert by_owner[None] == "x=base-collector"
assert by_owner["langfuse_otel"] == "Authorization=Basic base-langfuse"

View file

@ -1058,6 +1058,97 @@ def test_proxy_global_first_registered_wins(monkeypatch):
assert second is not first
def test_select_global_otel_v2_logger_reuses_existing_preset_logger():
"""The global-provider selection must reuse the logger the callback factory
already built (e.g. an arize preset logger that folds the OTEL_* base exporter
and its own exporter into one logger), not mint a second generic one.
Regression for the orphan span: the startup publish used to search
``service_callback`` (which a preset logger does not always reach), miss the
existing logger, and build a second generic ``OpenTelemetryV2`` whose provider
became the OTel global. The server span then exported through that generic
provider while the preset logger's gen-ai spans exported to the preset backend,
so on that backend the LLM span had no parent. Selecting from the loggers the
factory registered keeps one logger, one provider, one connected trace.
"""
from litellm.integrations.otel.logger import select_global_otel_v2_logger
cfg = OpenTelemetryV2Config(exporter="in_memory")
tp = providers.build_tracer_provider(cfg)
preset_logger = OpenTelemetryV2(
config=cfg, callback_name="arize", tracer_provider=tp
)
chosen = select_global_otel_v2_logger([object(), preset_logger, object()])
assert chosen is preset_logger
def test_select_global_otel_v2_logger_prefers_registered_owner_over_list_scan():
"""Selection reuses the canonical owner the factory registered, not whatever
the ``in_memory_loggers`` scan happens to reach first.
The factory designates one logger as ``proxy_server.open_telemetry_logger`` the
moment it builds the first one, and every other v2 path (guardrail, seed,
phase spans) routes through that owner. With two presets configured, the list
scan's "first ``OpenTelemetryV2``" is order-dependent and could disagree with
that owner, publishing one backend's provider as the global while the rest of
the v2 code emits through another. Passing the registered owner pins the global
provider to the same logger the rest of the code already uses.
"""
from litellm.integrations.otel.logger import select_global_otel_v2_logger
cfg = OpenTelemetryV2Config(exporter="in_memory")
owner = OpenTelemetryV2(
config=cfg,
callback_name="arize",
tracer_provider=providers.build_tracer_provider(cfg),
)
other = OpenTelemetryV2(
config=cfg,
callback_name="langfuse_otel",
tracer_provider=providers.build_tracer_provider(cfg),
)
chosen = select_global_otel_v2_logger([other, owner], registered=owner)
assert chosen is owner
def test_select_global_otel_v2_logger_builds_one_when_none_registered():
"""With no logger registered, selection builds exactly one generic logger so
the proxy still publishes a provider; it must not return ``None``."""
from litellm.integrations.otel.logger import select_global_otel_v2_logger
chosen = select_global_otel_v2_logger([])
assert isinstance(chosen, OpenTelemetryV2)
def test_publish_global_otel_v2_provider_sets_selected_logger_provider():
"""The startup publish must set the OTel global provider to the *selected*
logger's provider (the preset logger that owns every exporter), so the FastAPI
server span and the gen-ai spans share one provider and one trace.
Drives the publish step the proxy runs at startup, with the global-setter
injected so no real global OTel state is mutated. Guards the wiring that a unit
test would otherwise miss: that the published provider is the selected logger's,
not some other.
"""
from litellm.integrations.otel.logger import publish_global_otel_v2_provider
cfg = OpenTelemetryV2Config(exporter="in_memory")
tp = providers.build_tracer_provider(cfg)
preset_logger = OpenTelemetryV2(
config=cfg, callback_name="arize", tracer_provider=tp
)
published = []
chosen = publish_global_otel_v2_provider(
[object(), preset_logger], published.append
)
assert chosen is preset_logger
assert published == [preset_logger._tracer_provider]
def test_registers_into_litellm_service_callback(monkeypatch):
"""The logger must mutate ``litellm.service_callback`` in place. An empty
list is falsy, so a ``getattr(..) or []`` would append to a throwaway local

View file

@ -44,6 +44,37 @@ def test_agentops_exporter_factory_is_registered():
assert _AGENTOPS_EXPORTER_KIND in providers._EXPORTER_FACTORIES
def test_dynamic_cred_presets_tag_exporter_with_matching_owner(monkeypatch):
"""Each dynamic-credential preset must tag the exporter it contributes with
its own callback name, so per-request tenant routing
(``TenantTracerCache``) applies that integration's credentials only to its
own exporter and never bleeds them onto a co-configured backend.
"""
from litellm.integrations.otel.presets import (
DYNAMIC_HEADERS_BY_CALLBACK,
PRESET_BY_CALLBACK,
)
monkeypatch.setenv("ARIZE_SPACE_ID", "S")
monkeypatch.setenv("ARIZE_API_KEY", "K")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk")
monkeypatch.setenv("LANGFUSE_HOST", "https://cloud.langfuse.com")
monkeypatch.setenv("WANDB_API_KEY", "w")
monkeypatch.setenv("WANDB_PROJECT_ID", "entity/project")
from litellm.integrations.otel.model.config import ExporterOwner
for callback_name in DYNAMIC_HEADERS_BY_CALLBACK:
cfg = PRESET_BY_CALLBACK[callback_name]()
owners = {e.owner for e in cfg.exporters}
assert ExporterOwner(callback_name) in owners, (
f"{callback_name} preset did not tag its exporter with "
f"owner={callback_name!r}; tenant credentials would leak across "
f"exporters. owners present: {owners}"
)
def test_agentops_exporter_mints_jwt_lazily(monkeypatch):
pytest.importorskip("opentelemetry.exporter.otlp.proto.http.trace_exporter")
monkeypatch.setattr(

View file

@ -504,3 +504,28 @@ async def test_proxy_startup_event_invalid_missing_app_arg_raises():
# no arguments — the decorator preserves the missing-arg TypeError.
async with proxy_startup_event(): # type: ignore[call-arg]
pass
def test_otel_global_provider_published_after_callback_init():
"""The OTel V2 global-provider publish must run after callback
initialization in ``proxy_startup_event``.
Regression for the orphan span: a preset (arize, langfuse, …) builds its
single folded logger during ``_initialize_startup_logging``. Publishing the
global ``TracerProvider`` before that ran found no logger and built a second
generic one whose provider became the global, so the FastAPI server span and
the preset's gen-ai spans exported through different providers and the LLM
span was orphaned. The publish (``publish_global_otel_v2_provider``) must
therefore appear after ``_initialize_startup_logging`` in the lifespan source.
"""
wrapped = getattr(proxy_startup_event, "__wrapped__", proxy_startup_event)
source = inspect.getsource(wrapped)
init_pos = source.find("_initialize_startup_logging(")
publish_pos = source.find("publish_global_otel_v2_provider(")
assert init_pos != -1, "callback init call not found in proxy_startup_event"
assert publish_pos != -1, "OTEL global publish not found in proxy_startup_event"
assert init_pos < publish_pos, (
"OTEL global provider is published before callbacks are initialized; a "
"preset logger will not exist yet and a second generic logger will own "
"the global provider, orphaning gen-ai spans"
)