From e70f7e2d7a54c42451bda0e9c51382e2b22b0fbf Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Mon, 22 Jun 2026 12:36:22 -0700 Subject: [PATCH] fix(ui): resolve user_id to email in Spend Per User usage chart (#30992) The Usage dashboard "Spend Per User" chart rendered raw UUIDs (and default_user_id) instead of emails. /user/daily/activity passed entity_metadata_field=None, so every user entity in the breakdown carried empty metadata; the chart could only fall back to the user_id. The frontend resolved labels from a separately paginated user list, so any spender not on a loaded page showed as a UUID. Resolve the email/alias for the user_ids actually on the page (mirroring how api key metadata is already resolved) and attach it to the entity metadata, so the chart labels each spender with their email and falls back to the UUID only when no email is on file. get_daily_activity gains an optional resolve_entity_metadata hook so the user endpoint can do this page-scoped lookup without loading the whole user table. Resolves LIT-3889 --- .../common_daily_activity.py | 22 ++++- .../internal_user_endpoints.py | 22 +++++ .../test_common_daily_activity.py | 80 +++++++++++++++++++ .../test_internal_user_endpoints.py | 55 +++++++++++++ .../EntityUsage/EntityUsage.test.tsx | 35 ++++++++ .../components/EntityUsage/EntityUsage.tsx | 8 ++ 6 files changed, 219 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 341a8767db0..79882909c23 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1,7 +1,7 @@ import asyncio from datetime import datetime from types import SimpleNamespace -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, Union from fastapi import HTTPException, status @@ -887,8 +887,17 @@ async def get_daily_activity( exclude_entity_ids: Optional[List[str]] = None, metadata_metrics_func: Optional[Callable[[List[Any]], SpendMetrics]] = None, timezone_offset_minutes: Optional[int] = None, + resolve_entity_metadata: Optional[ + Callable[[list[Any]], Awaitable[dict[str, dict]]] + ] = None, ) -> SpendAnalyticsPaginatedResponse: - """Common function to get daily activity for any entity type.""" + """Common function to get daily activity for any entity type. + + ``resolve_entity_metadata`` lets a caller resolve entity metadata from the + rows actually on the page (e.g. user_id -> user_email) instead of fetching + the whole entity table upfront, which matters when the entity set is + unbounded. + """ if prisma_client is None: raise HTTPException( @@ -939,11 +948,18 @@ async def get_daily_activity( take=page_size, ) + resolved_entity_metadata = entity_metadata_field + if resolve_entity_metadata is not None: + resolved_entity_metadata = { + **(entity_metadata_field or {}), + **(await resolve_entity_metadata(daily_spend_data)), + } + aggregated = await _aggregate_spend_records( prisma_client=prisma_client, records=daily_spend_data, entity_id_field=entity_id_field, - entity_metadata_field=entity_metadata_field, + entity_metadata_field=resolved_entity_metadata, ) metadata_metrics = aggregated["totals"] diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index c3ddf4deb1a..6d7f565fb85 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2617,6 +2617,25 @@ async def ui_view_users( # Using shared metric helper implementations from common_daily_activity +async def _resolve_user_email_metadata( + prisma_client: "PrismaClient", records: list[Any] +) -> dict[str, dict]: + """Map each user_id on the page to its email/alias so the Usage dashboard can + label the 'Spend Per User' chart with the email instead of the raw UUID.""" + user_ids = { + record.user_id for record in records if getattr(record, "user_id", None) + } + if not user_ids: + return {} + users = await UserRepository(prisma_client).table.find_many( + where={"user_id": {"in": list(user_ids)}} + ) + return { + user.user_id: {"user_email": user.user_email, "user_alias": user.user_alias} + for user in users + } + + @router.get( "/user/daily/activity", tags=["Budget & Spend Tracking", "Internal User management"], @@ -2719,6 +2738,9 @@ async def get_user_daily_activity( page=page, page_size=page_size, timezone_offset_minutes=timezone, + resolve_entity_metadata=lambda records: _resolve_user_email_metadata( + prisma_client, records + ), ) except HTTPException: diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index 2d2c18bb46e..b882090e8f1 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -638,6 +638,84 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): assert key_data.metrics.spend == 10.0 +def _daily_user_spend_record(*, user_id, api_key, spend): + """A LiteLLM_DailyUserSpend row as the per-user breakdown reads it.""" + return SimpleNamespace( + date="2024-01-01", + user_id=user_id, + api_key=api_key, + model="gpt-4", + model_group="gpt-4", + custom_llm_provider="openai", + mcp_namespaced_tool_name=None, + endpoint="/chat/completions", + spend=spend, + prompt_tokens=10, + completion_tokens=5, + cache_read_input_tokens=0, + cache_creation_input_tokens=0, + api_requests=1, + successful_requests=1, + failed_requests=0, + ) + + +@pytest.mark.asyncio +async def test_get_daily_activity_applies_resolve_entity_metadata_to_breakdown(): + """Regression for LIT-3889: the Spend Per User chart showed raw UUIDs. + + /user/daily/activity used to pass entity_metadata_field=None, so every + user entity in the breakdown carried empty metadata and the dashboard had + nothing to render but the user_id UUID. The page-scoped resolver must put + the resolved email/alias onto the entity metadata so the UI can label it, + while a spender with no email on file still falls back to the raw UUID. + """ + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + + records = [ + _daily_user_spend_record(user_id="user-with-email", api_key="key-1", spend=7.0), + _daily_user_spend_record(user_id="user-no-email", api_key="key-2", spend=3.0), + ] + + mock_table = MagicMock() + mock_table.count = AsyncMock(return_value=len(records)) + mock_table.find_many = AsyncMock(return_value=records) + mock_prisma.db.litellm_dailyuserspend = mock_table + mock_prisma.db.litellm_verificationtoken = MagicMock() + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + + seen_user_ids = {} + + async def resolver(page_records): + seen_user_ids["ids"] = {r.user_id for r in page_records} + return {"user-with-email": {"user_email": "spender@example.com"}} + + result = await get_daily_activity( + prisma_client=mock_prisma, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=None, + entity_metadata_field=None, + start_date="2024-01-01", + end_date="2024-01-01", + model=None, + api_key=None, + page=1, + page_size=1000, + resolve_entity_metadata=resolver, + ) + + # Resolver is driven by the user_ids actually on the page + assert seen_user_ids["ids"] == {"user-with-email", "user-no-email"} + + entities = result.results[0].breakdown.entities + # Email is on the entity metadata so the UI labels the chart with it + assert entities["user-with-email"].metadata["user_email"] == "spender@example.com" + # No email on file -> empty metadata -> UI falls back to the UUID + assert entities["user-no-email"].metadata == {} + + class TestAdjustDatesForTimezone: """ Regression tests for the timezone double-counting bug. @@ -758,6 +836,8 @@ class TestBuildAggregatedSqlQuery: ] assert "model = $4" in sql assert "api_key = $5" in sql + + @pytest.mark.asyncio async def test_get_daily_activity_aggregated_empty_result_set(): """Regression test for the empty-range 500. diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 9b7fc817e4e..b4602e0ad8b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2,6 +2,7 @@ import json import os import sys from datetime import datetime, timezone +from types import SimpleNamespace import pytest from fastapi.testclient import TestClient @@ -20,6 +21,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.management_endpoints.internal_user_endpoints import ( LiteLLM_UserTableWithKeyCount, + _resolve_user_email_metadata, _update_internal_user_params, get_user_key_counts, get_users, @@ -3098,3 +3100,56 @@ async def test_ghsa_wvg4_proxy_admin_can_update_user_budget(mocker): user_request=user_request, user_api_key_dict=admin_caller ) assert result is not None + + +@pytest.mark.asyncio +async def test_resolve_user_email_metadata_maps_page_user_ids_to_email(mocker): + """Regression for LIT-3889. + + The Spend Per User chart rendered raw UUIDs because the per-user activity + breakdown carried no email. This resolver must turn the user_ids on the + page into {user_id: {user_email, user_alias}} so the chart can label each + spender, and it must only look up the user_ids actually present (not the + whole user table). + """ + + mock_prisma_client = mocker.MagicMock() + find_many = mocker.AsyncMock( + return_value=[ + SimpleNamespace( + user_id="u1", user_email="alice@example.com", user_alias="Alice" + ), + SimpleNamespace(user_id="u2", user_email=None, user_alias="bob-alias"), + ] + ) + mock_prisma_client.db.litellm_usertable.find_many = find_many + + records = [ + SimpleNamespace(user_id="u1"), + SimpleNamespace(user_id="u1"), # duplicate -> deduped + SimpleNamespace(user_id="u2"), + ] + + result = await _resolve_user_email_metadata(mock_prisma_client, records) + + assert result == { + "u1": {"user_email": "alice@example.com", "user_alias": "Alice"}, + "u2": {"user_email": None, "user_alias": "bob-alias"}, + } + where_arg = find_many.call_args.kwargs["where"] + assert set(where_arg["user_id"]["in"]) == {"u1", "u2"} + + +@pytest.mark.asyncio +async def test_resolve_user_email_metadata_skips_db_when_no_user_ids(mocker): + """No user_ids on the page (e.g. all spend is unattributed) means no query.""" + mock_prisma_client = mocker.MagicMock() + find_many = mocker.AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_usertable.find_many = find_many + + records = [SimpleNamespace(user_id=None), SimpleNamespace(user_id="")] + + result = await _resolve_user_email_metadata(mock_prisma_client, records) + + assert result == {} + find_many.assert_not_called() diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx index dc201cccfea..15f572717e7 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx @@ -703,4 +703,39 @@ describe("EntityUsage", () => { expect(screen.getByText("tag-1")).toBeInTheDocument(); }); }); + + it("should label the chart with user_email metadata instead of the raw UUID (LIT-3889)", async () => { + const userUuid = "c0e68be8-057e-4e2f-9d3a-000000000000"; + const spendDataForUser = { + ...mockSpendData, + results: [ + { + ...mockSpendData.results[0], + breakdown: { + ...mockSpendData.results[0].breakdown, + entities: { + [userUuid]: { + ...mockSpendData.results[0].breakdown.entities["tag-1"], + metadata: { user_email: "spender@example.com" }, + }, + }, + }, + }, + ], + }; + + mockUserDailyActivityCall.mockResolvedValue(spendDataForUser); + + // entityList is null to simulate a spender missing from the paginated user list + render(); + + await waitFor(() => { + expect(mockUserDailyActivityCall).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getByText("spender@example.com")).toBeInTheDocument(); + }); + expect(screen.queryByText(userUuid)).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index d415b8453a2..d78e39a87f3 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -328,6 +328,14 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti if (metadata?.team_alias) { return metadata.team_alias; } + // Resolve user_id to email/alias so the Spend Per User chart never shows a raw UUID + // when an email is on file (the entityList is paginated and may miss spenders) + if (metadata?.user_email) { + return metadata.user_email; + } + if (metadata?.user_alias) { + return metadata.user_alias; + } return entity; };