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
This commit is contained in:
Yassin Kortam 2026-06-22 12:36:22 -07:00 • committed by GitHub
parent fd377eece8
commit e70f7e2d7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 219 additions and 3 deletions

View file

@ -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"]

View file

@ -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:

View file

@ -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.

View file

@ -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()

View file

@ -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(<EntityUsage {...defaultProps} entityType="user" entityList={null} />);
await waitFor(() => {
expect(mockUserDailyActivityCall).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText("spender@example.com")).toBeInTheDocument();
});
expect(screen.queryByText(userUuid)).not.toBeInTheDocument();
});
});

View file

@ -328,6 +328,14 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ 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;
};