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