fix(ui): find logs by request id across pages and dates (LIT-3981) (#31743)

* fix(spend): resolve spend logs by request_id across all dates (LIT-3981)

The /spend/logs/ui search only filtered the page already loaded, so a log id
copied from another page or from outside the active date window could not be
found. request_id is the primary key of LiteLLM_SpendLogs, so when it is
supplied on the internal UI route the mandatory date window is dropped and the
lookup resolves across all time. The date window stays required when no
request_id is given, and the public /spend/logs/v2 contract is unchanged.

A non-admin id lookup is gated by the same ownership check the detail endpoint
uses, so the relaxed window cannot be used to read another tenant's log by id

* fix(ui): send the logs request_id search to the server (LIT-3981)

The "Search by Request ID" box filtered only the rows already on the current
page, so an id from another page never matched. It now feeds the existing
server-side request_id filter via handleFilterChange, which debounces, resets
to page one, and rides the existing react-query key. The dead client-side
filter and its searchTerm state are removed; the session composition and dedup
logic is unchanged.

The box is now an exact request_id lookup, matching its label; the incidental
client-side model and user substring matching it used to do is dropped in
favor of the dedicated filters

* refactor(spend): model the request_id spend-log lookup as an explicit point lookup (LIT-3981)

The date-window relaxation for request_id lookups rode an apply_date_window flag threaded through the date validation and parsing. Model the two intents directly instead. A UI request_id query is a point lookup on the @id primary key that drops the time window and authorizes by row ownership; every other query, including the public /spend/logs/v2 route, takes the range-scan path that still requires a window

Because the ownership check fully authorizes the single row, the general user/team scoping is now skipped for id lookups rather than layered on top redundantly. The confusing `is_v2 or request_id is None` guard is gone, and moving the date requirement into the range-scan branch lets the type checker narrow the dates it parses

Behavior is preserved: the v2 contract still requires dates even when a request_id is supplied, and a non-owner is still rejected with 403. A regression test covers the non-admin owner id lookup, which resolves across all time and filters by the primary key alone
This commit is contained in:
ryan-crabbe-berri 2026-07-23 16:40:19 -07:00 • committed by GitHub
parent 7aaaa055b7
commit a507394841
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 312 additions and 68 deletions

View file

@ -1695,13 +1695,10 @@ async def ui_view_spend_logs(
code=status.HTTP_401_UNAUTHORIZED,
)
if start_date is None or end_date is None:
raise ProxyException(
message="Start date and end date are required",
type="bad_request",
param="None",
code=status.HTTP_400_BAD_REQUEST,
)
# Inline import — auth_utils participates in a proxy import cycle.
from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415
is_v2 = "/spend/logs/v2" in get_request_route(request)
# Validate sort_by and sort_order
valid_sort_fields = {
@ -1729,36 +1726,50 @@ async def ui_view_spend_logs(
)
try:
# Inline import — auth_utils participates in a proxy import cycle.
from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415
is_admin_view = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
is_request_id_lookup = request_id is not None and not is_v2
is_v2 = "/spend/logs/v2" in get_request_route(request)
formats = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] if is_v2 else ["%Y-%m-%d %H:%M:%S"]
if is_request_id_lookup:
# request_id is the @id primary key: it identifies a single row, so a
# time window is meaningless. The dashboard always sends a default 24h
# window, which hid ids copied from an older page (LIT-3981). Drop the
# window for the id lookup so it resolves across all time; every other
# query, including the public v2 route, still requires one (below).
start_date_obj: datetime | None = None
end_date_obj: datetime | None = None
else:
if start_date is None or end_date is None:
raise ProxyException(
message="Start date and end date are required",
type="bad_request",
param="None",
code=status.HTTP_400_BAD_REQUEST,
)
formats = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] if is_v2 else ["%Y-%m-%d %H:%M:%S"]
def parse_date(date_str: str) -> datetime:
date_str = date_str.strip()
for fmt in formats:
try:
return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
expected = "'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'" if is_v2 else "'YYYY-MM-DD HH:MM:SS'"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date format: {date_str}. Expected: {expected}",
)
def parse_date(date_str: str) -> datetime:
date_str = date_str.strip()
for fmt in formats:
try:
return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
expected = "'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'" if is_v2 else "'YYYY-MM-DD HH:MM:SS'"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date format: {date_str}. Expected: {expected}",
)
start_date_obj = parse_date(start_date)
end_date_obj = parse_date(end_date)
# Convert to ISO format strings for Prisma
start_date_iso = start_date_obj.isoformat() # Already in UTC, no need to add Z
end_date_iso = end_date_obj.isoformat() # Already in UTC, no need to add Z
start_date_obj = parse_date(start_date)
end_date_obj = parse_date(end_date)
# Build where conditions
where_conditions: dict[str, Any] = {
"startTime": {"gte": start_date_iso, "lte": end_date_iso},
}
where_conditions: dict[str, Any] = {}
if start_date_obj is not None and end_date_obj is not None:
where_conditions["startTime"] = {
"gte": start_date_obj.isoformat(), # Already in UTC, no need to add Z
"lte": end_date_obj.isoformat(),
}
if team_id is not None:
where_conditions["team_id"] = team_id
@ -1827,9 +1838,19 @@ async def ui_view_spend_logs(
where_conditions["spend"]["gte"] = min_spend
if max_spend is not None:
where_conditions["spend"]["lte"] = max_spend
is_admin_view = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
# A request_id lookup drops the date window, so a non-admin could otherwise
# reach any single row by id; require they own it, mirroring the detail
# endpoint. That ownership check fully authorizes the one row, so the
# general scoping below is skipped for id lookups. Scoped to the UI route
# so the public v2 contract is unchanged.
if request_id is not None and not is_v2 and not is_admin_view:
await _assert_user_can_view_request_id(
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
request_id=request_id,
)
permitted_team_ids: List[str] | None = None
if not is_admin_view:
if not is_request_id_lookup and not is_admin_view:
if team_id is not None:
can_view_team = await _can_team_member_view_log(
prisma_client=prisma_client,
@ -1875,15 +1896,16 @@ async def ui_view_spend_logs(
sql_params: List[Any] = []
p = 1 # parameter index counter
# Date range (always present). Wrap the param side with
# `AT TIME ZONE 'UTC'` so comparison against the plain `timestamp`
# column does not depend on the DB session timezone (see #22529).
sql_conditions.append(f"\"startTime\" >= (${p}::timestamptz AT TIME ZONE 'UTC')")
sql_params.append(start_date_obj)
p += 1
sql_conditions.append(f"\"startTime\" <= (${p}::timestamptz AT TIME ZONE 'UTC')")
sql_params.append(end_date_obj)
p += 1
# Date range. Wrap the param side with `AT TIME ZONE 'UTC'` so comparison
# against the plain `timestamp` column does not depend on the DB session
# timezone (see #22529). Absent for a request_id-only lookup (see above).
if start_date_obj is not None and end_date_obj is not None:
sql_conditions.append(f"\"startTime\" >= (${p}::timestamptz AT TIME ZONE 'UTC')")
sql_params.append(start_date_obj)
p += 1
sql_conditions.append(f"\"startTime\" <= (${p}::timestamptz AT TIME ZONE 'UTC')")
sql_params.append(end_date_obj)
p += 1
# Equality filters - read effective values from where_conditions (post-authorization)
for sql_col, wc_key in [

View file

@ -1628,6 +1628,226 @@ async def test_ui_view_spend_logs_date_range_filter(client, monkeypatch):
assert data["data"][0]["id"] == "log2"
@pytest.mark.asyncio
async def test_ui_view_spend_logs_request_id_lookup_ignores_date_window(
client, monkeypatch
):
"""
LIT-3981: a request_id lookup on the UI route resolves across all time even
when the caller sends a date window that excludes the log (the dashboard
always sends a window). The window is dropped and request_id alone scopes
the query. Pre-fix the window was always applied, so an id from an older
page returned nothing.
"""
today = datetime.datetime.now(timezone.utc)
mock_spend_logs = [
{
"id": "log_old",
"request_id": "req-old",
"api_key": "sk-test-key",
"user": "test_user_1",
"team_id": "team1",
"spend": 0.05,
"startTime": (today - datetime.timedelta(days=90)).isoformat(),
"model": "gpt-4",
},
]
captured: dict = {}
def filter_fn(where):
captured["where"] = where
rows = _filter_logs_by_date_range(mock_spend_logs, where)
if where.get("request_id"):
rows = [r for r in rows if r["request_id"] == where["request_id"]]
return rows
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn),
)
# A 5-day window that EXCLUDES the 90-day-old log, as the dashboard sends.
start_date = (today - datetime.timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
end_date = today.strftime("%Y-%m-%d %H:%M:%S")
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN
)
try:
response = client.get(
"/spend/logs/ui",
params={
"request_id": "req-old",
"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 data["data"][0]["request_id"] == "req-old"
# Query dropped the time window and scoped solely by the primary key.
assert "startTime" not in captured["where"]
assert captured["where"]["request_id"] == "req-old"
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_requires_dates_without_request_id(
client, monkeypatch
):
"""The date window stays mandatory on the UI route when no request_id is set."""
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma([], lambda where: []),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN
)
try:
response = client.get(
"/spend/logs/ui", headers={"Authorization": "Bearer sk-test"}
)
assert response.status_code == 400
assert "date" in response.text.lower()
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_spend_logs_v2_still_requires_dates_with_request_id(client, monkeypatch):
"""The public /spend/logs/v2 contract is unchanged: dates remain required even
when request_id is supplied. Only the internal UI route relaxes the window."""
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma([], lambda where: []),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN
)
try:
response = client.get(
"/spend/logs/v2",
params={"request_id": "req-old"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 400
assert "date" in response.text.lower()
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_request_id_blocks_non_owner(client, monkeypatch):
"""A non-admin looking up a request_id they do not own is rejected (403), so
the relaxed date window cannot read another tenant's log by id."""
class _ForeignRow:
user = "other_user"
team_id = None
class _SpendLogs:
async def find_unique(self, where, include=None):
return _ForeignRow()
class _DB:
def __init__(self):
self.litellm_spendlogs = _SpendLogs()
class _Prisma:
def __init__(self):
self.db = _DB()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _Prisma())
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1"
)
try:
response = client.get(
"/spend/logs/ui",
params={"request_id": "foreign-req"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 403
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_request_id_owner_scoped_by_id_only(
client, monkeypatch
):
"""A non-admin owner looking up their own request_id resolves across all time.
The ownership check authorizes the single row, so the query drops both the date
window and the general user/team scoping and filters by the primary key alone;
without that skip an internal user would have a `user`/`OR` clause added."""
today = datetime.datetime.now(timezone.utc)
mock_spend_logs = [
{
"id": "log_old",
"request_id": "req-old",
"api_key": "sk-test-key",
"user": "user_1",
"team_id": "team1",
"spend": 0.05,
"startTime": (today - datetime.timedelta(days=90)).isoformat(),
"model": "gpt-4",
},
]
captured: dict = {}
def filter_fn(where):
captured["where"] = where
rows = _filter_logs_by_date_range(mock_spend_logs, where)
if where.get("request_id"):
rows = [r for r in rows if r["request_id"] == where["request_id"]]
return rows
mock_prisma = make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn)
class _OwnedRow:
user = "user_1"
team_id = "team1"
async def _find_unique(where, include=None):
return _OwnedRow()
mock_prisma.db.find_unique = _find_unique
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
# A 5-day window that EXCLUDES the 90-day-old log, as the dashboard sends.
start_date = (today - datetime.timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
end_date = today.strftime("%Y-%m-%d %H:%M:%S")
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1"
)
try:
response = client.get(
"/spend/logs/ui",
params={
"request_id": "req-old",
"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 data["data"][0]["request_id"] == "req-old"
assert "startTime" not in captured["where"]
assert captured["where"]["request_id"] == "req-old"
assert "user" not in captured["where"]
assert "OR" not in captured["where"]
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_unauthorized(client):
# Test without authorization header

View file

@ -121,23 +121,21 @@ describe("RequestLogsPanel", () => {
});
});
describe("client-side search", () => {
it("narrows the visible rows without refetching", async () => {
describe("search by request id (LIT-3981)", () => {
it("sends the typed request id to the server on the first page instead of filtering the loaded rows", async () => {
const user = userEvent.setup();
respondWith([
logEntry({ request_id: "req-alpha", model: "gpt-4o" }),
logEntry({ request_id: "req-beta", model: "claude-opus" }),
]);
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
await waitFor(() => expect(row("req-alpha")).not.toBeNull());
const callsBefore = vi.mocked(uiSpendLogsCall).mock.calls.length;
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
await user.type(screen.getByTestId("datatable-search"), "alpha");
await user.type(screen.getByTestId("datatable-search"), "req-on-another-page");
await waitFor(() => expect(row("req-beta")).toBeNull());
expect(row("req-alpha")).not.toBeNull();
expect(vi.mocked(uiSpendLogsCall).mock.calls.length).toBe(callsBefore);
await waitFor(() => {
const call = lastCall();
if (!call) throw new Error("uiSpendLogsCall was not called");
expect(call.params?.request_id).toBe("req-on-another-page");
expect(call.page).toBe(1);
});
});
});

View file

@ -11,7 +11,7 @@ import { keyInfoV1Call } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import type { LogEntry } from "./columns";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
import { DEFAULT_LOGS_SORTING, useLogFilterLogic } from "./log_filter_logic";
import { DEFAULT_LOGS_SORTING, LOG_FILTER_IDS, useLogFilterLogic } from "./log_filter_logic";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar";
import { RequestLogsTable } from "./RequestLogsTable";
@ -34,7 +34,6 @@ interface SessionComposition {
}
export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) {
const [searchTerm, setSearchTerm] = useState("");
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
const [sorting, setSorting] = useState<SortingState>(DEFAULT_LOGS_SORTING);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@ -93,14 +92,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const { data: selectedKeyInfo } = useQuery(keyInfoQueryOptions);
const rows = useMemo<LogEntry[]>(() => {
const searchedLogs = filteredLogs.data.filter((log) => {
if (!searchTerm) return true;
return (
log.request_id.includes(searchTerm) ||
log.model.includes(searchTerm) ||
(log.user !== undefined && log.user.includes(searchTerm))
);
});
const searchedLogs = filteredLogs.data;
const sessionCompositionById = searchedLogs.reduce<Record<string, SessionComposition>>((acc, log) => {
if (!log.session_id) return acc;
@ -141,7 +133,20 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
if (!log.session_id || (log.session_total_count || 1) <= 1) return true;
return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id;
});
}, [filteredLogs.data, searchTerm]);
}, [filteredLogs.data]);
const searchTerm = useMemo(() => {
const entry = columnFilters.find((filter) => filter.id === LOG_FILTER_IDS.REQUEST_ID);
return typeof entry?.value === "string" ? entry.value : "";
}, [columnFilters]);
const handleSearchChange = useCallback((value: string) => {
setColumnFilters((previous) => {
const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.REQUEST_ID);
return value === "" ? others : [...others, { id: LOG_FILTER_IDS.REQUEST_ID, value }];
});
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const handleSortingChange = useCallback<OnChangeFn<SortingState>>((updaterOrValue) => {
setSorting(updaterOrValue);
@ -159,7 +164,6 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const handleResetFilters = useCallback(() => {
setColumnFilters([]);
setSearchTerm("");
setStartTime(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
setEndTime(moment().format("YYYY-MM-DDTHH:mm"));
setIsCustomDate(false);
@ -221,7 +225,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
columnFilters={columnFilters}
onColumnFiltersChange={handleColumnFiltersChange}
searchValue={searchTerm}
onSearchChange={setSearchTerm}
onSearchChange={handleSearchChange}
onRefresh={() => void logsQuery.refetch()}
onRowClick={handleRowClick}
onKeyHashClick={handleKeyHashClick}