feat(otel): add SigNoz preset for OpenTelemetry v2

Adds a `signoz` callback so LiteLLM can export traces to SigNoz over OTLP,
alongside the existing vendor presets.

SigNoz Cloud and self-hosted share one endpoint field, `SIGNOZ_INGESTION_ENDPOINT`,
with an optional `SIGNOZ_INGESTION_KEY`. There is no default host: the dispatch
branch raises a ValueError naming the missing variable, matching how LangTrace,
Logfire and Arize validate, so nothing is exported until a destination is set.
`requires_headers` follows the key, since SigNoz Cloud rejects unauthenticated
exports while a self-hosted collector accepts them.

Per-team credentials are supported through `signoz_ingestion_key`, with a tile in
the proxy admin UI. Teams supply a key and never a destination, so the operator's
endpoint stays authoritative and no per-request endpoint resolver is registered.

The legacy fallback resolves the signal path through `_otlp_traces_endpoint` and
passes the key on `OpenTelemetryConfig.headers` rather than exporting it to
`OTEL_EXPORTER_OTLP_TRACES_HEADERS`, keeping it off a process-global var that
another backend's exporter would read.
This commit is contained in:
Nagesh Bansal 2026-08-25 17:57:14 +05:30
parent f005afa146
commit c3b80de7f8
No known key found for this signature in database
GPG key ID: 87AE46945352DBF2
12 changed files with 201 additions and 0 deletions

View file

@ -163,6 +163,7 @@ _custom_logger_compatible_callbacks_literal = Literal[
"levo",
"compression_interception",
"newrelic",
"signoz",
]
cold_storage_custom_logger: Optional[_custom_logger_compatible_callbacks_literal] = None
logged_real_time_event_types: Optional[Union[List[str], Literal["*"]]] = None

View file

@ -404,6 +404,21 @@
},
"description": "S3 Bucket (AWS) Logging Integration"
},
{
"id": "signoz",
"displayName": "SigNoz",
"logo": "signoz.svg",
"supports_key_team_logging": true,
"dynamic_params": {
"signoz_ingestion_key": {
"type": "password",
"ui_name": "SigNoz Ingestion Key",
"description": "Per-team ingestion key. Team traces export to this key's SigNoz account over OTLP.",
"required": false
}
},
"description": "SigNoz OpenTelemetry Observability Integration"
},
{
"id": "sqs",
"displayName": "SQS",

View file

@ -40,6 +40,7 @@ class ExporterOwner(str, Enum):
LEVO = "levo"
AGENTOPS = "agentops"
NEWRELIC = "newrelic"
SIGNOZ = "signoz"
class _OTelV2Flag(BaseSettings):

View file

@ -30,6 +30,10 @@ from litellm.integrations.otel.presets.phoenix import (
phoenix_preset,
phoenix_project_headers,
)
from litellm.integrations.otel.presets.signoz import (
signoz_dynamic_headers,
signoz_preset,
)
from litellm.integrations.otel.presets.weave import weave_dynamic_headers, weave_preset
from litellm.types.utils import StandardCallbackDynamicParams
@ -44,6 +48,7 @@ PRESET_BY_CALLBACK: Final[Mapping[str, Preset]] = MappingProxyType(
"langtrace": langtrace_preset,
"levo": levo_preset,
"newrelic": newrelic_preset,
"signoz": signoz_preset,
"weave_otel": weave_preset,
}
)
@ -58,6 +63,7 @@ DYNAMIC_HEADERS_BY_CALLBACK: Final[Mapping[str, Callable[[StandardCallbackDynami
"arize": arize_dynamic_headers,
"langfuse_otel": langfuse_dynamic_headers,
"newrelic": newrelic_dynamic_headers,
"signoz": signoz_dynamic_headers,
"weave_otel": weave_dynamic_headers,
}
)
@ -153,5 +159,6 @@ __all__ = [
"newrelic_preset",
"phoenix_preset",
"project_routing_headers",
"signoz_preset",
"weave_preset",
]

View file

@ -0,0 +1,57 @@
"""SigNoz preset — OTLP/HTTP exporter to SigNoz + GenAI vocabulary."""
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
from litellm.types.utils import StandardCallbackDynamicParams
SIGNOZ_INGESTION_ENDPOINT_ENV: Final = "SIGNOZ_INGESTION_ENDPOINT"
class _SigNozSettings(BaseSettings):
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
# One endpoint for both SigNoz Cloud and self-hosted, with no default host, so
# nothing exports until the operator names a destination.
endpoint: str | None = Field(default=None, validation_alias=SIGNOZ_INGESTION_ENDPOINT_ENV)
ingestion_key: str | None = Field(default=None, validation_alias="SIGNOZ_INGESTION_KEY")
def signoz_preset(
*,
config_overrides: OpenTelemetryV2Config | None = None,
) -> OpenTelemetryV2Config:
settings: Final = _SigNozSettings()
base: Final = config_overrides or OpenTelemetryV2Config()
key: Final = settings.ingestion_key
return base.model_copy(
update={
"exporters": [
*base.exporters,
ExporterSpec(
kind="otlp_http",
endpoint=settings.endpoint,
headers=(f"signoz-ingestion-key={key}" if key else None),
owner=ExporterOwner.SIGNOZ,
# Cloud rejects keyless exports; a self-hosted collector accepts them.
requires_headers=bool(key),
),
],
# SigNoz ingests the OTLP GenAI semantic conventions natively.
"mapper_names": ensure_mappers(base.mapper_names, "genai"),
}
)
def signoz_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str, str]:
"""Per-request SigNoz OTLP headers from team/key dynamic params."""
ingestion_key: Final = params.get("signoz_ingestion_key")
return {header: value for header, value in (("signoz-ingestion-key", ingestion_key),) if value}

View file

@ -74,6 +74,7 @@ _supported_callback_params: Final[tuple[str, ...]] = (
"dd_agent_port",
"newrelic_api_key",
"newrelic_region",
"signoz_ingestion_key",
"turn_off_message_logging",
)
@ -87,6 +88,7 @@ _request_blocked_callback_params: Final = frozenset(
"dd_agent_port",
"newrelic_api_key",
"newrelic_region",
"signoz_ingestion_key",
}
)
@ -99,6 +101,7 @@ _trusted_overlay_callback_params: Final = frozenset(
{
"newrelic_api_key",
"newrelic_region",
"signoz_ingestion_key",
}
)

View file

@ -4338,6 +4338,40 @@ def _init_custom_logger_compatible_class(
_in_memory_loggers.append(_otel_logger)
return _otel_logger
elif logging_integration == "signoz":
from litellm.integrations.otel.presets.signoz import (
SIGNOZ_INGESTION_ENDPOINT_ENV,
)
_signoz_endpoint = os.getenv(SIGNOZ_INGESTION_ENDPOINT_ENV)
if not _signoz_endpoint:
raise ValueError(f"{SIGNOZ_INGESTION_ENDPOINT_ENV} not found in environment variables")
_v2 = _maybe_construct_otel_v2("signoz", _in_memory_loggers)
if _v2 is not None:
return _v2
from litellm.integrations.opentelemetry import (
OpenTelemetry,
OpenTelemetryConfig,
)
from litellm.integrations.otel.plumbing.providers import (
_otlp_traces_endpoint,
)
_signoz_key = os.getenv("SIGNOZ_INGESTION_KEY")
otel_config = OpenTelemetryConfig(
exporter="otlp_http",
endpoint=_otlp_traces_endpoint(_signoz_endpoint),
headers=(f"signoz-ingestion-key={_signoz_key}" if _signoz_key else None),
)
for callback in _in_memory_loggers:
if isinstance(callback, OpenTelemetry) and callback.callback_name == "signoz":
return callback
_signoz_otel_logger = OpenTelemetry(config=otel_config, callback_name="signoz")
_in_memory_loggers.append(_signoz_otel_logger)
return _signoz_otel_logger
elif logging_integration == "mlflow":
for callback in _in_memory_loggers:
if isinstance(callback, MlflowLogger):

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 21 22"><rect width="21" height="21" y=".5" fill="url(#a)" rx="4.2"/><g fill="#fff" fill-rule="evenodd" clip-rule="evenodd" filter="url(#b)"><path d="M2.31 10.988v-.005l.002-.008.022-.088q.022-.082.073-.225c.069-.188.182-.448.364-.76.363-.623.998-1.445 2.089-2.3a9.3 9.3 0 0 1 2.957-1.569A7 7 0 0 1 8.78 5.8a5 5 0 0 1 .377-.048h.009l.001-.001.028.466.028.465-.065.006q-.076.008-.225.033a6 6 0 0 0-.72.169c-.576.197-1.4.583-2.362 1.336-.96.751-1.5 1.46-1.798 1.972a4 4 0 0 0-.29.607A2 2 0 0 0 3.7 11l.01.036q.013.054.051.16c.052.142.141.35.29.607.3.512.839 1.221 1.799 1.972.962.753 1.786 1.139 2.362 1.336l.027.01c.286.082.523.13.693.16a4 4 0 0 0 .29.038l-.028.465-.028.466h-.01l-.1-.01a5 5 0 0 1-.277-.04 7 7 0 0 1-.963-.233 9.3 9.3 0 0 1-2.957-1.57c-1.091-.854-1.726-1.676-2.09-2.3a5 5 0 0 1-.363-.76 3 3 0 0 1-.095-.312l-.001-.008-.001-.003v-.026m16.381 0v-.002l-.001-.003-.002-.008a2 2 0 0 0-.095-.313 5 5 0 0 0-.363-.76c-.364-.623-.998-1.445-2.09-2.3a9.3 9.3 0 0 0-2.957-1.569 7 7 0 0 0-.963-.234 5 5 0 0 0-.376-.048h-.01l-.001-.001-.028.466-.028.465.011.001.054.005q.076.008.225.033a6 6 0 0 1 .72.169c.577.197 1.4.583 2.362 1.336.96.751 1.5 1.46 1.799 1.972.149.257.238.466.29.607a2 2 0 0 1 .061.196l-.01.036q-.013.054-.051.16c-.052.142-.141.35-.29.607-.299.512-.839 1.221-1.799 1.972-.962.753-1.785 1.139-2.362 1.336l-.027.01a6 6 0 0 1-.693.16 4 4 0 0 1-.29.038l.028.465.028.466h.011l.1-.01q.1-.01.276-.04c.235-.04.566-.11.963-.233a9.3 9.3 0 0 0 2.957-1.57c1.092-.854 1.726-1.676 2.09-2.3.181-.31.294-.571.363-.76a3 3 0 0 0 .095-.312l.002-.008v-.029"/><path d="M14.351 11c0 2.067-1.664 3.743-3.717 3.743S6.918 13.067 6.918 11s1.664-3.744 3.716-3.744S14.352 8.932 14.352 11M9.705 8.894s-.446.526-.58.936c-.087.264-.117.702-.117.702H7.847s0-.351.232-.936.465-.702.465-.702zm-.58 3.275c.134.41.58.936.58.936H8.544s-.233-.117-.465-.702-.232-.936-.232-.936h1.161s.03.439.116.702"/></g><defs><radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="rotate(45.69 -7.805 17.962)scale(12.3258)" gradientUnits="userSpaceOnUse"><stop offset=".33" stop-color="#FF5E19"/><stop offset="1" stop-color="#FF2929"/></radialGradient><filter id="b" width="18.9" height="13.02" x="1.05" y="5.33" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy=".84"/><feGaussianBlur stdDeviation=".63"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0.368384 0 0 0 0 0.0623777 0 0 0 0 0.0623777 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_2420_727"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_2420_727" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy=".84"/><feGaussianBlur stdDeviation=".63"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/><feBlend in2="shape" result="effect2_innerShadow_2420_727"/></filter></defs></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -3305,6 +3305,10 @@ class StandardCallbackDynamicParams(TypedDict, total=False):
newrelic_api_key: str | None # writable-ok: initialize_standard_callback_dynamic_params assigns into the dict
newrelic_region: str | None # writable-ok: initialize_standard_callback_dynamic_params assigns into the dict
# SigNoz dynamic params (proxy-stamped team/key callback vars only;
# request-supplied values are blocked)
signoz_ingestion_key: str | None # writable-ok: initialize_standard_callback_dynamic_params assigns into the dict
# Logging settings
turn_off_message_logging: bool | None # when true will not log messages
litellm_disabled_callbacks: list[str] | None

View file

@ -458,3 +458,24 @@ def test_newrelic_key_only_team_routes_to_us_not_operator_region(monkeypatch):
)
owned = next(e for e in new_cfg.exporters if e.owner == "newrelic")
assert owned.endpoint == "https://otlp.nr-data.net"
def test_signoz_dynamic_headers_stamp_ingestion_key():
from litellm.integrations.otel.presets import dynamic_otlp_headers
assert dynamic_otlp_headers("signoz", {"signoz_ingestion_key": "team-key"}) == {
"signoz-ingestion-key": "team-key"
}
# No key means no per-request routing; the caller keeps its default tracer.
assert dynamic_otlp_headers("signoz", {}) is None
def test_signoz_registers_no_dynamic_endpoint_resolver():
# A team supplies a key, never a destination, so the operator's endpoint wins.
from litellm.integrations.otel.presets import (
DYNAMIC_ENDPOINT_BY_CALLBACK,
dynamic_otlp_endpoint,
)
assert "signoz" not in DYNAMIC_ENDPOINT_BY_CALLBACK
assert dynamic_otlp_endpoint("signoz", {"signoz_ingestion_key": "k"}) is None

View file

@ -212,3 +212,59 @@ def test_newrelic_preset_unset_content_knob_keeps_default(monkeypatch):
from litellm.integrations.otel.presets.newrelic import newrelic_preset
assert newrelic_preset().capture_span_content is False
def test_signoz_preset_reads_env_endpoint_and_key(monkeypatch):
monkeypatch.setenv("SIGNOZ_INGESTION_ENDPOINT", "https://ingest.eu.signoz.cloud:443")
monkeypatch.setenv("SIGNOZ_INGESTION_KEY", "env-ingestion-key")
from litellm.integrations.otel.model.config import ExporterOwner
from litellm.integrations.otel.presets.signoz import signoz_preset
cfg = signoz_preset()
spec = next(e for e in cfg.exporters if e.owner == ExporterOwner.SIGNOZ)
assert spec.kind == "otlp_http"
assert spec.endpoint == "https://ingest.eu.signoz.cloud:443"
assert spec.headers == "signoz-ingestion-key=env-ingestion-key"
assert spec.requires_headers is True
assert "genai" in cfg.mapper_names
def test_signoz_preset_without_key_is_self_hosted(monkeypatch):
# A self-hosted collector accepts unauthenticated OTLP, so requiring headers
# would drop exports that would have succeeded.
monkeypatch.setenv("SIGNOZ_INGESTION_ENDPOINT", "http://signoz-collector.internal:4318")
monkeypatch.delenv("SIGNOZ_INGESTION_KEY", raising=False)
from litellm.integrations.otel.model.config import ExporterOwner
from litellm.integrations.otel.presets.signoz import signoz_preset
cfg = signoz_preset()
spec = next(e for e in cfg.exporters if e.owner == ExporterOwner.SIGNOZ)
assert spec.endpoint == "http://signoz-collector.internal:4318"
assert spec.headers is None
assert spec.requires_headers is False
def test_signoz_preset_has_no_default_endpoint(monkeypatch):
# No region table and no default host: the preset never invents a destination.
monkeypatch.delenv("SIGNOZ_INGESTION_ENDPOINT", raising=False)
monkeypatch.delenv("SIGNOZ_INGESTION_KEY", raising=False)
from litellm.integrations.otel.model.config import ExporterOwner
from litellm.integrations.otel.presets.signoz import signoz_preset
cfg = signoz_preset()
spec = next(e for e in cfg.exporters if e.owner == ExporterOwner.SIGNOZ)
assert spec.endpoint is None
def test_signoz_preset_endpoint_passed_through_verbatim(monkeypatch):
# The plumbing appends the signal path, so pre-appending would double it.
monkeypatch.setenv("SIGNOZ_INGESTION_ENDPOINT", "https://ingest.us.signoz.cloud:443/v1/traces")
monkeypatch.delenv("SIGNOZ_INGESTION_KEY", raising=False)
from litellm.integrations.otel.model.config import ExporterOwner
from litellm.integrations.otel.plumbing.providers import _otlp_traces_endpoint
from litellm.integrations.otel.presets.signoz import signoz_preset
cfg = signoz_preset()
spec = next(e for e in cfg.exporters if e.owner == ExporterOwner.SIGNOZ)
assert spec.endpoint == "https://ingest.us.signoz.cloud:443/v1/traces"
assert _otlp_traces_endpoint(spec.endpoint) == "https://ingest.us.signoz.cloud:443/v1/traces"

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 21 22"><rect width="21" height="21" y=".5" fill="url(#a)" rx="4.2"/><g fill="#fff" fill-rule="evenodd" clip-rule="evenodd" filter="url(#b)"><path d="M2.31 10.988v-.005l.002-.008.022-.088q.022-.082.073-.225c.069-.188.182-.448.364-.76.363-.623.998-1.445 2.089-2.3a9.3 9.3 0 0 1 2.957-1.569A7 7 0 0 1 8.78 5.8a5 5 0 0 1 .377-.048h.009l.001-.001.028.466.028.465-.065.006q-.076.008-.225.033a6 6 0 0 0-.72.169c-.576.197-1.4.583-2.362 1.336-.96.751-1.5 1.46-1.798 1.972a4 4 0 0 0-.29.607A2 2 0 0 0 3.7 11l.01.036q.013.054.051.16c.052.142.141.35.29.607.3.512.839 1.221 1.799 1.972.962.753 1.786 1.139 2.362 1.336l.027.01c.286.082.523.13.693.16a4 4 0 0 0 .29.038l-.028.465-.028.466h-.01l-.1-.01a5 5 0 0 1-.277-.04 7 7 0 0 1-.963-.233 9.3 9.3 0 0 1-2.957-1.57c-1.091-.854-1.726-1.676-2.09-2.3a5 5 0 0 1-.363-.76 3 3 0 0 1-.095-.312l-.001-.008-.001-.003v-.026m16.381 0v-.002l-.001-.003-.002-.008a2 2 0 0 0-.095-.313 5 5 0 0 0-.363-.76c-.364-.623-.998-1.445-2.09-2.3a9.3 9.3 0 0 0-2.957-1.569 7 7 0 0 0-.963-.234 5 5 0 0 0-.376-.048h-.01l-.001-.001-.028.466-.028.465.011.001.054.005q.076.008.225.033a6 6 0 0 1 .72.169c.577.197 1.4.583 2.362 1.336.96.751 1.5 1.46 1.799 1.972.149.257.238.466.29.607a2 2 0 0 1 .061.196l-.01.036q-.013.054-.051.16c-.052.142-.141.35-.29.607-.299.512-.839 1.221-1.799 1.972-.962.753-1.785 1.139-2.362 1.336l-.027.01a6 6 0 0 1-.693.16 4 4 0 0 1-.29.038l.028.465.028.466h.011l.1-.01q.1-.01.276-.04c.235-.04.566-.11.963-.233a9.3 9.3 0 0 0 2.957-1.57c1.092-.854 1.726-1.676 2.09-2.3.181-.31.294-.571.363-.76a3 3 0 0 0 .095-.312l.002-.008v-.029"/><path d="M14.351 11c0 2.067-1.664 3.743-3.717 3.743S6.918 13.067 6.918 11s1.664-3.744 3.716-3.744S14.352 8.932 14.352 11M9.705 8.894s-.446.526-.58.936c-.087.264-.117.702-.117.702H7.847s0-.351.232-.936.465-.702.465-.702zm-.58 3.275c.134.41.58.936.58.936H8.544s-.233-.117-.465-.702-.232-.936-.232-.936h1.161s.03.439.116.702"/></g><defs><radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="rotate(45.69 -7.805 17.962)scale(12.3258)" gradientUnits="userSpaceOnUse"><stop offset=".33" stop-color="#FF5E19"/><stop offset="1" stop-color="#FF2929"/></radialGradient><filter id="b" width="18.9" height="13.02" x="1.05" y="5.33" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy=".84"/><feGaussianBlur stdDeviation=".63"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0.368384 0 0 0 0 0.0623777 0 0 0 0 0.0623777 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_2420_727"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_2420_727" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy=".84"/><feGaussianBlur stdDeviation=".63"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/><feBlend in2="shape" result="effect2_innerShadow_2420_727"/></filter></defs></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB