From 05eafde76acb96352533770f2b46771c00306d24 Mon Sep 17 00:00:00 2001 From: Hariom Bangari Date: Mon, 19 Oct 2026 11:59:51 +0100 Subject: [PATCH] fix(posthog): skip every user_api_key* metadata field instead of an enumerated list The proxy writes the calling key's record into the request metadata under user_api_key* names: the hash, budgets and spend, the budget reservation, the request route, the key's and team's metadata, and the whole UserAPIKeyAuth object under user_api_key_auth. The PostHog logger skipped sixteen of those names and copied every other metadata key onto the event as a custom property, so the ones not on the list, user_api_key_auth above all, reached PostHog on every $ai_generation. The set of names grows with the proxy, so the rule is now the prefix, the one redact_user_api_key_info already applies, and the enumerated entries go. --- litellm/integrations/posthog.py | 24 ++++++------------- tests/unit/integrations/test_posthog.py | 31 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/litellm/integrations/posthog.py b/litellm/integrations/posthog.py index 0f165218321..3cb0ce4e42b 100644 --- a/litellm/integrations/posthog.py +++ b/litellm/integrations/posthog.py @@ -262,24 +262,9 @@ class PostHogLogger(CustomBatchLogger): litellm_internal_fields: Final = { "endpoint", "caching_groups", - "user_api_key_hash", - "user_api_key_alias", - "user_api_key_team_id", - "user_api_key_user_id", - "user_api_key_org_id", - "user_api_key_team_alias", - "user_api_key_end_user_id", - "user_api_key_user_email", - "user_api_key", "user_api_end_user_max_budget", "litellm_api_version", "global_max_parallel_requests", - "user_api_key_team_max_budget", - "user_api_key_team_spend", - "user_api_key_spend", - "user_api_key_max_budget", - "user_api_key_model_max_budget", - "user_api_key_metadata", "headers", "litellm_parent_otel_span", "requester_ip_address", @@ -295,8 +280,13 @@ class PostHogLogger(CustomBatchLogger): } for key, value in metadata.items(): - if key not in litellm_internal_fields: - properties[key] = value + # The proxy writes the calling key's record under user_api_key* names + # (the hash, budgets, spend, the whole UserAPIKeyAuth object), and the + # set of names grows with the proxy, so the rule is the prefix, as in + # redact_user_api_key_info, not a list. + if key.startswith("user_api_key") or key in litellm_internal_fields: + continue + properties[key] = value def _get_distinct_id(self, standard_logging_object: StandardLoggingPayload, kwargs: PostHogLogKwargs) -> str: metadata: Final = self._extract_metadata(kwargs) diff --git a/tests/unit/integrations/test_posthog.py b/tests/unit/integrations/test_posthog.py index b920de4a4bd..dec6b476b88 100644 --- a/tests/unit/integrations/test_posthog.py +++ b/tests/unit/integrations/test_posthog.py @@ -243,6 +243,37 @@ async def test_custom_metadata_filters_internal_fields(): assert "model_info" not in event_payload["properties"] +@pytest.mark.asyncio +async def test_custom_metadata_skips_every_key_record_field(): + """The proxy writes the calling key's record into the request metadata under + user_api_key* names, including ones no fixed list names: the whole UserAPIKeyAuth + object, its metadata, the budget reservation and the request route. None of them + is a custom property, and the key's hash and budgets must not reach PostHog.""" + posthog_logger = PostHogLogger() + standard_payload = create_standard_logging_payload() + + kwargs = { + "standard_logging_object": standard_payload, + "litellm_params": { + "metadata": { + "custom_field": "should_appear", + "user_api_key_auth": {"token": "hash123", "max_budget": 10.0, "spend": 2.5}, + "user_api_key_auth_metadata": {"tags": ["prod"]}, + "user_api_key_budget_reservation": {"amount": 1.0}, + "user_api_key_request_route": "/chat/completions", + "user_api_key_user_spend": 2.5, + "user_api_key_team_metadata": {"team": "legal"}, + } + }, + } + + event_payload = posthog_logger.create_posthog_event_payload(kwargs) + + assert event_payload["properties"]["custom_field"] == "should_appear" + leaked = [key for key in event_payload["properties"] if key.startswith("user_api_key")] + assert leaked == [] + + @pytest.mark.asyncio async def test_custom_metadata_with_no_metadata(): """Test that logger handles cases with no metadata gracefully"""