diff --git a/litellm/__init__.py b/litellm/__init__.py
index 9ec11dd1291..c4fc4cfb028 100644
--- a/litellm/__init__.py
+++ b/litellm/__init__.py
@@ -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
diff --git a/litellm/integrations/callback_configs.json b/litellm/integrations/callback_configs.json
index 38928f67f42..cb74c16cb82 100644
--- a/litellm/integrations/callback_configs.json
+++ b/litellm/integrations/callback_configs.json
@@ -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",
diff --git a/litellm/integrations/otel/__init__.py b/litellm/integrations/otel/__init__.py
index d7627d4d63d..424ea8a3a4a 100644
--- a/litellm/integrations/otel/__init__.py
+++ b/litellm/integrations/otel/__init__.py
@@ -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",
diff --git a/litellm/integrations/otel/mappers/__init__.py b/litellm/integrations/otel/mappers/__init__.py
index 4e424e025c0..546f2aeb4c0 100644
--- a/litellm/integrations/otel/mappers/__init__.py
+++ b/litellm/integrations/otel/mappers/__init__.py
@@ -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",
diff --git a/litellm/integrations/otel/mappers/mixpanel.py b/litellm/integrations/otel/mappers/mixpanel.py
new file mode 100644
index 00000000000..1707005a3be
--- /dev/null
+++ b/litellm/integrations/otel/mappers/mixpanel.py
@@ -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(())
diff --git a/litellm/integrations/otel/model/baggage.py b/litellm/integrations/otel/model/baggage.py
index 606fd79f218..aabaaea9100 100644
--- a/litellm/integrations/otel/model/baggage.py
+++ b/litellm/integrations/otel/model/baggage.py
@@ -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,
}
diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py
index dd150082252..58e4c105cad 100644
--- a/litellm/integrations/otel/model/config.py
+++ b/litellm/integrations/otel/model/config.py
@@ -44,6 +44,7 @@ class ExporterOwner(str, Enum):
AGENTOPS = "agentops"
NEWRELIC = "newrelic"
SIGNOZ = "signoz"
+ MIXPANEL = "mixpanel"
class _OTelV2Flag(BaseSettings):
diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py
index a69c334a1fb..483bad2505d 100644
--- a/litellm/integrations/otel/model/metadata.py
+++ b/litellm/integrations/otel/model/metadata.py
@@ -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,
diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py
index b7f955a75ed..4ebda265d5b 100644
--- a/litellm/integrations/otel/model/semconv.py
+++ b/litellm/integrations/otel/model/semconv.py
@@ -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).
diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py
index fc1e9b7099d..44d56b965a7 100644
--- a/litellm/integrations/otel/plumbing/providers.py
+++ b/litellm/integrations/otel/plumbing/providers.py
@@ -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):
diff --git a/litellm/integrations/otel/presets/__init__.py b/litellm/integrations/otel/presets/__init__.py
index 7c891c29409..b962366e547 100644
--- a/litellm/integrations/otel/presets/__init__.py
+++ b/litellm/integrations/otel/presets/__init__.py
@@ -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",
diff --git a/litellm/integrations/otel/presets/destinations.py b/litellm/integrations/otel/presets/destinations.py
index a705fc16bbc..95a6c00c826 100644
--- a/litellm/integrations/otel/presets/destinations.py
+++ b/litellm/integrations/otel/presets/destinations.py
@@ -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"}),
}
)
diff --git a/litellm/integrations/otel/presets/mixpanel.py b/litellm/integrations/otel/presets/mixpanel.py
new file mode 100644
index 00000000000..a0b69d56bb5
--- /dev/null
+++ b/litellm/integrations/otel/presets/mixpanel.py
@@ -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"))
diff --git a/litellm/litellm_core_utils/custom_logger_registry.py b/litellm/litellm_core_utils/custom_logger_registry.py
index 74badc2c8c1..67ab0e95bb4 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,
+ "mixpanel": OpenTelemetry,
"mlflow": MlflowLogger,
"langfuse": LangfusePromptManagement,
"otel": OpenTelemetry,
diff --git a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py
index c3769686e45..f9e67f123c1 100644
--- a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py
+++ b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py
@@ -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",
}
)
diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py
index 9e0461e951d..db58732ab39 100644
--- a/litellm/litellm_core_utils/litellm_logging.py
+++ b/litellm/litellm_core_utils/litellm_logging.py
@@ -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:
diff --git a/litellm/proxy/_experimental/out/assets/logos/mixpanel.svg b/litellm/proxy/_experimental/out/assets/logos/mixpanel.svg
new file mode 100644
index 00000000000..71f9e91877a
--- /dev/null
+++ b/litellm/proxy/_experimental/out/assets/logos/mixpanel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 49d23d82aa9..217a6e520f5 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -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",
diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py
index 30ede935cfa..c525884fa61 100644
--- a/litellm/proxy/auth/auth_utils.py
+++ b/litellm/proxy/auth/auth_utils.py
@@ -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
diff --git a/litellm/proxy/common_utils/callback_config_validation.py b/litellm/proxy/common_utils/callback_config_validation.py
index 302978d17a4..6aff24f2964 100644
--- a/litellm/proxy/common_utils/callback_config_validation.py
+++ b/litellm/proxy/common_utils/callback_config_validation.py
@@ -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
diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py
index 5f1948d1c1c..a27a1ec24b6 100644
--- a/litellm/proxy/litellm_pre_call_utils.py
+++ b/litellm/proxy/litellm_pre_call_utils.py
@@ -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)
diff --git a/litellm/types/utils.py b/litellm/types/utils.py
index 7e8645fd0dd..bf1cbf3913a 100644
--- a/litellm/types/utils.py
+++ b/litellm/types/utils.py
@@ -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
diff --git a/tests/unit/integrations/otel/test_otel_v2_baggage.py b/tests/unit/integrations/otel/test_otel_v2_baggage.py
index 5855cb60aca..e6e2452fa63 100644
--- a/tests/unit/integrations/otel/test_otel_v2_baggage.py
+++ b/tests/unit/integrations/otel/test_otel_v2_baggage.py
@@ -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
diff --git a/tests/unit/integrations/otel/test_otel_v2_destinations.py b/tests/unit/integrations/otel/test_otel_v2_destinations.py
index 782442aad22..506390a8cbf 100644
--- a/tests/unit/integrations/otel/test_otel_v2_destinations.py
+++ b/tests/unit/integrations/otel/test_otel_v2_destinations.py
@@ -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"
diff --git a/tests/unit/integrations/otel/test_otel_v2_dynamic.py b/tests/unit/integrations/otel/test_otel_v2_dynamic.py
index 8163ba06317..027b0dea5dd 100644
--- a/tests/unit/integrations/otel/test_otel_v2_dynamic.py
+++ b/tests/unit/integrations/otel/test_otel_v2_dynamic.py
@@ -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
diff --git a/tests/unit/integrations/otel/test_otel_v2_presets.py b/tests/unit/integrations/otel/test_otel_v2_presets.py
index 570c27a24e1..c774d115998 100644
--- a/tests/unit/integrations/otel/test_otel_v2_presets.py
+++ b/tests/unit/integrations/otel/test_otel_v2_presets.py
@@ -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
diff --git a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py
index 7e112fcf6a4..dc0acb8dd80 100644
--- a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py
+++ b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py
@@ -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()) == {}
diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py
index 561ac9b2b33..deafbd52362 100644
--- a/tests/unit/litellm_core_utils/test_litellm_logging.py
+++ b/tests/unit/litellm_core_utils/test_litellm_logging.py
@@ -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()
diff --git a/tests/unit/proxy/management_endpoints/test_callback_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_callback_management_endpoints.py
index 34fa164fd6d..f0c41fe058c 100644
--- a/tests/unit/proxy/management_endpoints/test_callback_management_endpoints.py
+++ b/tests/unit/proxy/management_endpoints/test_callback_management_endpoints.py
@@ -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()
diff --git a/tests/unit/proxy/test_litellm_pre_call_utils.py b/tests/unit/proxy/test_litellm_pre_call_utils.py
index e03fd9850c1..780ddcec892 100644
--- a/tests/unit/proxy/test_litellm_pre_call_utils.py
+++ b/tests/unit/proxy/test_litellm_pre_call_utils.py
@@ -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"
diff --git a/ui/litellm-dashboard/public/assets/logos/mixpanel.svg b/ui/litellm-dashboard/public/assets/logos/mixpanel.svg
new file mode 100644
index 00000000000..71f9e91877a
--- /dev/null
+++ b/ui/litellm-dashboard/public/assets/logos/mixpanel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx
index 6ee092f030d..ec6780b9e8f 100644
--- a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx
+++ b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx
@@ -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",