feat(otel): add LangWatch preset to OpenTelemetry v2

This commit is contained in:
Rogério Chaves 2026-09-25 10:47:28 +00:00 • committed by Rogério Chaves
parent cb7e6a7f4c
commit e3c6ff4f1a
7 changed files with 273 additions and 1 deletions

View file

@ -174,6 +174,7 @@ _custom_logger_compatible_callbacks_literal = Literal[
"ternary",
"posthog",
"levo",
"langwatch",
"compression_interception",
"newrelic",
"signoz",

View file

@ -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"

View file

@ -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",

View 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"),
}
)

View file

@ -97,6 +97,7 @@ class CustomLoggerRegistry:
"weave_otel": OpenTelemetry,
"levo": OpenTelemetry,
"signoz": OpenTelemetry,
"langwatch": OpenTelemetry,
"mlflow": MlflowLogger,
"langfuse": LangfusePromptManagement,
"otel": OpenTelemetry,

View file

@ -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

View 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"