mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
fix(otel): stop a key's Langfuse logger from re-sending spans to the operator's collector
With langfuse_otel only in key or team logging, the proxy publishes the generic V2 logger at startup and it already exports to the operator's OTLP collector. The Langfuse logger a key's request builds kept the same base exporter, so the collector got every model span twice. A logger built after the publish now keeps only its preset's own exporters, as it already did beside a configured V2 logger, and a later call that would rebuild one exporting nowhere reuses it.
This commit is contained in:
parent
44d055e4d5
commit
1963a42bac
4 changed files with 64 additions and 9 deletions
|
|
@ -1038,6 +1038,11 @@ def seed_request_identity(user_api_key_dict: object, model: str | None = None) -
|
|||
logger.seed_request_identity(user_api_key_dict, model=model)
|
||||
|
||||
|
||||
def v2_provider_published() -> bool:
|
||||
"""Whether the proxy published a v2 provider at startup, which delivers to the operator's own backends."""
|
||||
return _published_v2_provider is not None
|
||||
|
||||
|
||||
def fan_out_provider() -> ApiTracerProvider:
|
||||
"""The provider :func:`publish_global_otel_v2_provider` gave the tenant fan-out.
|
||||
|
||||
|
|
|
|||
|
|
@ -5971,11 +5971,12 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom
|
|||
Returns ``None`` when V2 is off OR when there's no preset registered for
|
||||
``callback_name`` — callers should then fall through to the legacy path.
|
||||
|
||||
A logger built while another V2 logger is already registered keeps only the
|
||||
exporters its own preset contributed, whether or not the operator holds
|
||||
credentials for that backend and whether or not a destination is anchored: the
|
||||
registered logger owns operator delivery, so a copy of the operator's base OTLP
|
||||
exporters here would emit every LLM call a second time into the operator's sink.
|
||||
A logger built while another V2 logger is already registered, or after the proxy
|
||||
published one at startup, keeps only the exporters its own preset contributed,
|
||||
whether or not the operator holds credentials for that backend and whether or not
|
||||
a destination is anchored: that logger owns operator delivery, so a copy of the
|
||||
operator's base OTLP exporters here would emit every LLM call a second time into
|
||||
the operator's sink.
|
||||
A preset that contributes no exporter of its own (a mapper over the operator's
|
||||
collector) keeps the base exporters, since it has nothing else to deliver through.
|
||||
A preset that needs operator credentials it cannot find is allowed to build only
|
||||
|
|
@ -5986,7 +5987,7 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom
|
|||
until a key/team destination appears: the proxy builds the operator's callback at
|
||||
startup, before any request has resolved a destination, and the legacy path there
|
||||
would post every non-team request to the backend keyless. That logger is reused by
|
||||
later calls as long as the preset would again build exporting nowhere; one that
|
||||
later calls as long as a rebuild would again export nowhere; one that
|
||||
was degraded for a destination while the preset raises without one is not, since
|
||||
the degrade was justified by that destination alone.
|
||||
"""
|
||||
|
|
@ -5994,7 +5995,7 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom
|
|||
|
||||
if not is_otel_v2_enabled():
|
||||
return None
|
||||
from litellm.integrations.otel.logger import OpenTelemetryV2, build_otel_v2_logger
|
||||
from litellm.integrations.otel.logger import OpenTelemetryV2, build_otel_v2_logger, v2_provider_published
|
||||
from litellm.integrations.otel.plumbing.context import destination_backends
|
||||
from litellm.integrations.otel.presets import PRESET_BY_CALLBACK
|
||||
|
||||
|
|
@ -6022,9 +6023,11 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom
|
|||
return None
|
||||
if _is_credential_gated(built) and not carried and _only_the_placeholder_would_export(built):
|
||||
return None
|
||||
if existing is not None and _exports_nowhere(built):
|
||||
config: Final = (
|
||||
_only_the_presets_own_exporters(built, callback_name) if has_v2_logger or v2_provider_published() else built
|
||||
)
|
||||
if existing is not None and _exports_nowhere(config):
|
||||
return existing
|
||||
config: Final = _only_the_presets_own_exporters(built, callback_name) if has_v2_logger else built
|
||||
if _exports_nowhere(config):
|
||||
verbose_logger.warning(
|
||||
"OTel V2: no operator credentials for '%s'; only key/team destinations will receive its traces",
|
||||
|
|
|
|||
|
|
@ -128,4 +128,5 @@ def test_a_tenant_langfuse_project_gets_user_session_and_tags_without_operator_k
|
|||
for span in tenant
|
||||
] == [{"user.id": "user-42", "session.id": session, "langfuse.trace.tags": TAGS}], [s["name"] for s in tenant]
|
||||
assert "langfuse.observation.metadata" in tenant[0]["attributes"], sorted(tenant[0]["attributes"])
|
||||
assert len(operator) == 1, [span["name"] for span in operator]
|
||||
assert "langfuse.observation.metadata" not in operator[0]["attributes"], sorted(operator[0]["attributes"])
|
||||
|
|
|
|||
|
|
@ -3522,6 +3522,52 @@ class TestLangfuseTenantOnly:
|
|||
assert later is at_startup
|
||||
assert loggers == [at_startup]
|
||||
|
||||
@staticmethod
|
||||
def _key_logger_beside_the_generic_startup_logger(
|
||||
monkeypatch: pytest.MonkeyPatch, loggers: list[CustomLogger]
|
||||
) -> OpenTelemetryV2:
|
||||
"""``langfuse_otel`` only in a key's logging: the proxy published the generic logger,
|
||||
which exports to the operator's collector, and a key's request builds the Langfuse one."""
|
||||
from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2
|
||||
|
||||
credential_less_proxy(monkeypatch)
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector.local:4318")
|
||||
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
|
||||
publish_global_otel_v2_provider(loggers, lambda _p: None)
|
||||
|
||||
def with_destination() -> OpenTelemetryV2 | None:
|
||||
set_request_destinations((LANGFUSE_DEST,))
|
||||
return _maybe_construct_otel_v2("langfuse_otel", loggers)
|
||||
|
||||
is_otel_v2_enabled.cache_clear()
|
||||
logger: Final = in_fresh_context(with_destination)
|
||||
is_otel_v2_enabled.cache_clear()
|
||||
assert isinstance(logger, OpenTelemetryV2)
|
||||
return logger
|
||||
|
||||
def test_a_key_destinations_logger_leaves_the_operators_collector_to_the_published_logger(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The published logger already sends every span to the collector; a copy here doubles each one."""
|
||||
logger: Final = self._key_logger_beside_the_generic_startup_logger(monkeypatch, [])
|
||||
|
||||
assert all(spec.requires_headers and not spec.headers for spec in logger.config.exporters)
|
||||
|
||||
def test_a_key_destinations_logger_is_reused_by_later_requests_without_a_destination(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2
|
||||
|
||||
loggers: Final[list[CustomLogger]] = []
|
||||
for_the_key: Final = self._key_logger_beside_the_generic_startup_logger(monkeypatch, loggers)
|
||||
|
||||
is_otel_v2_enabled.cache_clear()
|
||||
later: Final = in_fresh_context(_maybe_construct_otel_v2, "langfuse_otel", loggers)
|
||||
is_otel_v2_enabled.cache_clear()
|
||||
|
||||
assert later is for_the_key
|
||||
assert loggers == [for_the_key]
|
||||
|
||||
def test_a_key_destination_gets_the_langfuse_fields_from_the_generic_startup_logger(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue