mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
feat(otel): add Mixpanel OpenTelemetry v2 preset callback
This commit is contained in:
parent
cfe0100e1f
commit
0ef9a94d60
32 changed files with 625 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
19
litellm/integrations/otel/mappers/mixpanel.py
Normal file
19
litellm/integrations/otel/mappers/mixpanel.py
Normal 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(())
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class ExporterOwner(str, Enum):
|
|||
AGENTOPS = "agentops"
|
||||
NEWRELIC = "newrelic"
|
||||
SIGNOZ = "signoz"
|
||||
MIXPANEL = "mixpanel"
|
||||
|
||||
|
||||
class _OTelV2Flag(BaseSettings):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"}),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
97
litellm/integrations/otel/presets/mixpanel.py
Normal file
97
litellm/integrations/otel/presets/mixpanel.py
Normal 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"))
|
||||
|
|
@ -97,6 +97,7 @@ class CustomLoggerRegistry:
|
|||
"weave_otel": OpenTelemetry,
|
||||
"levo": OpenTelemetry,
|
||||
"signoz": OpenTelemetry,
|
||||
"mixpanel": OpenTelemetry,
|
||||
"mlflow": MlflowLogger,
|
||||
"langfuse": LangfusePromptManagement,
|
||||
"otel": OpenTelemetry,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) == {}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
1
ui/litellm-dashboard/public/assets/logos/mixpanel.svg
Normal file
1
ui/litellm-dashboard/public/assets/logos/mixpanel.svg
Normal 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 |
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue