mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-31 06:56:59 +00:00
feat(otel): add LangWatch preset to OpenTelemetry v2
This commit is contained in:
parent
cb7e6a7f4c
commit
e3c6ff4f1a
7 changed files with 273 additions and 1 deletions
|
|
@ -174,6 +174,7 @@ _custom_logger_compatible_callbacks_literal = Literal[
|
|||
"ternary",
|
||||
"posthog",
|
||||
"levo",
|
||||
"langwatch",
|
||||
"compression_interception",
|
||||
"newrelic",
|
||||
"signoz",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class ExporterOwner(str, Enum):
|
|||
LANGFUSE_OTEL = "langfuse_otel"
|
||||
WEAVE_OTEL = "weave_otel"
|
||||
LEVO = "levo"
|
||||
LANGWATCH = "langwatch"
|
||||
AGENTOPS = "agentops"
|
||||
NEWRELIC = "newrelic"
|
||||
SIGNOZ = "signoz"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from litellm.integrations.otel.presets.langfuse import (
|
|||
langfuse_preset,
|
||||
)
|
||||
from litellm.integrations.otel.presets.langtrace import langtrace_preset
|
||||
from litellm.integrations.otel.presets.langwatch import langwatch_preset
|
||||
from litellm.integrations.otel.presets.levo import levo_preset
|
||||
from litellm.integrations.otel.presets.newrelic import (
|
||||
newrelic_dynamic_endpoint,
|
||||
|
|
@ -47,6 +48,7 @@ PRESET_BY_CALLBACK: Final[Mapping[str, Preset]] = MappingProxyType(
|
|||
"arize_phoenix": phoenix_preset,
|
||||
"langfuse_otel": langfuse_preset,
|
||||
"langtrace": langtrace_preset,
|
||||
"langwatch": langwatch_preset,
|
||||
"levo": levo_preset,
|
||||
"newrelic": newrelic_preset,
|
||||
"signoz": signoz_preset,
|
||||
|
|
@ -56,7 +58,7 @@ PRESET_BY_CALLBACK: Final[Mapping[str, Preset]] = MappingProxyType(
|
|||
|
||||
#: Callback name → per-request OTLP header builder (team/key multi-tenant
|
||||
#: routing). Only integrations that support dynamic credentials appear here —
|
||||
#: Arize-Phoenix/Langtrace/Levo/AgentOps don't, so they use the logger's
|
||||
#: Arize-Phoenix/Langtrace/LangWatch/Levo/AgentOps don't, so they use the logger's
|
||||
#: default tracer.
|
||||
DYNAMIC_HEADERS_BY_CALLBACK: Final[Mapping[str, Callable[[StandardCallbackDynamicParams], dict[str, str]]]] = (
|
||||
MappingProxyType(
|
||||
|
|
@ -157,6 +159,7 @@ __all__ = [
|
|||
"dynamic_otlp_headers",
|
||||
"langfuse_preset",
|
||||
"langtrace_preset",
|
||||
"langwatch_preset",
|
||||
"levo_preset",
|
||||
"newrelic_preset",
|
||||
"phoenix_preset",
|
||||
|
|
|
|||
83
litellm/integrations/otel/presets/langwatch.py
Normal file
83
litellm/integrations/otel/presets/langwatch.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""LangWatch preset: OTLP/HTTP to LangWatch with a bearer API key.
|
||||
|
||||
LangWatch (https://langwatch.ai) ingests the canonical OpenTelemetry GenAI
|
||||
semantic conventions, so the default ``genai`` vocabulary is all it needs.
|
||||
Self-hosted deployments serve the same path on their own host, set through
|
||||
``LANGWATCH_ENDPOINT``.
|
||||
"""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from litellm.integrations.otel.model.config import (
|
||||
ExporterOwner,
|
||||
ExporterSpec,
|
||||
OpenTelemetryV2Config,
|
||||
)
|
||||
from litellm.integrations.otel.presets.utils import (
|
||||
ensure_mappers,
|
||||
is_unconfigured_placeholder,
|
||||
)
|
||||
|
||||
LANGWATCH_DEFAULT_ENDPOINT: Final = "https://app.langwatch.ai"
|
||||
_LANGWATCH_OTEL_TRACES_PATH: Final = "/api/otel/v1/traces"
|
||||
|
||||
|
||||
class _LangWatchSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
|
||||
|
||||
api_key: str | None = Field(default=None, validation_alias="LANGWATCH_API_KEY")
|
||||
endpoint: str | None = Field(default=None, validation_alias="LANGWATCH_ENDPOINT")
|
||||
|
||||
|
||||
def langwatch_otel_traces_endpoint(base_url: str | None) -> str:
|
||||
"""The OTLP/HTTP traces URL for a LangWatch base URL (cloud when unset)."""
|
||||
base: Final = (base_url or "").strip().rstrip("/") or LANGWATCH_DEFAULT_ENDPOINT
|
||||
normalized: Final = base if base.startswith(("http://", "https://")) else f"https://{base}"
|
||||
if normalized.endswith(_LANGWATCH_OTEL_TRACES_PATH):
|
||||
return normalized
|
||||
return f"{normalized}{_LANGWATCH_OTEL_TRACES_PATH}"
|
||||
|
||||
|
||||
def get_langwatch_otel_config() -> tuple[str, str]:
|
||||
"""Return ``(traces_endpoint, otlp_headers)`` from the LangWatch env vars.
|
||||
|
||||
Raises ``ValueError`` when ``LANGWATCH_API_KEY`` is unset: LangWatch rejects
|
||||
unauthenticated exports, so a keyless exporter would only produce 401s.
|
||||
"""
|
||||
settings: Final = _LangWatchSettings()
|
||||
if not settings.api_key:
|
||||
raise ValueError("LANGWATCH_API_KEY environment variable is required for the LangWatch integration.")
|
||||
return (
|
||||
langwatch_otel_traces_endpoint(settings.endpoint),
|
||||
f"Authorization=Bearer {settings.api_key}",
|
||||
)
|
||||
|
||||
|
||||
def langwatch_preset(
|
||||
*,
|
||||
config_overrides: OpenTelemetryV2Config | None = None,
|
||||
allow_missing_credentials: bool = False,
|
||||
) -> OpenTelemetryV2Config:
|
||||
endpoint, headers = get_langwatch_otel_config()
|
||||
base: Final = config_overrides or OpenTelemetryV2Config()
|
||||
return base.model_copy(
|
||||
update={
|
||||
# Drop the console placeholder ``OpenTelemetryV2Config`` folds in when
|
||||
# nothing was configured, so spans go to LangWatch and not to stdout.
|
||||
# An exporter the operator configured on purpose is kept.
|
||||
"exporters": [
|
||||
*(spec for spec in base.exporters if not is_unconfigured_placeholder(spec)),
|
||||
ExporterSpec(
|
||||
kind="otlp_http",
|
||||
endpoint=endpoint,
|
||||
headers=headers,
|
||||
owner=ExporterOwner.LANGWATCH,
|
||||
),
|
||||
],
|
||||
# LangWatch reads the OTel GenAI semantic conventions natively.
|
||||
"mapper_names": ensure_mappers(base.mapper_names, "genai"),
|
||||
}
|
||||
)
|
||||
|
|
@ -97,6 +97,7 @@ class CustomLoggerRegistry:
|
|||
"weave_otel": OpenTelemetry,
|
||||
"levo": OpenTelemetry,
|
||||
"signoz": OpenTelemetry,
|
||||
"langwatch": OpenTelemetry,
|
||||
"mlflow": MlflowLogger,
|
||||
"langfuse": LangfusePromptManagement,
|
||||
"otel": OpenTelemetry,
|
||||
|
|
|
|||
|
|
@ -5142,6 +5142,31 @@ def build_custom_logger_compatible_class(
|
|||
_in_memory_loggers.append(_signoz_logger)
|
||||
return _signoz_logger
|
||||
|
||||
elif logging_integration == "langwatch":
|
||||
_v2 = _maybe_construct_otel_v2("langwatch", _in_memory_loggers)
|
||||
if _v2 is not None:
|
||||
return _v2
|
||||
from litellm.integrations.opentelemetry import (
|
||||
OpenTelemetry,
|
||||
OpenTelemetryConfig,
|
||||
)
|
||||
from litellm.integrations.otel.presets.langwatch import (
|
||||
get_langwatch_otel_config,
|
||||
)
|
||||
|
||||
langwatch_endpoint, langwatch_headers = get_langwatch_otel_config()
|
||||
otel_config = OpenTelemetryConfig(
|
||||
exporter="otlp_http",
|
||||
endpoint=langwatch_endpoint,
|
||||
headers=langwatch_headers,
|
||||
)
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, OpenTelemetry) and callback.callback_name == "langwatch":
|
||||
return callback
|
||||
_langwatch_otel_logger: Final = OpenTelemetry(config=otel_config, callback_name="langwatch")
|
||||
_in_memory_loggers.append(_langwatch_otel_logger)
|
||||
return _langwatch_otel_logger
|
||||
|
||||
elif logging_integration == "mlflow":
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, MlflowLogger):
|
||||
|
|
@ -5685,6 +5710,13 @@ def get_custom_logger_compatible_class(
|
|||
if isinstance(callback, PROXY_DynamicRateLimitHandlerV3):
|
||||
return callback
|
||||
|
||||
elif logging_integration == "langwatch":
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry
|
||||
from litellm.integrations.otel.logger import OpenTelemetryV2
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, (OpenTelemetryV2, OpenTelemetry)) and callback.callback_name == "langwatch":
|
||||
return callback
|
||||
elif logging_integration == "langtrace":
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry
|
||||
|
||||
|
|
|
|||
151
tests/unit/integrations/otel/test_otel_v2_langwatch_preset.py
Normal file
151
tests/unit/integrations/otel/test_otel_v2_langwatch_preset.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""LangWatch preset tests: env parsing, cloud and self-hosted endpoints, auth
|
||||
header, registration, and the V2 / legacy factory paths."""
|
||||
|
||||
import contextvars
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("opentelemetry")
|
||||
|
||||
import litellm # noqa: E402
|
||||
from litellm.integrations.otel.model.config import ( # noqa: E402
|
||||
ExporterOwner,
|
||||
ExporterSpec,
|
||||
OpenTelemetryV2Config,
|
||||
is_otel_v2_enabled,
|
||||
)
|
||||
from litellm.integrations.otel.presets import ( # noqa: E402
|
||||
DYNAMIC_HEADERS_BY_CALLBACK,
|
||||
PRESET_BY_CALLBACK,
|
||||
)
|
||||
from litellm.integrations.otel.presets.langwatch import ( # noqa: E402
|
||||
get_langwatch_otel_config,
|
||||
langwatch_otel_traces_endpoint,
|
||||
langwatch_preset,
|
||||
)
|
||||
|
||||
_CLOUD_TRACES: str = "https://app.langwatch.ai/api/otel/v1/traces"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def langwatch_env(monkeypatch):
|
||||
monkeypatch.setenv("LANGWATCH_API_KEY", "lw-test-key")
|
||||
monkeypatch.delenv("LANGWATCH_ENDPOINT", raising=False)
|
||||
# Isolate from any operator-level OTel exporter config in the environment.
|
||||
for name in [name for name in os.environ if name.startswith("OTEL_")]:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
return monkeypatch
|
||||
|
||||
|
||||
def _langwatch_spec(cfg: OpenTelemetryV2Config) -> ExporterSpec:
|
||||
specs = [spec for spec in cfg.exporters if spec.owner == ExporterOwner.LANGWATCH]
|
||||
assert len(specs) == 1
|
||||
return specs[0]
|
||||
|
||||
|
||||
def test_preset_exports_to_langwatch_cloud_with_bearer_key(langwatch_env):
|
||||
cfg = langwatch_preset()
|
||||
|
||||
spec = _langwatch_spec(cfg)
|
||||
assert spec.kind == "otlp_http"
|
||||
assert spec.endpoint == _CLOUD_TRACES
|
||||
assert spec.headers == "Authorization=Bearer lw-test-key"
|
||||
# Canonical GenAI vocabulary only; no vendor-specific mapper is layered on.
|
||||
assert cfg.mapper_names[0] == "genai"
|
||||
assert set(cfg.mapper_names) <= {"genai", "legacy"}
|
||||
|
||||
|
||||
def test_preset_drops_the_unconfigured_console_placeholder(langwatch_env):
|
||||
# With nothing else configured, spans must go to LangWatch only, not also to stdout.
|
||||
cfg = langwatch_preset()
|
||||
|
||||
assert [spec.owner for spec in cfg.exporters] == [ExporterOwner.LANGWATCH]
|
||||
|
||||
|
||||
def test_preset_keeps_an_exporter_the_operator_configured(langwatch_env):
|
||||
base = OpenTelemetryV2Config(exporter="otlp_http", endpoint="http://collector.local:4318")
|
||||
|
||||
cfg = langwatch_preset(config_overrides=base)
|
||||
|
||||
assert [spec.endpoint for spec in cfg.exporters] == ["http://collector.local:4318", _CLOUD_TRACES]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("configured", "expected"),
|
||||
[
|
||||
("https://langwatch.internal.example.com", "https://langwatch.internal.example.com/api/otel/v1/traces"),
|
||||
("https://langwatch.internal.example.com/", "https://langwatch.internal.example.com/api/otel/v1/traces"),
|
||||
("http://localhost:5560", "http://localhost:5560/api/otel/v1/traces"),
|
||||
("langwatch.internal.example.com", "https://langwatch.internal.example.com/api/otel/v1/traces"),
|
||||
(
|
||||
"https://langwatch.internal.example.com/api/otel/v1/traces",
|
||||
"https://langwatch.internal.example.com/api/otel/v1/traces",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_self_hosted_endpoint_gets_the_otel_traces_path(langwatch_env, configured, expected):
|
||||
langwatch_env.setenv("LANGWATCH_ENDPOINT", configured)
|
||||
|
||||
assert _langwatch_spec(langwatch_preset()).endpoint == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unset", [None, "", " "])
|
||||
def test_endpoint_defaults_to_langwatch_cloud(unset):
|
||||
assert langwatch_otel_traces_endpoint(unset) == _CLOUD_TRACES
|
||||
|
||||
|
||||
def test_missing_api_key_raises(monkeypatch):
|
||||
monkeypatch.delenv("LANGWATCH_API_KEY", raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match="LANGWATCH_API_KEY"):
|
||||
get_langwatch_otel_config()
|
||||
with pytest.raises(ValueError, match="LANGWATCH_API_KEY"):
|
||||
langwatch_preset()
|
||||
|
||||
|
||||
def test_langwatch_is_a_registered_callback():
|
||||
assert PRESET_BY_CALLBACK["langwatch"] is langwatch_preset
|
||||
assert ExporterOwner("langwatch") is ExporterOwner.LANGWATCH
|
||||
assert "langwatch" in litellm._known_custom_logger_compatible_callbacks
|
||||
# No per-request credentials in this version: every request uses the operator's key.
|
||||
assert "langwatch" not in DYNAMIC_HEADERS_BY_CALLBACK
|
||||
|
||||
|
||||
def _build_callback(monkeypatch, *, v2: bool):
|
||||
from litellm.litellm_core_utils import litellm_logging
|
||||
|
||||
monkeypatch.setenv("LITELLM_OTEL_V2", "true" if v2 else "false")
|
||||
monkeypatch.setattr(litellm_logging, "_in_memory_loggers", [])
|
||||
is_otel_v2_enabled.cache_clear()
|
||||
try:
|
||||
return contextvars.copy_context().run(
|
||||
litellm_logging._init_custom_logger_compatible_class,
|
||||
"langwatch",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
finally:
|
||||
is_otel_v2_enabled.cache_clear()
|
||||
|
||||
|
||||
def test_v2_factory_builds_a_langwatch_otel_v2_logger(langwatch_env):
|
||||
from litellm.integrations.otel.logger import OpenTelemetryV2
|
||||
|
||||
logger = _build_callback(langwatch_env, v2=True)
|
||||
|
||||
assert isinstance(logger, OpenTelemetryV2)
|
||||
assert logger.callback_name == "langwatch"
|
||||
assert _langwatch_spec(logger.config).endpoint == _CLOUD_TRACES
|
||||
|
||||
|
||||
def test_legacy_factory_exports_to_langwatch_when_v2_is_off(langwatch_env):
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry
|
||||
|
||||
logger = _build_callback(langwatch_env, v2=False)
|
||||
|
||||
assert type(logger) is OpenTelemetry
|
||||
assert logger.callback_name == "langwatch"
|
||||
assert logger.OTEL_EXPORTER == "otlp_http"
|
||||
assert logger.OTEL_ENDPOINT == _CLOUD_TRACES
|
||||
assert logger.OTEL_HEADERS == "Authorization=Bearer lw-test-key"
|
||||
Loading…
Add table
Reference in a new issue