From 2ea7d86469aa3ca472c084c18d47a93d6629685b Mon Sep 17 00:00:00 2001 From: Jay Date: Wed, 9 Sep 2026 14:54:50 +0530 Subject: [PATCH 1/8] feat: Filter by project on logs and usage screens (BerriAI/litellm/issues/40386) --- .../management_endpoints/project_endpoints.py | 203 +++++++++++++++++- .../spend_management_endpoints.py | 8 + .../management_endpoints/project_endpoints.py | 20 ++ .../test_project_endpoints_prisma.py | 187 ++++++++++++++++ .../test_spend_management_endpoints.py | 76 +++++++ .../src/components/networking.tsx | 1 + .../view_logs/RequestLogsFilters.test.tsx | 22 ++ .../view_logs/RequestLogsFilters.tsx | 29 +++ .../view_logs/log_filter_logic.test.tsx | 1 + .../components/view_logs/log_filter_logic.tsx | 3 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 123 +++++++++++ 11 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 litellm/types/proxy/management_endpoints/project_endpoints.py diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index b2eda76f9ae..7e272a2c59c 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -11,11 +11,13 @@ Endpoints for /project operations #### PROJECT MANAGEMENT #### import json -from collections.abc import Sequence -from typing import TYPE_CHECKING, Final +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Final, NamedTuple, TypedDict from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import TypeAdapter +from typing_extensions import ReadOnly from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -34,6 +36,10 @@ from litellm.repositories.project_repository import ProjectRepository from litellm.repositories.team_repository import TeamRepository from litellm.repositories.user_repository import UserRepository from litellm.repositories.verification_token_repository import VerificationTokenRepository +from litellm.types.proxy.management_endpoints.project_endpoints import ( + ProjectDailySpendResponse, + ProjectDailySpendRow, +) if TYPE_CHECKING: from prisma import models as prisma_models @@ -1083,3 +1089,196 @@ async def list_projects( ) ) raise handle_exception_on_proxy(e) + + +def _project_daily_activity_error(*, status_code: int, message: str) -> HTTPException: + """Mirrors _daily_activity_error() from team_endpoints.py.""" + return HTTPException(status_code=status_code, detail={"error": message}) # mutable-ok: FastAPI JSON detail + + +_MAX_PROJECT_DAILY_ACTIVITY_RANGE_DAYS: Final = 400 + + +def _project_daily_activity_date_range_error(start_date: str | None, end_date: str | None) -> str | None: + """Mirrors _aggregated_date_range_error() from team_endpoints.py. + + There is no daily-aggregated project spend table, so this endpoint scans + LiteLLM_SpendLogs directly and needs the same guard against an unbounded range. + """ + if start_date is None or end_date is None: + return "Please provide start_date and end_date" + try: + parsed_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + parsed_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + return "start_date and end_date must be valid YYYY-MM-DD dates" + if parsed_end < parsed_start: + return "end_date must be on or after start_date" + if (parsed_end - parsed_start).days > _MAX_PROJECT_DAILY_ACTIVITY_RANGE_DAYS: + return f"Date range must be at most {_MAX_PROJECT_DAILY_ACTIVITY_RANGE_DAYS} days" + return None + + +def _project_daily_spend_sql(*, project_count: int) -> str: + project_placeholders: Final = ", ".join(f"${i}" for i in range(3, 3 + project_count)) + return f""" + SELECT + (DATE_TRUNC('day', sl."startTime" AT TIME ZONE 'UTC'))::date::text AS spend_date, + sl.metadata->>'user_api_key_project_id' AS project_id, + SUM(sl.spend)::float AS spend, + SUM(sl.prompt_tokens)::bigint AS prompt_tokens, + SUM(sl.completion_tokens)::bigint AS completion_tokens, + SUM(sl.total_tokens)::bigint AS total_tokens, + COUNT(*)::bigint AS api_requests, + COUNT(*) FILTER (WHERE sl.status IS DISTINCT FROM 'failure')::bigint AS successful_requests, + COUNT(*) FILTER (WHERE sl.status = 'failure')::bigint AS failed_requests + FROM "LiteLLM_SpendLogs" sl + WHERE sl."startTime" >= $1::timestamp + AND sl."startTime" < $2::timestamp + INTERVAL '1 day' + AND sl.metadata->>'user_api_key_project_id' IN ({project_placeholders}) + GROUP BY spend_date, project_id + ORDER BY spend_date, project_id + """ + + +class _ProjectDailySpendDbRow(TypedDict): + spend_date: ReadOnly[str] + project_id: ReadOnly[str | None] + spend: ReadOnly[float] + prompt_tokens: ReadOnly[int] + completion_tokens: ReadOnly[int] + total_tokens: ReadOnly[int] + api_requests: ReadOnly[int] + successful_requests: ReadOnly[int] + failed_requests: ReadOnly[int] + + +class _ProjectDailyActivityScope(NamedTuple): + project_ids: tuple[str, ...] + project_alias_by_id: Mapping[str, str | None] + + +async def _resolve_project_daily_activity_scope( + *, + project_ids: str, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, +) -> _ProjectDailyActivityScope: + """ + Resolve which of the requested projects the caller may view. + + Proxy admins may query any project. Everyone else must be an admin of the + project's team, the same permission /project/update enforces. + """ + requested: Final = tuple(pid.strip() for pid in project_ids.split(",") if pid.strip()) + if not requested: + return _ProjectDailyActivityScope(project_ids=(), project_alias_by_id={}) + + projects: Final = await _project_table(prisma_client).find_many(where={"project_id": {"in": list(requested)}}) + found_by_id: Final = {p.project_id: p for p in projects} + missing: Final = [pid for pid in requested if pid not in found_by_id] + if missing: + raise _project_daily_activity_error(status_code=404, message=f"Project(s) not found: {missing}") + + if not user_api_key_has_admin_view(user_api_key_dict): + for project_id in requested: + has_permission = await _check_user_permission_for_project( + user_api_key_dict=user_api_key_dict, + team_id=found_by_id[project_id].team_id, + prisma_client=prisma_client, + ) + if not has_permission: + raise _project_daily_activity_error( + status_code=403, + message=f"Not authorized to view daily activity for project_id={project_id}", + ) + + return _ProjectDailyActivityScope( + project_ids=requested, + project_alias_by_id={pid: found_by_id[pid].project_alias for pid in requested}, + ) + + +@router.get( + "/project/daily/activity", + tags=["project management"], + dependencies=[Depends(user_api_key_auth)], + response_model=ProjectDailySpendResponse, +) +async def get_project_daily_activity( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + project_ids: str | None = None, + start_date: str | None = None, + end_date: str | None = None, +) -> ProjectDailySpendResponse: + """ + Daily spend per project, attributed per request from spend logs. + + Scans LiteLLM_SpendLogs directly and groups by day and the project_id + stored in each request's metadata: there is no daily-aggregated project + spend table, unlike /team/daily/activity. + + Proxy admins may query any project. Team admins may query projects + belonging to teams they administer. + + Example: + ```bash + curl --location 'http://0.0.0.0:4000/project/daily/activity?project_ids=project-123&start_date=2026-09-01&end_date=2026-09-04' \\ + --header 'Authorization: Bearer sk-1234' + ``` + """ + from litellm.proxy.proxy_server import premium_user, prisma_client + + if not premium_user: + raise HTTPException( + status_code=403, + detail={ + "error": "Project management is an enterprise feature. " + CommonProxyErrors.not_premium_user.value + }, + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + range_error: Final = _project_daily_activity_date_range_error(start_date, end_date) + if range_error is not None or start_date is None or end_date is None: + raise _project_daily_activity_error( + status_code=400, message=range_error or "Please provide start_date and end_date" + ) + + if not project_ids: + raise _project_daily_activity_error(status_code=400, message="Please provide project_ids") + + scope: Final = await _resolve_project_daily_activity_scope( + project_ids=project_ids, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + if not scope.project_ids: + return ProjectDailySpendResponse(start_date=start_date, end_date=end_date, results=()) + + rows: Final[Sequence[_ProjectDailySpendDbRow]] = await prisma_client.db.query_raw( + _project_daily_spend_sql(project_count=len(scope.project_ids)), + start_date, + end_date, + *scope.project_ids, + ) + results: Final = tuple( + ProjectDailySpendRow( + date=row["spend_date"], + project_id=row["project_id"] or "", + project_alias=scope.project_alias_by_id.get(row["project_id"] or ""), + spend=row["spend"], + prompt_tokens=row["prompt_tokens"], + completion_tokens=row["completion_tokens"], + total_tokens=row["total_tokens"], + api_requests=row["api_requests"], + successful_requests=row["successful_requests"], + failed_requests=row["failed_requests"], + ) + for row in rows + ) + return ProjectDailySpendResponse(start_date=start_date, end_date=end_date, results=results) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index f8831ca4152..81298782fc7 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2349,6 +2349,10 @@ async def ui_view_spend_logs( default=None, description="Filter spend logs by team_id", ), + project_id: str | None = fastapi.Query( + default=None, + description="Filter spend logs by project_id", + ), min_spend: float | None = fastapi.Query( default=None, description="Filter logs with spend greater than or equal to this value", @@ -2763,6 +2767,10 @@ async def ui_view_spend_logs( sql_conditions.append(f"metadata->'error_information'->>'error_message' LIKE ${p}") sql_params.append(f"%{error_message}%") p += 1 + if project_id is not None: + sql_conditions.append(f"metadata->>'user_api_key_project_id' = ${p}") + sql_params.append(project_id) + p += 1 if ( group_by_session is True diff --git a/litellm/types/proxy/management_endpoints/project_endpoints.py b/litellm/types/proxy/management_endpoints/project_endpoints.py new file mode 100644 index 00000000000..248fc6f8dbf --- /dev/null +++ b/litellm/types/proxy/management_endpoints/project_endpoints.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel + + +class ProjectDailySpendRow(BaseModel): + date: str + project_id: str + project_alias: str | None = None + spend: float = 0.0 + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + api_requests: int = 0 + successful_requests: int = 0 + failed_requests: int = 0 + + +class ProjectDailySpendResponse(BaseModel): + start_date: str + end_date: str + results: tuple[ProjectDailySpendRow, ...] diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index c23b203feba..61a66d46e5c 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -23,6 +23,7 @@ from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( update_project, delete_project, project_info, + get_project_daily_activity, ) from litellm.proxy.proxy_server import ( LitellmUserRoles, @@ -1368,3 +1369,189 @@ async def test_new_project_flag_on_access_group_model_returns_400(monkeypatch): assert "prod-models" in str(exc_info.value) assert "expand to multiple models at request time" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_project_daily_activity_requires_project_ids(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + mock_prisma = MagicMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + with pytest.raises(HTTPException) as exc_info: + await get_project_daily_activity( + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"), + project_ids=None, + start_date="2026-09-01", + end_date="2026-09-04", + ) + assert exc_info.value.status_code == 400 + assert "project_ids" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_get_project_daily_activity_admin_groups_by_day_and_project(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + project_alpha = MagicMock(project_id="project-alpha", project_alias="Alpha", team_id="team-1") + project_beta = MagicMock(project_id="project-beta", project_alias="Beta", team_id="team-1") + + mock_prisma = MagicMock() + mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[project_alpha, project_beta]) + mock_prisma.db.query_raw = AsyncMock( + return_value=[ + { + "spend_date": "2026-09-01", + "project_id": "project-alpha", + "spend": 0.5, + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + "api_requests": 3, + "successful_requests": 2, + "failed_requests": 1, + }, + { + "spend_date": "2026-09-02", + "project_id": "project-beta", + "spend": 0.25, + "prompt_tokens": 4, + "completion_tokens": 2, + "total_tokens": 6, + "api_requests": 1, + "successful_requests": 1, + "failed_requests": 0, + }, + ] + ) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + admin = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin") + response = await get_project_daily_activity( + user_api_key_dict=admin, + project_ids="project-alpha,project-beta", + start_date="2026-09-01", + end_date="2026-09-02", + ) + + sql, *params = mock_prisma.db.query_raw.call_args.args + assert params == ["2026-09-01", "2026-09-02", "project-alpha", "project-beta"] + assert 'FROM "LiteLLM_SpendLogs" sl' in sql + assert "sl.metadata->>'user_api_key_project_id' IN ($3, $4)" in sql + assert "GROUP BY spend_date, project_id" in sql + + assert response.start_date == "2026-09-01" + assert response.end_date == "2026-09-02" + assert [(r.date, r.project_id, r.project_alias, r.spend, r.api_requests) for r in response.results] == [ + ("2026-09-01", "project-alpha", "Alpha", 0.5, 3), + ("2026-09-02", "project-beta", "Beta", 0.25, 1), + ] + assert (response.results[0].successful_requests, response.results[0].failed_requests) == (2, 1) + + +@pytest.mark.asyncio +async def test_get_project_daily_activity_team_admin_can_view_own_project(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + project = MagicMock(project_id="project-alpha", project_alias="Alpha", team_id="team-1") + team = MagicMock(admins=["alice"]) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[project]) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team) + mock_prisma.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice") + response = await get_project_daily_activity( + user_api_key_dict=caller, + project_ids="project-alpha", + start_date="2026-09-01", + end_date="2026-09-02", + ) + assert response.results == () + mock_prisma.db.query_raw.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_project_daily_activity_non_team_admin_forbidden(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + project = MagicMock(project_id="project-alpha", project_alias="Alpha", team_id="team-1") + team = MagicMock(admins=["someone-else"]) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[project]) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team) + mock_prisma.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="bob") + with pytest.raises(HTTPException) as exc_info: + await get_project_daily_activity( + user_api_key_dict=caller, + project_ids="project-alpha", + start_date="2026-09-01", + end_date="2026-09-02", + ) + assert exc_info.value.status_code == 403 + mock_prisma.db.query_raw.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_project_daily_activity_unknown_project_404(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + mock_prisma = MagicMock() + mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[]) + mock_prisma.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + admin = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin") + with pytest.raises(HTTPException) as exc_info: + await get_project_daily_activity( + user_api_key_dict=admin, + project_ids="does-not-exist", + start_date="2026-09-01", + end_date="2026-09-02", + ) + assert exc_info.value.status_code == 404 + mock_prisma.db.query_raw.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "project_ids,start_date,end_date,expected_error", + [ + (None, "2026-09-01", "2026-09-04", "project_ids"), + ("", "2026-09-01", "2026-09-04", "project_ids"), + ("project-alpha", None, "2026-09-04", "start_date and end_date"), + ("project-alpha", "2026-09-04", "2026-09-01", "on or after"), + ("project-alpha", "2020-01-01", "2026-12-31", "at most 400 days"), + ("project-alpha", "nope", "2026-09-04", "valid YYYY-MM-DD"), + ], +) +async def test_get_project_daily_activity_rejects_bad_input( + monkeypatch, project_ids, start_date, end_date, expected_error +): + from unittest.mock import AsyncMock, MagicMock + + mock_prisma = MagicMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + admin = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin") + with pytest.raises(HTTPException) as exc_info: + await get_project_daily_activity( + user_api_key_dict=admin, project_ids=project_ids, start_date=start_date, end_date=end_date + ) + assert exc_info.value.status_code == 400 + assert expected_error in str(exc_info.value.detail) + mock_prisma.db.query_raw.assert_not_called() diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 329a33eb440..c5fdc3c6cb8 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -119,6 +119,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params): gte = re.search(r'"startTime" >= \(\$(\d+)', cond) lte = re.search(r'"startTime" <= \(\$(\d+)', cond) alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond) + project = re.search(r"user_api_key_project_id' = \$(\d+)", cond) code = re.search(r"error_code' = \$(\d+)", cond) msg = re.search(r"error_message' LIKE \$(\d+)", cond) sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond) @@ -152,6 +153,13 @@ def _reconstruct_ui_where_from_sql(sql_query, params): "string_contains": str(params[int(alias.group(1)) - 1]).strip("%"), } ) + elif project: + metadata_conds.append( + { + "path": ["user_api_key_project_id"], + "equals": params[int(project.group(1)) - 1], + } + ) elif code: metadata_conds.append( { @@ -4226,6 +4234,74 @@ async def test_ui_view_spend_logs_with_error_message(client): app.dependency_overrides.pop(ps.user_api_key_auth, None) +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_project_id(client): + """Test filtering spend logs by project_id""" + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-3.5-turbo", + "metadata": '{"user_api_key_project_id": "project-1"}', + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-test-key", + "user": "test_user_2", + "team_id": "team1", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "metadata": '{"user_api_key_project_id": "project-2"}', + }, + ] + + def filter_by_project_id(where): + if "metadata" in where: + mf = where["metadata"] + if mf.get("path") == ["user_api_key_project_id"]: + project_id = mf.get("equals") + return [log for log in mock_spend_logs if json.loads(log["metadata"])["user_api_key_project_id"] == project_id] + return mock_spend_logs + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + + try: + with patch.object( + ps, + "prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_project_id), + ): + start_date, end_date = _default_date_range() + + response = client.get( + "/spend/logs/ui", + params={ + "project_id": "project-1", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["id"] == "log1" + assert data["data"][0]["metadata"]["user_api_key_project_id"] == "project-1" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_spend_logs_with_error_code_and_key_alias(client): """Test merging error_code and key_alias filters with AND logic""" diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d7cf5b6110b..169b279bf79 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2075,6 +2075,7 @@ export const userFilterUICall = async (accessToken: string, params: URLSearchPar interface UiSpendLogsParams { api_key?: string; team_id?: string; + project_id?: string; request_id?: string; session_id?: string; user_id?: string; diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index 5a35c7ae16b..fd7df329f33 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -24,10 +24,15 @@ vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({ useInfiniteSpendLogEndUsers: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({ + useProjects: vi.fn(), +})); + import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers"; import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; const emptyInfiniteQuery = { data: { pages: [], pageParams: [] }, @@ -77,6 +82,9 @@ describe("RequestLogsFilters", () => { vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, ); + vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType< + typeof useProjects + >); }); it("renders every backend-supported filter field", async () => { @@ -84,6 +92,7 @@ describe("RequestLogsFilters", () => { for (const label of [ "Team ID", + "Project", "Status", "Cache", "Key Alias", @@ -130,6 +139,19 @@ describe("RequestLogsFilters", () => { expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "alice@example.com"); }); + it("selects a project value from the caller's visible projects", async () => { + vi.mocked(useProjects).mockReturnValue({ + data: [{ project_id: "project-1", project_alias: "Alpha" }], + isLoading: false, + } as unknown as ReturnType); + const user = userEvent.setup(); + const { set } = renderFilters(); + + await chooseSelectOption(user, await screen.findByPlaceholderText("Search or select a project"), /Alpha/); + + expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.PROJECT_ID, "project-1"); + }); + it("pushes the User ID picker query to the paginated user lookup", async () => { const user = userEvent.setup(); renderFilters(); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index fc7e34bd5e1..9203edb8276 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -6,6 +6,7 @@ import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/u import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import { DataTableFilterField } from "@/components/shared/DataTable"; import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; @@ -76,6 +77,32 @@ function TeamFilterField({ ); } +function ProjectFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) { + const { data: projects, isLoading } = useProjects(); + + const options = useMemo( + () => + (projects ?? []).map((project) => ({ + label: project.project_alias || project.project_id, + value: project.project_id, + sublabel: project.project_id, + })), + [projects], + ); + + return ( + + onChange(emptyToUndefined(next))} + placeholder="Search or select a project" + emptyText={isLoading ? "Loading projects…" : "No projects found"} + /> + + ); +} + function KeyAliasFilterField({ value, onChange, @@ -328,6 +355,8 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF teams={teams} /> + + Date: Fri, 11 Sep 2026 15:53:36 +0530 Subject: [PATCH 6/8] fix: UI/Linting fixes resolved --- .../test_spend_management_endpoints.py | 41 +++++++++---------- .../hooks/uiSettings/useUISettings.ts | 15 ++++++- .../components/EntityUsage/EntityUsage.tsx | 34 +++++++++------ .../projectUsageAggregations.test.ts | 13 +++--- .../ProjectUsage/projectUsageAggregations.ts | 10 ++++- .../_components/components/UsagePageView.tsx | 10 +++-- .../UsageViewSelect/UsageViewSelect.test.tsx | 19 +++++---- .../hooks/usePaginatedDailyActivity.ts | 4 +- .../view_logs/RequestLogsFilters.test.tsx | 4 +- 9 files changed, 91 insertions(+), 59 deletions(-) diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index c5fdc3c6cb8..2adb4470d03 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -4235,7 +4235,7 @@ async def test_ui_view_spend_logs_with_error_message(client): @pytest.mark.asyncio -async def test_ui_view_spend_logs_with_project_id(client): +async def test_ui_view_spend_logs_with_project_id(client, monkeypatch): """Test filtering spend logs by project_id""" mock_spend_logs = [ { @@ -4274,30 +4274,27 @@ async def test_ui_view_spend_logs_with_project_id(client): user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" ) + monkeypatch.setattr(ps, "prisma_client", make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_project_id)) + try: - with patch.object( - ps, - "prisma_client", - make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_project_id), - ): - start_date, end_date = _default_date_range() + start_date, end_date = _default_date_range() - response = client.get( - "/spend/logs/ui", - params={ - "project_id": "project-1", - "start_date": start_date, - "end_date": end_date, - }, - headers={"Authorization": "Bearer sk-test"}, - ) + response = client.get( + "/spend/logs/ui", + params={ + "project_id": "project-1", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) - assert response.status_code == 200 - data = response.json() - assert data["total"] == 1 - assert len(data["data"]) == 1 - assert data["data"][0]["id"] == "log1" - assert data["data"][0]["metadata"]["user_api_key_project_id"] == "project-1" + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["id"] == "log1" + assert data["data"][0]["metadata"]["user_api_key_project_id"] == "project-1" finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts index eaa1c89eab4..0f6a9b805ba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts @@ -4,6 +4,16 @@ import { createQueryKeys } from "../common/queryKeysFactory"; const uiSettingsKeys = createQueryKeys("uiSettings"); +export interface UISettingsFieldSchema { + description?: string; + properties?: Record; +} + +export interface UISettingsData { + field_schema: UISettingsFieldSchema; + values: Record; +} + /** * UI settings, cached for an hour by default because they rarely change. * @@ -14,11 +24,12 @@ const uiSettingsKeys = createQueryKeys("uiSettings"); * so a caller that needs to notice a change also has to poll. */ export const useUISettings = (options?: { staleTime?: number; refetchInterval?: number }) => { - return useQuery>({ + const queryOptions = { queryKey: uiSettingsKeys.list({}), queryFn: async () => await getUiSettings(), staleTime: options?.staleTime ?? 60 * 60 * 1000, gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour refetchInterval: options?.refetchInterval, - }); + }; + return useQuery(queryOptions); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index f55dc05f5c2..4081acc0d0b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -35,7 +35,7 @@ import { userDailyActivityCall, } from "@/components/networking"; import { Logo } from "@/components/molecules/logo/Logo"; -import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity"; +import { usePaginatedDailyActivity, type FetchPageFn } from "../../hooks/usePaginatedDailyActivity"; import { EntityMetricWithMetadata } from "@/components/UsagePage/types"; import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; import EndpointUsage from "../EndpointUsage/EndpointUsage"; @@ -44,6 +44,15 @@ import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView import TopModelView from "./TopModelView"; import TeamUserSpendCard from "./TeamUserSpendCard"; +/** The entity metadata shape actually probed by getEntityLabel: whichever of these + * fields the backend populated for a given entity type (team, user, ...). */ +interface EntityBreakdownMetadata { + team_alias?: string; + user_email?: string; + user_alias?: string; + [key: string]: unknown; +} + interface EntityMetrics { metrics: { spend: number; @@ -56,7 +65,7 @@ interface EntityMetrics { failed_requests: number; api_requests: number; }; - metadata: Record; + metadata: EntityBreakdownMetadata; } interface EntitySpendData { @@ -88,7 +97,7 @@ interface EntityUsageProps { isOrgAdmin?: boolean; } -const ENTITY_FETCH_FNS: Record Promise> = { +const ENTITY_FETCH_FNS: Record = { tag: tagDailyActivityCall, team: teamDailyActivityCall, organization: organizationDailyActivityCall, @@ -99,7 +108,7 @@ const ENTITY_FETCH_FNS: Record Promise> = { // Single-shot endpoints returning the whole range in one response; entity types // without one fall back to page-draining the paginated endpoint. -const ENTITY_AGGREGATED_FETCH_FNS: Partial Promise>> = { +const ENTITY_AGGREGATED_FETCH_FNS: Partial> = { team: teamDailyActivityAggregatedCall, }; @@ -141,18 +150,19 @@ const EntityUsage: React.FC = ({ const hasRequestWindow = !!accessToken && !!startTime && !!endTime; const enabled = hasRequestWindow && canViewEntity; + const entityPaginatedActivityOptions = { + fetchFn, + args: [accessToken, startTime, endTime, entityFilterArg], + enabled, + aggregatedFetchFn, + }; const { data: spendDataRaw, isFetchingMore, progress, cancelled, cancel, - } = usePaginatedDailyActivity({ - fetchFn, - args: [accessToken, startTime, endTime, entityFilterArg], - enabled, - aggregatedFetchFn, - }); + } = usePaginatedDailyActivity(entityPaginatedActivityOptions); const spendData = spendDataRaw as unknown as EntitySpendData; @@ -181,7 +191,7 @@ const EntityUsage: React.FC = ({ } }; - const getEntityLabel = (entity: string, metadata?: Record): string => { + const getEntityLabel = (entity: string, metadata?: EntityBreakdownMetadata): string => { if (entityList) { const entityItem = entityList.find((item) => item.value === entity); if (entityItem) { @@ -226,7 +236,7 @@ const EntityUsage: React.FC = ({ cache_creation_input_tokens: 0, }, metadata: { - alias: getEntityLabel(entity, data.metadata as any), + alias: getEntityLabel(entity, data.metadata as EntityBreakdownMetadata), id: entity, }, }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts index 7ef733f5eac..db6f5ab63c7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts @@ -20,13 +20,15 @@ const row = (overrides: Partial = {}): ProjectDailySpendRo describe("summarizeProjectUsage", () => { it("returns all-zero totals for no rows", () => { - expect(summarizeProjectUsage([])).toEqual({ + const zeroTotals = { total_spend: 0, total_api_requests: 0, total_successful_requests: 0, total_failed_requests: 0, total_tokens: 0, - }); + }; + + expect(summarizeProjectUsage([])).toEqual(zeroTotals); }); it("sums spend, requests, and tokens across every row regardless of project or date", () => { @@ -40,14 +42,15 @@ describe("summarizeProjectUsage", () => { total_tokens: 40, }; const rows = [row({ spend: 1.5 }), row(projectBetaRow)]; - - expect(summarizeProjectUsage(rows)).toEqual({ + const expectedTotals = { total_spend: 3.75, total_api_requests: 7, total_successful_requests: 6, total_failed_requests: 1, total_tokens: 55, - }); + }; + + expect(summarizeProjectUsage(rows)).toEqual(expectedTotals); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts index ed385c56344..5ddd9ee9545 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts @@ -66,6 +66,14 @@ const groupByProjectId = (rows: ProjectDailySpendRow[]): ProjectDailySpendRow[][ return [...groups.values()]; }; +const EMPTY_PROJECT_GROUP_TOTALS = { + spend: 0, + requests: 0, + successful_requests: 0, + failed_requests: 0, + tokens: 0, +}; + const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => { const [{ project_id, project_alias }] = rows; const totals = rows.reduce( @@ -76,7 +84,7 @@ const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => failed_requests: acc.failed_requests + row.failed_requests, tokens: acc.tokens + row.total_tokens, }), - { spend: 0, requests: 0, successful_requests: 0, failed_requests: 0, tokens: 0 }, + EMPTY_PROJECT_GROUP_TOTALS, ); return { project_id, project_alias: project_alias || project_id, ...totals }; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index b841d3f0a3b..85c5f4faf92 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -124,9 +124,9 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const [isAiChatOpen, setIsAiChatOpen] = useState(false); const [selectedUsageView, setUsageView] = useState("global"); - const stillHasAccessToSelectedView = - (selectedUsageView !== "organization" || canViewOrganizationUsage) && - (selectedUsageView !== "project" || canViewProjectUsage); + const hasOrganizationAccessIfSelected = selectedUsageView !== "organization" || canViewOrganizationUsage; + const hasProjectAccessIfSelected = selectedUsageView !== "project" || canViewProjectUsage; + const stillHasAccessToSelectedView = hasOrganizationAccessIfSelected && hasProjectAccessIfSelected; const usageView: UsageOption = stillHasAccessToSelectedView ? selectedUsageView : "global"; const [showCredentialBanner, setShowCredentialBanner] = useState(true); @@ -238,10 +238,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const aggregatedFailed = selectForRange(aggregatedFailure, currentAggregatedRangeKey) === true; // Paginated fallback — only enabled when aggregated endpoint fails + const hasRequestWindow = !!accessToken && !!startTime && !!endTime; + const hasPaginatedFallbackRequestWindow = aggregatedFailed && hasRequestWindow; const paginatedResult = usePaginatedDailyActivity({ fetchFn: userDailyActivityCall, args: [accessToken, startTime, endTime, effectiveUserId], - enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime, + enabled: hasPaginatedFallbackRequestWindow, }); // Derive userSpendData from whichever source is active diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx index f3327ba036b..be1951abe4f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx @@ -71,15 +71,18 @@ describe("UsageViewSelect", () => { }, ); - it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])("should hide %s from an internal user", async (optionName) => { - const user = userEvent.setup(); - const { container } = render( - , - ); + it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])( + "should hide %s from an internal user", + async (optionName) => { + const user = userEvent.setup(); + const { container } = render( + , + ); - await openMenu(user); - expect(offers(container, optionName)).toBe(false); - }); + await openMenu(user); + expect(offers(container, optionName)).toBe(false); + }, + ); // An org admin's session role is "Internal User" — org-admin-ness lives in the // membership table — so the two rows above cannot tell them apart from a plain diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts index e023feda2e3..49f83c8c9d3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts @@ -32,12 +32,12 @@ const SUMMABLE_METADATA_KEYS = [ "total_flat_cost", ] as const; -interface DailyActivityResponse { +export interface DailyActivityResponse { results: DailyData[]; metadata: Record; } -type FetchPageFn = (...args: any[]) => Promise; +export type FetchPageFn = (...args: any[]) => Promise; interface UsePaginatedDailyActivityParams { /** The API call function (e.g., userDailyActivityCall). */ diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index 56c5b612707..eb1ee087d85 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -87,9 +87,7 @@ describe("RequestLogsFilters", () => { vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, ); - vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType< - typeof useProjects - >); + vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType); vi.mocked(useUISettings).mockReturnValue({ data: { values: { enable_projects_ui: true } }, } as unknown as ReturnType); From b9df1f39087f981f4a93fff89143b7414ff882c7 Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Fri, 11 Sep 2026 17:49:18 +0530 Subject: [PATCH 7/8] fix: narrow unknown UI settings value before passing to PageVisibilitySettings Type error in build-ui CI: values.enabled_ui_pages_internal_users is Record and was passed directly into a prop typed string[] | null | undefined. --- .../Settings/AdminSettings/UISettings/UISettings.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 612ca05d083..538a770e1a3 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -45,6 +45,10 @@ function SettingRow({ ); } +function toStringArrayOrNull(value: unknown): string[] | null { + return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : null; +} + export default function UISettings() { const { accessToken } = useAuthorized(); const { data, isLoading, isError, error } = useUISettings(); @@ -434,7 +438,7 @@ export default function UISettings() { Date: Fri, 11 Sep 2026 18:49:19 +0530 Subject: [PATCH 8/8] fix: allowlist GET /project/daily/activity in terraform endpoint audit Read-only spend analytics endpoint with no Terraform-managed state, same as the other */daily/activity endpoints already allowlisted. Co-Authored-By: Claude Sonnet 5 --- terraform/provider/tools/endpointaudit/coverage_allowlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 6bc8947e89f..0faf07f819a 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -19,6 +19,7 @@ GET /guardrails/usage/overview GET /key/spend/report GET /organization/daily/activity GET /organization/spend/report +GET /project/daily/activity GET /tag/daily/activity GET /tag/dau GET /tag/distinct