diff --git a/litellm/__init__.py b/litellm/__init__.py index ff901ede512..d9ac7e9a8fa 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -174,6 +174,7 @@ _custom_logger_compatible_callbacks_literal = Literal[ "ternary", "posthog", "levo", + "langwatch", "compression_interception", "newrelic", "signoz", diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py index cca12b885c1..2a7af0a9078 100644 --- a/litellm/integrations/otel/model/config.py +++ b/litellm/integrations/otel/model/config.py @@ -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" diff --git a/litellm/integrations/otel/presets/__init__.py b/litellm/integrations/otel/presets/__init__.py index 7c891c29409..2cad500c81d 100644 --- a/litellm/integrations/otel/presets/__init__.py +++ b/litellm/integrations/otel/presets/__init__.py @@ -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", diff --git a/litellm/integrations/otel/presets/langwatch.py b/litellm/integrations/otel/presets/langwatch.py new file mode 100644 index 00000000000..95055408cad --- /dev/null +++ b/litellm/integrations/otel/presets/langwatch.py @@ -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"), + } + ) diff --git a/litellm/litellm_core_utils/custom_logger_registry.py b/litellm/litellm_core_utils/custom_logger_registry.py index 74badc2c8c1..0769004434b 100644 --- a/litellm/litellm_core_utils/custom_logger_registry.py +++ b/litellm/litellm_core_utils/custom_logger_registry.py @@ -97,6 +97,7 @@ class CustomLoggerRegistry: "weave_otel": OpenTelemetry, "levo": OpenTelemetry, "signoz": OpenTelemetry, + "langwatch": OpenTelemetry, "mlflow": MlflowLogger, "langfuse": LangfusePromptManagement, "otel": OpenTelemetry, diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index e766a2be9c3..b25976b2c06 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -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 diff --git a/tests/unit/integrations/otel/test_otel_v2_langwatch_preset.py b/tests/unit/integrations/otel/test_otel_v2_langwatch_preset.py new file mode 100644 index 00000000000..0c8c6fa2b99 --- /dev/null +++ b/tests/unit/integrations/otel/test_otel_v2_langwatch_preset.py @@ -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"