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;
};