feat(otel): add Mixpanel OpenTelemetry v2 preset callback

This commit is contained in:
Nausherwan Korai 2026-10-27 19:41:46 +00:00
parent cfe0100e1f
commit 0ef9a94d60
32 changed files with 625 additions and 5 deletions

View file

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

View file

@ -309,6 +309,27 @@
},
"description": "Langsmith Logging Integration"
},
{
"id": "mixpanel",
"displayName": "Mixpanel",
"logo": "mixpanel.svg",
"supports_key_team_logging": true,
"dynamic_params": {
"mixpanel_project_token": {
"type": "password",
"ui_name": "Mixpanel Project Token",
"description": "Mixpanel project token for this team or key. Traces export to this project over OTLP.",
"required": false
},
"mixpanel_region": {
"type": "text",
"ui_name": "Mixpanel Region (us, eu, or in)",
"description": "Data residency region for this project (us, eu, or in). Defaults to us.",
"required": false
}
},
"description": "Mixpanel Agent Intelligence OpenTelemetry Integration. Setup: https://docs.mixpanel.com/docs/agent-intelligence"
},
{
"id": "newrelic",
"displayName": "New Relic",

View file

@ -60,6 +60,7 @@ from litellm.integrations.otel.model.semconv import (
NetworkTransport,
RpcSystem,
Server,
User,
resolve_operation,
resolve_output_type,
resolve_provider,
@ -113,6 +114,7 @@ __all__ = [
"SpanError",
"SpanRole",
"SpanSpec",
"User",
"db_system",
"is_mcp_list_tools",
"is_mcp_tool_call",

View file

@ -18,6 +18,7 @@ from litellm.integrations.otel.mappers.genai import GenAIMapper
from litellm.integrations.otel.mappers.langfuse import LangfuseMapper
from litellm.integrations.otel.mappers.langtrace import LangtraceMapper
from litellm.integrations.otel.mappers.legacy import LegacyMapper
from litellm.integrations.otel.mappers.mixpanel import MixpanelMapper
from litellm.integrations.otel.mappers.openinference import OpenInferenceMapper
from litellm.integrations.otel.mappers.utils import tool_attr_budget
from litellm.integrations.otel.mappers.weave import WeaveMapper
@ -35,6 +36,7 @@ _PLAIN_MAPPERS: Final[dict[str, Callable[[], AttributeMapper]]] = {
"langfuse": LangfuseMapper,
"weave": WeaveMapper,
"langtrace": LangtraceMapper,
"mixpanel": MixpanelMapper,
}
@ -63,6 +65,7 @@ __all__ = [
"LangfuseMapper",
"LangtraceMapper",
"LegacyMapper",
"MixpanelMapper",
"OpenInferenceMapper",
"WeaveMapper",
"resolve_mappers",

View file

@ -0,0 +1,19 @@
from litellm.integrations.otel.mappers.base import AttributeMap, SpanData
from litellm.integrations.otel.mappers.utils import drop_none_pairs
from litellm.integrations.otel.model.payloads import (
LLMCallSpanData,
MCPListToolsSpanData,
MCPToolCallSpanData,
)
from litellm.integrations.otel.model.semconv import User
class MixpanelMapper:
def map(self, data: SpanData) -> AttributeMap:
match data:
case LLMCallSpanData():
return drop_none_pairs(((User.ID, data.identity.user_id or data.trace.user_id or None),))
case MCPToolCallSpanData() | MCPListToolsSpanData():
return drop_none_pairs(((User.ID, data.identity.user_id or None),))
case _:
return drop_none_pairs(())

View file

@ -19,7 +19,7 @@ from types import MappingProxyType
from typing import Final
from litellm.integrations.otel.model.metadata import RequestIdentity, allowlisted_metadata
from litellm.integrations.otel.model.semconv import GenAI, LiteLLM
from litellm.integrations.otel.model.semconv import GenAI, LiteLLM, User
# Attribute key -> value extractor over (identity, request_model,
# team_metadata_keys). The single definition of what may be promoted and under
@ -33,6 +33,7 @@ _PROMOTABLE: Final[dict[str, Callable[[RequestIdentity, str | None, tuple[str, .
),
LiteLLM.KEY_HASH: lambda identity, model, team_metadata_keys: identity.key_hash,
LiteLLM.END_USER: lambda identity, model, team_metadata_keys: identity.end_user,
User.ID: lambda identity, model, team_metadata_keys: identity.user_id,
GenAI.REQUEST_MODEL: lambda identity, model, team_metadata_keys: model,
LiteLLM.PROVIDER_MODEL: lambda identity, model, team_metadata_keys: identity.provider_model,
}

View file

@ -44,6 +44,7 @@ class ExporterOwner(str, Enum):
AGENTOPS = "agentops"
NEWRELIC = "newrelic"
SIGNOZ = "signoz"
MIXPANEL = "mixpanel"
class _OTelV2Flag(BaseSettings):

View file

@ -62,6 +62,14 @@ _AUTH_ATTRIBUTE: Final = TypeAdapter(object)
REQUESTER_METADATA_KEY: Final = "requester_metadata"
REQUESTER_METADATA_PATH: Final = f"{REQUESTER_METADATA_KEY}."
_USER_ID_METADATA_KEYS: Final[tuple[str, ...]] = (
"requester_metadata.user.id",
"user.id",
"requester_metadata.user_id",
"user_id",
"requester_metadata.trace_user_id",
"trace_user_id",
)
def allowlisted_metadata(metadata: Mapping[str, str], metadata_keys: tuple[str, ...]) -> Mapping[str, str]:
@ -92,6 +100,14 @@ class RequestIdentity:
request_route: str | None = None
metadata: Mapping[str, str] = field(default_factory=dict)
@property
def user_id(self) -> str | None:
return (
next((value for key in _USER_ID_METADATA_KEYS if (value := self.metadata.get(key))), None)
or self.end_user
or self.metadata.get("user_api_key_end_user_id")
)
@classmethod
def from_payload(cls, payload: StandardLoggingPayload) -> RequestIdentity:
"""Parse caller identity out of a closed request's payload metadata.
@ -101,6 +117,8 @@ class RequestIdentity:
model, not just the user-facing one.
"""
raw_meta: Final = cast(Mapping[str, object], payload.get("metadata") or {})
payload_view: Final = as_str_mapping(payload)
params: Final = as_str_mapping(payload_view.get("model_parameters")) if payload_view is not None else None
metadata: Final = MappingProxyType(dict(flatten_metadata(raw_meta)))
return cls(
call_id=as_str(payload.get("litellm_call_id")) or as_str(payload.get("id")),
@ -111,7 +129,11 @@ class RequestIdentity:
team_alias=as_str(raw_meta.get("user_api_key_team_alias")) or as_str(raw_meta.get("team_alias")),
team_metadata=_team_metadata_dict(raw_meta.get("user_api_key_team_metadata")),
key_hash=as_str(raw_meta.get("user_api_key_hash")),
end_user=as_str(payload.get("end_user")) or as_str(raw_meta.get("user_api_key_end_user_id")),
end_user=(
as_str(payload.get("end_user"))
or as_str(raw_meta.get("user_api_key_end_user_id"))
or (as_str(params.get("user")) if params is not None else None)
),
provider_model=resolve_provider_model(payload),
request_route=as_str(raw_meta.get("user_api_key_request_route")),
metadata=metadata,

View file

@ -254,6 +254,10 @@ class Server:
PORT: Final = "server.port"
class User:
ID: Final = "user.id"
class DB:
"""Database / cache client-span keys (OTel ``db.*`` semconv).

View file

@ -147,7 +147,7 @@ def _otlp_traces_endpoint(endpoint: str | None) -> str | None:
return endpoint
endpoint = endpoint.rstrip("/")
# Splunk Observability uses ``/v2/trace/otlp``; never rewrite it.
if endpoint.endswith("/v1/traces") or "/v2/trace/otlp" in endpoint:
if endpoint.endswith(("/v1/traces", "/v1/ai/otel/traces")) or "/v2/trace/otlp" in endpoint:
return endpoint
for other_signal in ("/v1/logs", "/v1/metrics"):
if endpoint.endswith(other_signal):

View file

@ -21,6 +21,11 @@ from litellm.integrations.otel.presets.langfuse import (
)
from litellm.integrations.otel.presets.langtrace import langtrace_preset
from litellm.integrations.otel.presets.levo import levo_preset
from litellm.integrations.otel.presets.mixpanel import (
mixpanel_dynamic_endpoint,
mixpanel_dynamic_headers,
mixpanel_preset,
)
from litellm.integrations.otel.presets.newrelic import (
newrelic_dynamic_endpoint,
newrelic_dynamic_headers,
@ -48,6 +53,7 @@ PRESET_BY_CALLBACK: Final[Mapping[str, Preset]] = MappingProxyType(
"langfuse_otel": langfuse_preset,
"langtrace": langtrace_preset,
"levo": levo_preset,
"mixpanel": mixpanel_preset,
"newrelic": newrelic_preset,
"signoz": signoz_preset,
"weave_otel": weave_preset,
@ -63,6 +69,7 @@ DYNAMIC_HEADERS_BY_CALLBACK: Final[Mapping[str, Callable[[StandardCallbackDynami
{
"arize": arize_dynamic_headers,
"langfuse_otel": langfuse_dynamic_headers,
"mixpanel": mixpanel_dynamic_headers,
"newrelic": newrelic_dynamic_headers,
"signoz": signoz_dynamic_headers,
"weave_otel": weave_dynamic_headers,
@ -77,6 +84,7 @@ DYNAMIC_HEADERS_BY_CALLBACK: Final[Mapping[str, Callable[[StandardCallbackDynami
DYNAMIC_ENDPOINT_BY_CALLBACK: Final[Mapping[str, Callable[[StandardCallbackDynamicParams], str | None]]] = (
MappingProxyType(
{
"mixpanel": mixpanel_dynamic_endpoint,
"newrelic": newrelic_dynamic_endpoint,
"signoz": signoz_dynamic_endpoint,
}
@ -158,6 +166,7 @@ __all__ = [
"langfuse_preset",
"langtrace_preset",
"levo_preset",
"mixpanel_preset",
"newrelic_preset",
"phoenix_preset",
"project_routing_headers",

View file

@ -80,6 +80,13 @@ def _newrelic_destination(params: StandardCallbackDynamicParams) -> "_Destinatio
return (endpoint, None) if endpoint else None
def _mixpanel_destination(params: StandardCallbackDynamicParams) -> "_Destination | None":
from litellm.integrations.otel.presets.mixpanel import mixpanel_dynamic_endpoint
endpoint: Final = mixpanel_dynamic_endpoint(params)
return (endpoint, None) if endpoint else None
#: Callback name -> destination resolver. A backend is destination-capable exactly
#: when it appears here AND in ``DYNAMIC_HEADERS_BY_CALLBACK``: without a header
#: builder the destination would carry no tenant credentials, and the exporter
@ -91,6 +98,7 @@ _DESTINATION_BY_CALLBACK: Final[Mapping[str, Callable[[StandardCallbackDynamicPa
"arize": _arize_destination,
"weave_otel": _weave_destination,
"newrelic": _newrelic_destination,
"mixpanel": _mixpanel_destination,
}
)
)
@ -105,6 +113,7 @@ _REQUIRED_HEADERS_BY_CALLBACK: Final[Mapping[str, frozenset[str]]] = MappingProx
"arize": frozenset({"arize-space-id", "api_key"}),
"weave_otel": frozenset({"Authorization", "project_id"}),
"newrelic": frozenset({"api-key"}),
"mixpanel": frozenset({"Authorization"}),
}
)

View file

@ -0,0 +1,97 @@
import base64
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from litellm._logging import verbose_logger
from litellm.integrations.otel.model.config import (
ExporterOwner,
ExporterSpec,
OpenTelemetryV2Config,
)
from litellm.integrations.otel.model.semconv import User
from litellm.integrations.otel.presets.utils import ensure_mappers
from litellm.types.utils import StandardCallbackDynamicParams
MIXPANEL_PROJECT_TOKEN_ENV: Final = "MIXPANEL_PROJECT_TOKEN"
MIXPANEL_OTLP_ENDPOINT_BY_REGION: Final[Mapping[str, str]] = MappingProxyType(
{
"us": "https://ingestion-us.mixpanel.com/v1/ai/otel/traces",
"eu": "https://ingestion-eu.mixpanel.com/v1/ai/otel/traces",
"in": "https://ingestion-in.mixpanel.com/v1/ai/otel/traces",
}
)
_DEFAULT_REGION: Final = "us"
class _MixpanelSettings(BaseSettings):
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore", frozen=True)
project_token: str | None = Field(default=None, validation_alias=MIXPANEL_PROJECT_TOKEN_ENV)
region: str | None = Field(default=None, validation_alias="MIXPANEL_REGION")
endpoint: str | None = Field(default=None, validation_alias="MIXPANEL_ENDPOINT")
def mixpanel_authorization_header(project_token: str) -> str:
encoded: Final = base64.b64encode(f"{project_token}:".encode()).decode()
return f"Basic {encoded}"
def _resolve_region_endpoint(region: str | None) -> str:
default_endpoint: Final = MIXPANEL_OTLP_ENDPOINT_BY_REGION[_DEFAULT_REGION]
if not region:
return default_endpoint
endpoint: Final = MIXPANEL_OTLP_ENDPOINT_BY_REGION.get(region.strip().lower())
if endpoint is None:
verbose_logger.warning(
"Mixpanel: unknown mixpanel_region %r; supported regions: %s. Using the default (US) endpoint.",
region,
", ".join(sorted(MIXPANEL_OTLP_ENDPOINT_BY_REGION)),
)
return default_endpoint
return endpoint
def mixpanel_preset(
*,
config_overrides: OpenTelemetryV2Config | None = None,
allow_missing_credentials: bool = False,
) -> OpenTelemetryV2Config:
settings: Final = _MixpanelSettings()
base: Final = config_overrides or OpenTelemetryV2Config()
token: Final = (settings.project_token or "").strip() or None
endpoint: Final = (settings.endpoint or "").strip() or _resolve_region_endpoint(settings.region)
spec: Final = ExporterSpec(
kind="otlp_http",
endpoint=endpoint,
headers=(f"Authorization={mixpanel_authorization_header(token)}" if token else None),
owner=ExporterOwner.MIXPANEL,
requires_headers=True,
)
return base.model_copy(
update=MappingProxyType(
{
"exporters": (*base.exporters, spec),
"mapper_names": ensure_mappers(base.mapper_names, "genai", "mixpanel"),
"baggage_promoted_keys": ensure_mappers(base.baggage_promoted_keys, User.ID),
}
)
)
def mixpanel_dynamic_headers(
params: StandardCallbackDynamicParams,
) -> dict[str, str]: # mutable-ok: DYNAMIC_HEADERS_BY_CALLBACK returns a dict
token: Final = (params.get("mixpanel_project_token") or "").strip()
if not token:
return {}
return {"Authorization": mixpanel_authorization_header(token)}
def mixpanel_dynamic_endpoint(params: StandardCallbackDynamicParams) -> str:
return _resolve_region_endpoint(params.get("mixpanel_region"))

View file

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

View file

@ -145,6 +145,8 @@ _supported_callback_params: Final[tuple[str, ...]] = (
"newrelic_region",
"signoz_ingestion_endpoint",
"signoz_ingestion_key",
"mixpanel_project_token",
"mixpanel_region",
"turn_off_message_logging",
)
@ -160,6 +162,8 @@ _request_blocked_callback_params: Final = frozenset(
"newrelic_region",
"signoz_ingestion_endpoint",
"signoz_ingestion_key",
"mixpanel_project_token",
"mixpanel_region",
}
)
@ -174,6 +178,8 @@ _trusted_overlay_callback_params: Final = frozenset(
"newrelic_region",
"signoz_ingestion_endpoint",
"signoz_ingestion_key",
"mixpanel_project_token",
"mixpanel_region",
}
)

View file

@ -5697,6 +5697,19 @@ def build_custom_logger_compatible_class(
_in_memory_loggers.append(_signoz_logger)
return _signoz_logger
elif logging_integration == "mixpanel":
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.integrations.otel.presets.mixpanel import MIXPANEL_PROJECT_TOKEN_ENV
if not is_otel_v2_enabled():
raise ValueError("The 'mixpanel' callback requires OpenTelemetry v2 (set LITELLM_OTEL_V2=true)")
_mixpanel_v2: Final = _maybe_construct_otel_v2("mixpanel", _in_memory_loggers)
if _mixpanel_v2 is not None:
return _mixpanel_v2
raise ValueError(f"{MIXPANEL_PROJECT_TOKEN_ENV} not found in environment variables")
elif logging_integration == "mlflow":
for callback in _in_memory_loggers:
if isinstance(callback, MlflowLogger):
@ -6304,6 +6317,12 @@ def get_custom_logger_compatible_class(
return callback
if isinstance(callback, NewRelicLogger):
return callback
elif logging_integration == "mixpanel":
from litellm.integrations.otel.logger import OpenTelemetryV2
for callback in _in_memory_loggers:
if isinstance(callback, OpenTelemetryV2) and callback.callback_name == "mixpanel":
return callback
return None
except Exception as e:

View file

@ -0,0 +1 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M23.688 5.75049H8.31299C6.89776 5.75049 5.75049 6.89776 5.75049 8.31299V23.688C5.75049 25.1032 6.89776 26.2505 8.31299 26.2505H23.688C25.1032 26.2505 26.2505 25.1032 26.2505 23.688V8.31299C26.2505 6.89776 25.1032 5.75049 23.688 5.75049Z" stroke="#4F44E0" stroke-width="1.5" stroke-linejoin="round"/><path d="M13.4865 15.0188H15.0162C14.6342 14.7798 14.4912 14.4449 14.2993 13.824L13.7255 11.6969C13.463 10.741 13.2475 10.2866 12.1958 10.2866H9.99805V10.8605H10.3094C10.9539 10.8605 11.0263 11.0994 11.2182 11.8163L11.7196 13.6809C11.9821 14.5897 12.3895 15.0188 13.4883 15.0188H13.4865ZM17.0257 15.0188H18.5536C19.6524 15.0188 20.0344 14.5879 20.2987 13.6809L20.8002 11.8163C20.992 11.0994 21.0862 10.8605 21.7089 10.8605H22.0203V10.2866H19.8461C18.7708 10.2866 18.5554 10.7175 18.3164 11.6969L17.7425 13.824C17.5506 14.4684 17.4076 14.7798 17.0257 15.0188ZM15.0162 17.0246H17.0257L17.0257 15.0188H15.0162V17.0246ZM9.99805 21.7567H12.1958C13.2475 21.7567 13.463 21.3023 13.7255 20.3465L14.2993 18.2194C14.4912 17.5985 14.6342 17.2635 15.0162 17.0246H13.4865C12.3877 17.0246 11.9803 17.4554 11.7178 18.3624L11.2164 20.227C11.0245 20.9439 10.9539 21.1829 10.3076 21.1829H9.99805V21.7567ZM19.8425 21.7567H22.0167V21.1829H21.7053C21.0844 21.1829 20.9884 20.9439 20.7965 20.227L20.2951 18.3624C20.0326 17.4536 19.6506 17.0246 18.5499 17.0246H17.0257C17.4077 17.2635 17.5452 17.5749 17.7371 18.2194L18.311 20.3465C18.5499 21.3259 18.7654 21.7567 19.8407 21.7567H19.8425Z" fill="#4F44E0"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -3673,6 +3673,12 @@ class AllCallbacks(LiteLLMPydanticObjectBase):
],
)
mixpanel: CallbackOnUI = CallbackOnUI(
litellm_callback_name="mixpanel",
ui_callback_name="Mixpanel",
litellm_callback_params=("MIXPANEL_PROJECT_TOKEN", "MIXPANEL_REGION", "MIXPANEL_ENDPOINT"),
)
signoz: CallbackOnUI = CallbackOnUI(
litellm_callback_name="signoz",
ui_callback_name="SigNoz",

View file

@ -1917,9 +1917,11 @@ def get_end_user_id_from_request_body(
if user_id_str:
return user_id_str
# Check 5: 'metadata.user_id' in request_body (another common pattern)
# Check 5: 'metadata.user_id' or 'metadata.user.id' in request_body (another common pattern)
metadata_dict: Final = _as_dict(request_body.get("metadata"))
user_id_str = _coerce_user_id_to_str(metadata_dict.get("user_id"))
user_id_str = _coerce_user_id_to_str(metadata_dict.get("user_id")) or _coerce_user_id_to_str(
metadata_dict.get("user.id")
)
if user_id_str:
return user_id_str

View file

@ -14,6 +14,8 @@ from litellm.types.utils import CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_V
_NEWRELIC_CALLBACK: Final = "newrelic"
_NEWRELIC_VAR_PREFIX: Final = "newrelic_"
_MIXPANEL_CALLBACK: Final = "mixpanel"
_MIXPANEL_VAR_PREFIX: Final = "mixpanel_"
_LANGFUSE_OTEL_CALLBACK: Final = "langfuse_otel"
_LANGFUSE_SPAN_SCOPE_VAR: Final = "langfuse_span_scope"
_ARIZE_CALLBACK: Final = "arize"
@ -61,6 +63,8 @@ def callback_config_error(
)
if destination_setting_error is not None:
return destination_setting_error
if callback_name == _MIXPANEL_CALLBACK:
return _mixpanel_config_error(callback_vars)
if callback_name != _NEWRELIC_CALLBACK:
return None
return _newrelic_config_error(callback_vars)
@ -144,8 +148,10 @@ _VAR_FAMILIES: Final[Mapping[str, str]] = MappingProxyType(
"humanloop_": "Humanloop",
"langfuse_": "Langfuse",
"langsmith_": "LangSmith",
"mixpanel_": "Mixpanel",
"newrelic_": "New Relic",
"posthog_": "PostHog",
"signoz_": "SigNoz",
"wandb_": "Weights & Biases",
"weave_": "Weights & Biases",
}
@ -445,3 +451,26 @@ def _newrelic_config_error(callback_vars: Mapping[str, str]) -> str | None:
if region is not None and (not api_key or api_key == "None"):
return "newrelic_region requires newrelic_api_key; the region rides the team's own key."
return None
def _mixpanel_config_error(callback_vars: Mapping[str, str]) -> str | None:
if not any(key.startswith(_MIXPANEL_VAR_PREFIX) for key in callback_vars):
return None
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.integrations.otel.presets.mixpanel import MIXPANEL_OTLP_ENDPOINT_BY_REGION
if not is_otel_v2_enabled():
return "Per-team Mixpanel routing requires the proxy to run with LITELLM_OTEL_V2=true."
region: Final = callback_vars.get("mixpanel_region")
if region is not None and region.strip().lower() not in MIXPANEL_OTLP_ENDPOINT_BY_REGION:
return (
f"Unknown mixpanel_region {region!r}. "
f"Supported regions: {', '.join(sorted(MIXPANEL_OTLP_ENDPOINT_BY_REGION))}."
)
token: Final = callback_vars.get("mixpanel_project_token")
if region is not None and (not token or not token.strip() or token == "None"):
return "mixpanel_region requires mixpanel_project_token; the region rides the team's own project token."
return None

View file

@ -1126,6 +1126,8 @@ def convert_key_logging_metadata_to_callback(
continue
if var.startswith("signoz_") and data.callback_name != "signoz":
continue
if var.startswith("mixpanel_") and data.callback_name != "mixpanel":
continue
if team_callback_settings_obj.callback_vars is None:
team_callback_settings_obj.callback_vars = {}
team_callback_settings_obj.callback_vars[var] = str(value)

View file

@ -3951,6 +3951,9 @@ class StandardCallbackDynamicParams(TypedDict, total=False):
signoz_ingestion_endpoint: str | None # writable-ok: initialize_standard_callback_dynamic_params assigns it
signoz_ingestion_key: str | None # writable-ok: initialize_standard_callback_dynamic_params assigns it
mixpanel_project_token: str | None # writable-ok: initialize_standard_callback_dynamic_params assigns it
mixpanel_region: str | None # writable-ok: initialize_standard_callback_dynamic_params assigns it
# Logging settings
turn_off_message_logging: bool | None # when true will not log messages
litellm_disabled_callbacks: list[str] | None

View file

@ -254,3 +254,47 @@ def test_baggage_processor_allowlist_can_be_widened():
assert span.attributes.get("custom.key") == "v"
# team_alias not in this config's allowlist -> not promoted
assert LiteLLM.TEAM_ALIAS not in span.attributes
def test_user_id_not_promoted_by_default_and_promoted_for_mixpanel(monkeypatch):
monkeypatch.setenv("MIXPANEL_PROJECT_TOKEN", "mp-token")
from litellm.integrations.otel.presets.mixpanel import mixpanel_preset
payload = _payload()
payload["end_user"] = "user_123"
data = LLMCallSpanData.from_standard_logging_payload(payload)
default_bag = promoted_baggage(data.identity, data.request_model, BAGGAGE_PROMOTED_KEYS)
assert "user.id" not in default_bag
mp_cfg = mixpanel_preset(config_overrides=OpenTelemetryV2Config(exporter="in_memory"))
engine, exporter = _engine_and_exporter(mp_cfg)
mp_bag = promoted_baggage(data.identity, data.request_model, tuple(mp_cfg.baggage_promoted_keys))
assert mp_bag["user.id"] == "user_123"
ctx = ctx_mod.set_request_baggage(mp_bag)
root = engine.start_span(SpanRole.PROXY_REQUEST, "POST /chat/completions", ctx)
root_ctx = ctx_mod.context_from_span(root, ctx)
engine.emit(SpanRole.LLM_CALL, data, parent_context=root_ctx)
engine.emit(SpanRole.GUARDRAIL, GuardrailSpanData("presidio", status="success"), root_ctx)
engine.emit(SpanRole.SERVICE, ServiceSpanData("redis", call_type="set"), root_ctx)
root.end()
spans = exporter.get_finished_spans()
assert len(spans) == 4
for span in spans:
assert span.attributes.get("user.id") == "user_123"
def test_user_id_precedence_and_key_owner_exclusion():
payload = _payload()
payload["end_user"] = "from-end-user"
payload["metadata"]["user_api_key_user_id"] = "from-key-owner"
payload["metadata"]["requester_metadata"] = {"user.id": "from-explicit-user-dot-id"}
data = LLMCallSpanData.from_standard_logging_payload(payload)
assert data.identity.user_id == "from-explicit-user-dot-id"
key_owner_only_payload = _payload()
key_owner_only_payload["metadata"]["user_api_key_user_id"] = "from-key-owner"
key_owner_only_data = LLMCallSpanData.from_standard_logging_payload(key_owner_only_payload)
assert key_owner_only_data.identity.user_id is None

View file

@ -5374,3 +5374,13 @@ class TestArizeProjectRouting:
monkeypatch.setenv("ARIZE_PROJECT_NAME", "gateway-prod")
assert self._forwarded_projects() == {"gateway-prod"}
def test_mixpanel_destination_resolves_endpoint_and_basic_auth():
assert "mixpanel" in destination_capable_backends()
assert destination_for("mixpanel", {"mixpanel_region": "eu"}) is None
destination = destination_for("mixpanel", {"mixpanel_project_token": "team-tok", "mixpanel_region": "eu"})
assert destination is not None
assert destination.endpoint == "https://ingestion-eu.mixpanel.com/v1/ai/otel/traces"
assert destination.headers["Authorization"] == f"Basic {b64encode(b'team-tok:').decode()}"
assert destination.callback_name == "mixpanel"

View file

@ -743,3 +743,32 @@ def test_signoz_keyless_team_endpoint_is_ignored_so_the_operator_key_never_reach
owned = next(e for e in routed.exporters if e.owner == "signoz")
assert owned.endpoint == "https://ingest.us.signoz.cloud:443"
assert owned.headers == "signoz-ingestion-key=OPERATOR"
def test_mixpanel_dynamic_headers_and_endpoint_routing(monkeypatch, caplog):
expected_auth = "Basic " + base64.b64encode(b"team-token:").decode()
assert dynamic_otlp_headers("mixpanel", {"mixpanel_project_token": "team-token"}) == {
"Authorization": expected_auth
}
assert dynamic_otlp_headers("mixpanel", {}) is None
monkeypatch.setenv("MIXPANEL_REGION", "eu")
assert (
dynamic_otlp_endpoint("mixpanel", {"mixpanel_project_token": "team-token"})
== "https://ingestion-us.mixpanel.com/v1/ai/otel/traces"
)
assert (
dynamic_otlp_endpoint("mixpanel", {"mixpanel_project_token": "team-token", "mixpanel_region": "eu"})
== "https://ingestion-eu.mixpanel.com/v1/ai/otel/traces"
)
assert (
dynamic_otlp_endpoint("mixpanel", {"mixpanel_project_token": "team-token", "mixpanel_region": "IN"})
== "https://ingestion-in.mixpanel.com/v1/ai/otel/traces"
)
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
fallback = dynamic_otlp_endpoint(
"mixpanel", {"mixpanel_project_token": "team-token", "mixpanel_region": "ap-south"}
)
assert fallback == "https://ingestion-us.mixpanel.com/v1/ai/otel/traces"
assert "unknown mixpanel_region" in caplog.text

View file

@ -278,3 +278,59 @@ def test_signoz_preset_accepts_the_factory_call_shape(monkeypatch):
cfg = PRESET_BY_CALLBACK["signoz"](allow_missing_credentials=True)
assert any(e.owner == ExporterOwner.SIGNOZ for e in cfg.exporters)
def test_mixpanel_preset_reads_token_and_defaults_to_us(monkeypatch):
import base64
monkeypatch.setenv("MIXPANEL_PROJECT_TOKEN", "mp-token-123")
monkeypatch.delenv("MIXPANEL_REGION", raising=False)
monkeypatch.delenv("MIXPANEL_ENDPOINT", 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.mixpanel import mixpanel_preset
cfg = mixpanel_preset()
spec = next(e for e in cfg.exporters if e.owner == ExporterOwner.MIXPANEL)
expected_basic = base64.b64encode(b"mp-token-123:").decode()
assert spec.kind == "otlp_http"
assert spec.endpoint == "https://ingestion-us.mixpanel.com/v1/ai/otel/traces"
assert _otlp_traces_endpoint(spec.endpoint) == "https://ingestion-us.mixpanel.com/v1/ai/otel/traces"
assert spec.headers == f"Authorization=Basic {expected_basic}"
assert spec.requires_headers is True
assert "genai" in cfg.mapper_names
assert "mixpanel" in cfg.mapper_names
assert "user.id" in cfg.baggage_promoted_keys
def test_mixpanel_preset_region_and_endpoint_override(monkeypatch):
monkeypatch.setenv("MIXPANEL_PROJECT_TOKEN", "mp-token-123")
monkeypatch.setenv("MIXPANEL_REGION", "EU")
monkeypatch.delenv("MIXPANEL_ENDPOINT", raising=False)
from litellm.integrations.otel.model.config import ExporterOwner
from litellm.integrations.otel.presets.mixpanel import mixpanel_preset
eu_cfg = mixpanel_preset()
eu_spec = next(e for e in eu_cfg.exporters if e.owner == ExporterOwner.MIXPANEL)
assert eu_spec.endpoint == "https://ingestion-eu.mixpanel.com/v1/ai/otel/traces"
monkeypatch.setenv("MIXPANEL_REGION", "in")
in_cfg = mixpanel_preset()
in_spec = next(e for e in in_cfg.exporters if e.owner == ExporterOwner.MIXPANEL)
assert in_spec.endpoint == "https://ingestion-in.mixpanel.com/v1/ai/otel/traces"
monkeypatch.setenv("MIXPANEL_ENDPOINT", "http://collector.internal:4318/v1/ai/otel/traces")
override_cfg = mixpanel_preset()
override_spec = next(e for e in override_cfg.exporters if e.owner == ExporterOwner.MIXPANEL)
assert override_spec.endpoint == "http://collector.internal:4318/v1/ai/otel/traces"
def test_mixpanel_preset_without_token_is_header_gated(monkeypatch):
monkeypatch.delenv("MIXPANEL_PROJECT_TOKEN", raising=False)
from litellm.integrations.otel.model.config import ExporterOwner
from litellm.integrations.otel.presets.mixpanel import mixpanel_preset
cfg = mixpanel_preset()
spec = next(e for e in cfg.exporters if e.owner == ExporterOwner.MIXPANEL)
assert spec.headers is None
assert spec.requires_headers is True

View file

@ -17,6 +17,7 @@ from litellm.integrations.otel import GenAIOperation
from litellm.integrations.otel.mappers import (
LangfuseMapper,
LangtraceMapper,
MixpanelMapper,
OpenInferenceMapper,
WeaveMapper,
resolve_mappers,
@ -802,3 +803,30 @@ def test_resolve_mappers_composition_layers_vocabularies():
def test_resolve_mappers_rejects_unknown_name():
with pytest.raises(ValueError, match="unknown mapper name 'nope'"):
resolve_mappers(["genai", "nope"])
def test_mixpanel_mapper_stamps_user_id_and_omits_when_absent():
from litellm.integrations.otel.model.payloads import MCPToolCallSpanData
with_end_user = _llm_call(identity=RequestIdentity(call_id="c1", end_user="user_123"))
assert MixpanelMapper().map(with_end_user) == {"user.id": "user_123"}
with_trace_user = _llm_call(trace=TraceControls(user_id="trace_user_456"))
assert MixpanelMapper().map(with_trace_user) == {"user.id": "trace_user_456"}
mcp_span = MCPToolCallSpanData(
operation=GenAIOperation.EXECUTE_TOOL,
method="tools/call",
tool_name="search",
server_name="srv",
server_address=None,
server_port=None,
session_id=None,
arguments_json=None,
result_json=None,
error=None,
response_cost=None,
identity=RequestIdentity(call_id="c2", end_user="mcp_user"),
)
assert MixpanelMapper().map(mcp_span) == {"user.id": "mcp_user"}
assert MixpanelMapper().map(_llm_call()) == {}

View file

@ -13642,3 +13642,70 @@ def test_native_messages_body_carries_the_reported_list_cost_into_the_priced_res
assert isinstance(priced, ModelResponse)
headers = priced._hidden_params.get("additional_headers") or {}
assert headers.get("llm_provider-x-litellm-response-cost") == expected_header
def test_mixpanel_dispatch_constructs_otel_v2_when_flag_on(monkeypatch):
import base64
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.model.config import ExporterOwner, is_otel_v2_enabled
from litellm.litellm_core_utils import litellm_logging as logging_module
logging_module._in_memory_loggers.clear()
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
monkeypatch.setenv("MIXPANEL_PROJECT_TOKEN", "mp-token")
is_otel_v2_enabled.cache_clear()
try:
v2_logger = logging_module._init_custom_logger_compatible_class(
logging_integration="mixpanel",
internal_usage_cache=None,
llm_router=None,
custom_logger_init_args={},
)
assert isinstance(v2_logger, OpenTelemetryV2)
assert v2_logger.callback_name == "mixpanel"
spec = next(e for e in v2_logger.config.exporters if e.owner == ExporterOwner.MIXPANEL)
assert spec.endpoint == "https://ingestion-us.mixpanel.com/v1/ai/otel/traces"
expected_basic = base64.b64encode(b"mp-token:").decode()
assert spec.headers == f"Authorization=Basic {expected_basic}"
assert logging_module.get_custom_logger_compatible_class("mixpanel") is v2_logger
finally:
logging_module._in_memory_loggers.clear()
monkeypatch.delenv("LITELLM_OTEL_V2", raising=False)
is_otel_v2_enabled.cache_clear()
def test_mixpanel_dispatch_requires_otel_v2_and_project_token(monkeypatch):
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils import litellm_logging as logging_module
logging_module._in_memory_loggers.clear()
monkeypatch.delenv("LITELLM_OTEL_V2", raising=False)
monkeypatch.setenv("MIXPANEL_PROJECT_TOKEN", "mp-token")
is_otel_v2_enabled.cache_clear()
try:
assert (
logging_module._init_custom_logger_compatible_class(
logging_integration="mixpanel",
internal_usage_cache=None,
llm_router=None,
custom_logger_init_args={},
)
is None
)
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
monkeypatch.delenv("MIXPANEL_PROJECT_TOKEN", raising=False)
is_otel_v2_enabled.cache_clear()
assert (
logging_module._init_custom_logger_compatible_class(
logging_integration="mixpanel",
internal_usage_cache=None,
llm_router=None,
custom_logger_init_args={},
)
is None
)
finally:
logging_module._in_memory_loggers.clear()
monkeypatch.delenv("LITELLM_OTEL_V2", raising=False)
is_otel_v2_enabled.cache_clear()

View file

@ -481,3 +481,70 @@ class TestNewRelicKeyLoggingValidation:
)
finally:
is_otel_v2_enabled.cache_clear()
class TestMixpanelCallbackValidation:
def _team_data(self, callback_vars):
from litellm.proxy._types import AddTeamCallback
return AddTeamCallback(callback_name="mixpanel", callback_type="success", callback_vars=callback_vars)
def _key_metadata(self, callback_vars):
return {"logging": [{"callback_name": "mixpanel", "callback_type": "success", "callback_vars": callback_vars}]}
def test_mixpanel_entry_in_callbacks_configs(self):
from litellm.proxy.common_utils.callback_utils import is_sensitive_callback_key
from litellm.proxy.management_endpoints.team_callback_endpoints import _redact_callback_secrets
client = TestClient(app)
response = client.get("/callbacks/configs", headers={"Authorization": "Bearer sk-9876"})
assert response.status_code == 200
mixpanel = next((config for config in response.json() if config.get("id") == "mixpanel"), None)
assert mixpanel is not None
assert mixpanel["supports_key_team_logging"] is True
params = mixpanel["dynamic_params"]
assert params["mixpanel_project_token"]["type"] == "password"
assert "mixpanel_region" in params
assert is_sensitive_callback_key("mixpanel_project_token") is True
assert is_sensitive_callback_key("mixpanel_region") is False
raw_metadata = {
"logging": [
{
"callback_name": "mixpanel",
"callback_type": "success",
"callback_vars": {"mixpanel_project_token": "mp-secret-tok", "mixpanel_region": "eu"},
}
]
}
redacted = _redact_callback_secrets(raw_metadata)
assert redacted["logging"][0]["callback_vars"]["mixpanel_project_token"] == "***REDACTED***"
def test_team_and_key_validation(self, monkeypatch):
from fastapi import HTTPException
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.proxy.management_endpoints.key_management_endpoints import raise_on_invalid_key_logging_config
from litellm.proxy.management_endpoints.team_callback_endpoints import _validate_team_callback
monkeypatch.delenv("LITELLM_OTEL_V2", raising=False)
is_otel_v2_enabled.cache_clear()
try:
with pytest.raises(HTTPException) as exc:
_validate_team_callback(self._team_data({"mixpanel_project_token": "tok"}))
assert "LITELLM_OTEL_V2" in str(exc.value.detail)
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
with pytest.raises(HTTPException) as exc:
_validate_team_callback(self._team_data({"mixpanel_project_token": "tok", "mixpanel_region": "mars"}))
assert "Unknown mixpanel_region" in str(exc.value.detail)
with pytest.raises(HTTPException) as exc:
raise_on_invalid_key_logging_config(self._key_metadata({"mixpanel_region": "eu"}))
assert "requires mixpanel_project_token" in str(exc.value.detail)
_validate_team_callback(self._team_data({"mixpanel_project_token": "tok", "mixpanel_region": "in"}))
raise_on_invalid_key_logging_config(
self._key_metadata({"mixpanel_project_token": "tok", "mixpanel_region": "EU"})
)
finally:
is_otel_v2_enabled.cache_clear()

View file

@ -10953,3 +10953,51 @@ def test_mapped_baggage_tags_keep_non_string_client_tags_as_sent():
data: Final[dict[str, object]] = {"metadata": {"tags": [1, {"team": "x"}, "client-tag", "client-tag"]}}
LiteLLMProxyRequestSetup.apply_authenticated_baggage_tags(request, data)
assert data["metadata"]["tags"] == [1, {"team": "x"}, "client-tag", "client-tag", "tenant:a"]
def test_mixpanel_callback_vars_are_scoped_and_request_blocked():
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
initialize_standard_callback_dynamic_params,
)
from litellm.proxy._types import AddTeamCallback
from litellm.proxy.litellm_pre_call_utils import convert_key_logging_metadata_to_callback
from litellm.types.utils import TRUSTED_CALLBACK_VARS_FIELD
under_mixpanel: Final = convert_key_logging_metadata_to_callback(
data=AddTeamCallback(
callback_name="mixpanel",
callback_type="success",
callback_vars={"mixpanel_project_token": "team-tok", "mixpanel_region": "eu"},
),
team_callback_settings_obj=None,
)
assert under_mixpanel.callback_vars == {"mixpanel_project_token": "team-tok", "mixpanel_region": "eu"}
under_other: Final = convert_key_logging_metadata_to_callback(
data=AddTeamCallback(
callback_name="langfuse",
callback_type="success",
callback_vars={"mixpanel_project_token": "team-tok", "langfuse_host": "https://cloud.langfuse.com"},
),
team_callback_settings_obj=None,
)
assert under_other.callback_vars == {"langfuse_host": "https://cloud.langfuse.com"}
blocked: Final = initialize_standard_callback_dynamic_params(
{
"mixpanel_project_token": "attacker-tok",
"mixpanel_region": "eu",
"metadata": {"mixpanel_project_token": "attacker-tok"},
}
)
assert "mixpanel_project_token" not in blocked
assert "mixpanel_region" not in blocked
trusted: Final = initialize_standard_callback_dynamic_params(
{
"mixpanel_project_token": "attacker-tok",
TRUSTED_CALLBACK_VARS_FIELD: {"mixpanel_project_token": "trusted-tok", "mixpanel_region": "in"},
}
)
assert trusted.get("mixpanel_project_token") == "trusted-tok"
assert trusted.get("mixpanel_region") == "in"

View file

@ -0,0 +1 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M23.688 5.75049H8.31299C6.89776 5.75049 5.75049 6.89776 5.75049 8.31299V23.688C5.75049 25.1032 6.89776 26.2505 8.31299 26.2505H23.688C25.1032 26.2505 26.2505 25.1032 26.2505 23.688V8.31299C26.2505 6.89776 25.1032 5.75049 23.688 5.75049Z" stroke="#4F44E0" stroke-width="1.5" stroke-linejoin="round"/><path d="M13.4865 15.0188H15.0162C14.6342 14.7798 14.4912 14.4449 14.2993 13.824L13.7255 11.6969C13.463 10.741 13.2475 10.2866 12.1958 10.2866H9.99805V10.8605H10.3094C10.9539 10.8605 11.0263 11.0994 11.2182 11.8163L11.7196 13.6809C11.9821 14.5897 12.3895 15.0188 13.4883 15.0188H13.4865ZM17.0257 15.0188H18.5536C19.6524 15.0188 20.0344 14.5879 20.2987 13.6809L20.8002 11.8163C20.992 11.0994 21.0862 10.8605 21.7089 10.8605H22.0203V10.2866H19.8461C18.7708 10.2866 18.5554 10.7175 18.3164 11.6969L17.7425 13.824C17.5506 14.4684 17.4076 14.7798 17.0257 15.0188ZM15.0162 17.0246H17.0257L17.0257 15.0188H15.0162V17.0246ZM9.99805 21.7567H12.1958C13.2475 21.7567 13.463 21.3023 13.7255 20.3465L14.2993 18.2194C14.4912 17.5985 14.6342 17.2635 15.0162 17.0246H13.4865C12.3877 17.0246 11.9803 17.4554 11.7178 18.3624L11.2164 20.227C11.0245 20.9439 10.9539 21.1829 10.3076 21.1829H9.99805V21.7567ZM19.8425 21.7567H22.0167V21.1829H21.7053C21.0844 21.1829 20.9884 20.9439 20.7965 20.227L20.2951 18.3624C20.0326 17.4536 19.6506 17.0246 18.5499 17.0246H17.0257C17.4077 17.2635 17.5452 17.5749 17.7371 18.2194L18.311 20.3465C18.5499 21.3259 18.7654 21.7567 19.8407 21.7567H19.8425Z" fill="#4F44E0"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -7,6 +7,7 @@ import lagoLogo from "../../public/assets/logos/lago.svg";
import langfuseLogo from "../../public/assets/logos/langfuse.png";
import langsmithLogo from "../../public/assets/logos/langsmith.png";
import newrelicLogo from "../../public/assets/logos/newrelic.png";
import mixpanelLogo from "../../public/assets/logos/mixpanel.svg";
import openmeterLogo from "../../public/assets/logos/openmeter.png";
import otelLogo from "../../public/assets/logos/otel.png";
import pointfiveLogo from "../../public/assets/logos/pointfive.png";
@ -90,6 +91,17 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
},
description: "Datadog Logging Integration",
},
{
id: "mixpanel",
displayName: "Mixpanel",
logo: mixpanelLogo.src,
supports_key_team_logging: true,
dynamic_params: {
mixpanel_project_token: "password",
mixpanel_region: "text",
},
description: "Mixpanel Agent Intelligence OpenTelemetry Integration. Setup: https://docs.mixpanel.com/docs/agent-intelligence",
},
{
id: "newrelic",
displayName: "New Relic",