feat(ui): drill down from the cache chart into pre-filtered logs

Clicking a slice of the Cache Hits vs API Requests chart now lands on the
Logs page pre-filtered to exactly those requests: the failed slice maps to
status=failure, the cache-hit slice to cache_hit=true, and the api-requests
slice to status=success plus cache_hit=false, all scoped to the bar's
call_type and the chart's date range. To support that, /spend/logs/ui and
/spend/logs/v2 gain optional call_type and cache_hit query filters (additive,
so no breaking change to the public v2 surface), the Logs filter bar gets
matching Call Type and Cache Hit controls, and the Logs page seeds its
initial filters and time window from URL query params, which also makes
filtered logs views shareable by link. A seeded window disables live tail
so the drill-down target stays fixed on the clicked range
This commit is contained in:
ryan-crabbe-berri 2026-07-27 18:32:55 -07:00
parent 4d5cab3143
commit 8e76aacae2
10 changed files with 313 additions and 6 deletions

View file

@ -1660,6 +1660,10 @@ async def ui_view_spend_logs(
model_group: str | None = fastapi.Query(default=None, description="Filter logs by model group"),
key_alias: str | None = fastapi.Query(default=None, description="Filter logs by key alias"),
end_user: str | None = fastapi.Query(default=None, description="Filter logs by end user"),
call_type: str | None = fastapi.Query(default=None, description="Filter logs by call type (e.g., acompletion)"),
cache_hit: bool | None = fastapi.Query(
default=None, description="Filter logs by response-cache outcome: true for cache hits, false for misses"
),
error_code: str | None = fastapi.Query(default=None, description="Filter logs by error code (e.g., '404', '500')"),
error_message: str | None = fastapi.Query(
default=None, description="Filter logs by error message (partial string match)"
@ -1796,6 +1800,9 @@ async def ui_view_spend_logs(
if model_group is not None:
where_conditions["model_group"] = model_group
if call_type is not None:
where_conditions["call_type"] = call_type
# Build metadata filters
metadata_filters = []
if key_alias is not None:
@ -1917,6 +1924,7 @@ async def ui_view_spend_logs(
("model_id", "model_id"),
("model_group", "model_group"),
("end_user", "end_user"),
("call_type", "call_type"),
]:
val = where_conditions.get(wc_key)
if val is not None and isinstance(val, str):
@ -1947,6 +1955,14 @@ async def ui_view_spend_logs(
sql_params.append(status_filter)
p += 1
# Cache hit filter - only a literal 'True' is a hit; '', 'False', and
# NULL are all misses, so the miss side must be NULL-safe.
if cache_hit is not None:
if cache_hit:
sql_conditions.append("cache_hit = 'True'")
else:
sql_conditions.append("cache_hit IS DISTINCT FROM 'True'")
# Spend range
if min_spend is not None:
sql_conditions.append(f"spend >= ${p}")

View file

@ -90,6 +90,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
"model_id": "model_id",
"model_group": "model_group",
"end_user": "end_user",
"call_type": "call_type",
}
date_bounds: dict = {}
metadata_conds: list = []
@ -109,6 +110,10 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
where["OR"] = where.get("OR", []) + [{"multi_team": True}]
elif "status = 'success'" in cond:
where["OR"] = where.get("OR", []) + [{"status": "success"}]
elif cond == "cache_hit = 'True'":
where["cache_hit"] = {"is_hit": True}
elif cond == "cache_hit IS DISTINCT FROM 'True'":
where["cache_hit"] = {"is_hit": False}
elif sess:
where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")}
elif status:
@ -613,6 +618,88 @@ async def test_ui_view_spend_logs_with_user_id(client, monkeypatch):
assert data["data"][0]["user"] == "test_user_1"
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_call_type(client, monkeypatch):
now = datetime.datetime.now(timezone.utc).isoformat()
mock_spend_logs = [
{"request_id": "req1", "call_type": "acompletion", "spend": 0.05, "startTime": now},
{"request_id": "req2", "call_type": "anthropic_messages", "spend": 0.10, "startTime": now},
]
def filter_by_call_type(where):
call_type = where.get("call_type")
if call_type is None:
return mock_spend_logs
return [log for log in mock_spend_logs if log["call_type"] == call_type]
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_call_type),
)
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/ui",
params={
"call_type": "anthropic_messages",
"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"] == "req2"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cache_hit_query,expected_request_ids",
[
("true", {"req-hit"}),
("false", {"req-false", "req-empty", "req-null"}),
],
)
async def test_ui_view_spend_logs_with_cache_hit(client, monkeypatch, cache_hit_query, expected_request_ids):
now = datetime.datetime.now(timezone.utc).isoformat()
mock_spend_logs = [
{"request_id": "req-hit", "cache_hit": "True", "spend": 0.0, "startTime": now},
{"request_id": "req-false", "cache_hit": "False", "spend": 0.05, "startTime": now},
{"request_id": "req-empty", "cache_hit": "", "spend": 0.05, "startTime": now},
{"request_id": "req-null", "cache_hit": None, "spend": 0.05, "startTime": now},
]
def filter_by_cache_hit(where):
condition = where.get("cache_hit")
if condition is None:
return mock_spend_logs
if condition["is_hit"]:
return [log for log in mock_spend_logs if log["cache_hit"] == "True"]
return [log for log in mock_spend_logs if log["cache_hit"] != "True"]
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_cache_hit),
)
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/ui",
params={
"cache_hit": cache_hit_query,
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data = response.json()
assert {log["request_id"] for log in data["data"]} == expected_request_ids
@pytest.mark.asyncio
@pytest.mark.parametrize(
"session_id_query,expected_request_ids",

View file

@ -1,8 +1,8 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { renderWithProviders } from "../../../../../tests/test-utils";
import CacheDashboard from "./cache_dashboard";
import CacheDashboard, { buildLogsDrilldownUrl } from "./cache_dashboard";
const { useCacheActivity, cachingHealthCheckCall } = vi.hoisted(() => ({
useCacheActivity: vi.fn(),
@ -11,6 +11,7 @@ const { useCacheActivity, cachingHealthCheckCall } = vi.hoisted(() => ({
vi.mock("@/components/networking", () => ({
cachingHealthCheckCall,
serverRootPath: "/",
}));
vi.mock("@/app/(dashboard)/hooks/caching/useCacheActivity", () => ({
@ -198,3 +199,58 @@ describe("CacheDashboard cache analytics charts", () => {
expect(compactTicks(tokensCard)).toContain("60K");
});
});
describe("cache chart drill-down into the Logs page", () => {
const range = { startDate: "2026-07-20", endDate: "2026-07-27" };
it("maps a failed-requests slice to a status=failure logs link scoped to the bar's call type", () => {
const url = buildLogsDrilldownUrl({ name: "anthropic_messages", categoryClicked: "Failed requests" }, range);
expect(url).toBe(
"/ui/logs?call_type=anthropic_messages&status=failure&start_time=2026-07-20T00%3A00&end_time=2026-07-27T23%3A59",
);
});
it("maps a cache-hit slice to cache_hit=true", () => {
const url = buildLogsDrilldownUrl({ name: "acompletion", categoryClicked: "Cache hit" }, range);
expect(url).toContain("cache_hit=true");
expect(url).not.toContain("status=");
});
it("maps an api-requests slice to successful cache misses", () => {
const url = buildLogsDrilldownUrl({ name: "acompletion", categoryClicked: "LLM API requests" }, range);
expect(url).toContain("status=success");
expect(url).toContain("cache_hit=false");
});
it("omits call_type for the Unknown bucket since those rows have an empty call_type", () => {
const url = buildLogsDrilldownUrl({ name: "Unknown", categoryClicked: "Failed requests" }, range);
expect(url).not.toContain("call_type");
expect(url).toContain("status=failure");
});
it("navigates to the logs page when a requests-chart bar is clicked", async () => {
useCacheActivity.mockReturnValue({ data: cacheActivity, refetch: vi.fn() });
const assign = vi.fn();
vi.stubGlobal("location", { ...window.location, assign, search: "" });
renderWithProviders(
<CacheDashboard accessToken="sk-test" token="tok" userRole="Admin" userID="u1" premiumUser={false} />,
);
await screen.findByText("Cache Hits vs API Requests");
await waitFor(() => {
expect(document.querySelectorAll("path.recharts-rectangle").length).toBeGreaterThan(0);
});
const requestsCard = screen.getByText("Cache Hits vs API Requests").closest('[data-slot="card"]') as HTMLElement;
fireEvent.click(requestsCard.querySelector("path.recharts-rectangle")!);
expect(assign).toHaveBeenCalledTimes(1);
expect(String(assign.mock.calls[0][0])).toContain("/logs?call_type=acompletion");
vi.unstubAllGlobals();
});
});

View file

@ -21,6 +21,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RefreshCw } from "lucide-react";
import { cachingHealthCheckCall } from "@/components/networking";
import { useCacheActivity, type CacheActivityGroup } from "@/app/(dashboard)/hooks/caching/useCacheActivity";
import { migratedHref } from "@/utils/migratedPages";
// Import the new component
import { CacheHealthTab } from "./cache_health";
@ -42,6 +43,31 @@ const toChartDatum = (group: CacheActivityGroup) => ({
"Generated Completion Tokens": group.generated_completion_tokens,
});
const UNKNOWN_CALL_TYPE = "Unknown";
export const buildLogsDrilldownUrl = (
clicked: { name: string; categoryClicked: string },
range: { startDate: string | undefined; endDate: string | undefined },
): string => {
const params = new URLSearchParams();
if (clicked.name !== UNKNOWN_CALL_TYPE) {
params.set("call_type", clicked.name);
}
if (clicked.categoryClicked === REQUEST_SERIES.failed) {
params.set("status", "failure");
}
if (clicked.categoryClicked === REQUEST_SERIES.cacheHits) {
params.set("cache_hit", "true");
}
if (clicked.categoryClicked === REQUEST_SERIES.apiRequests) {
params.set("status", "success");
params.set("cache_hit", "false");
}
if (range.startDate) params.set("start_time", `${range.startDate}T00:00`);
if (range.endDate) params.set("end_time", `${range.endDate}T23:59`);
return `${migratedHref("logs")}?${params.toString()}`;
};
const formatDateWithoutTZ = (date: Date | undefined) => {
if (!date) return undefined;
return date.toISOString().split("T")[0];
@ -307,6 +333,15 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
categories={[REQUEST_SERIES.apiRequests, REQUEST_SERIES.cacheHits, REQUEST_SERIES.failed]}
colors={["sky", "teal", "red"]}
yAxisWidth={48}
className="cursor-pointer"
onValueChange={(clicked) =>
window.location.assign(
buildLogsDrilldownUrl(clicked, {
startDate: formatDateWithoutTZ(dateValue.from),
endDate: formatDateWithoutTZ(dateValue.to),
}),
)
}
/>
</CardContent>
</Card>

View file

@ -1924,6 +1924,8 @@ interface UiSpendLogsParams {
key_alias?: string;
error_code?: string;
error_message?: string;
call_type?: string;
cache_hit?: "true" | "false";
sort_by?: string;
sort_order?: "asc" | "desc";
min_spend?: number;

View file

@ -320,6 +320,32 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
placeholder="Enter public model or search tool…"
/>
</DataTableFilterField>
<DataTableFilterField label="Call Type">
<Input
value={valueOf(LOG_FILTER_IDS.CALL_TYPE)}
onChange={(event) => set(LOG_FILTER_IDS.CALL_TYPE, emptyToUndefined(event.target.value))}
placeholder="e.g. acompletion…"
/>
</DataTableFilterField>
<DataTableFilterField label="Cache Hit">
<Select
value={valueOf(LOG_FILTER_IDS.CACHE_HIT) === "" ? ALL_VALUE : valueOf(LOG_FILTER_IDS.CACHE_HIT)}
onValueChange={(next) =>
set(LOG_FILTER_IDS.CACHE_HIT, next === null || next === ALL_VALUE ? undefined : next)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Requests" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>All Requests</SelectItem>
<SelectItem value="true">Cache Hit</SelectItem>
<SelectItem value="false">Cache Miss</SelectItem>
</SelectContent>
</Select>
</DataTableFilterField>
</>
);
}

View file

@ -17,6 +17,7 @@ import {
formatLogsWindow,
getLogsWindowEndBound,
LOG_FILTER_IDS,
readLogsUrlSeed,
useLogFilterLogic,
} from "./log_filter_logic";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
@ -41,13 +42,18 @@ interface SessionComposition {
}
export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) {
const [urlSeed] = useState(() => readLogsUrlSeed(typeof window === "undefined" ? "" : window.location.search));
const seededWindow = urlSeed.startTime !== null && urlSeed.endTime !== null;
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
const [sorting, setSorting] = useState<SortingState>(DEFAULT_LOGS_SORTING);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(urlSeed.columnFilters);
const [startTime, setStartTime] = useState<string>(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
const [endTime, setEndTime] = useState<string>(moment().format("YYYY-MM-DDTHH:mm"));
const [isCustomDate, setIsCustomDate] = useState(false);
const [startTime, setStartTime] = useState<string>(
urlSeed.startTime ?? moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"),
);
const [endTime, setEndTime] = useState<string>(urlSeed.endTime ?? moment().format("YYYY-MM-DDTHH:mm"));
const [isCustomDate, setIsCustomDate] = useState(seededWindow);
const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>(DEFAULT_INTERVAL);
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState<string | null>(null);
@ -56,6 +62,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [isLiveTail, setIsLiveTail] = useState<boolean>(() => {
if (seededWindow) return false;
const storedValue = sessionStorage.getItem("isLiveTail");
return storedValue !== null ? JSON.parse(storedValue) : true;
});

View file

@ -13,6 +13,7 @@ import {
LIVE_TAIL_INTERVAL_MS,
LOG_FILTER_IDS,
LOGS_WINDOW_TICK_MS,
readLogsUrlSeed,
useLogFilterLogic,
type PaginatedResponse,
} from "./log_filter_logic";
@ -87,6 +88,8 @@ describe("useLogFilterLogic", () => {
{ id: LOG_FILTER_IDS.ERROR_CODE, value: "429", param: "error_code" },
{ id: LOG_FILTER_IDS.ERROR_MESSAGE, value: "rate limited", param: "error_message" },
{ id: LOG_FILTER_IDS.USER_ID, value: "user-9", param: "user_id" },
{ id: LOG_FILTER_IDS.CALL_TYPE, value: "anthropic_messages", param: "call_type" },
{ id: LOG_FILTER_IDS.CACHE_HIT, value: "true", param: "cache_hit" },
];
it.each(cases)("sends $id as $param", async ({ id, value, param }) => {
@ -110,6 +113,13 @@ describe("useLogFilterLogic", () => {
expect(params?.api_key).toBeUndefined();
expect(params?.error_code).toBeUndefined();
});
it("drops a cache_hit filter value that is not true or false", async () => {
renderFilterHook({ columnFilters: [{ id: LOG_FILTER_IDS.CACHE_HIT, value: "maybe" }] });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()?.params?.cache_hit).toBeUndefined();
});
});
describe("paging, dates, and sort", () => {
@ -294,3 +304,33 @@ describe("formatLogsWindow preset end bound", () => {
);
});
});
describe("readLogsUrlSeed", () => {
it("seeds column filters from known filter params and ignores unrelated ones", () => {
const seed = readLogsUrlSeed("?status=failure&call_type=anthropic_messages&cache_hit=true&page_size=50&foo=bar");
expect(seed.columnFilters).toEqual(
expect.arrayContaining([
{ id: LOG_FILTER_IDS.STATUS, value: "failure" },
{ id: LOG_FILTER_IDS.CALL_TYPE, value: "anthropic_messages" },
{ id: LOG_FILTER_IDS.CACHE_HIT, value: "true" },
]),
);
expect(seed.columnFilters).toHaveLength(3);
});
it("captures the seeded time window", () => {
const seed = readLogsUrlSeed("?start_time=2026-07-20T00:00&end_time=2026-07-27T23:59");
expect(seed.startTime).toBe("2026-07-20T00:00");
expect(seed.endTime).toBe("2026-07-27T23:59");
});
it("returns an empty seed for an empty query string", () => {
expect(readLogsUrlSeed("")).toEqual({ columnFilters: [], startTime: null, endTime: null });
});
it("skips blank filter values", () => {
expect(readLogsUrlSeed("?status=&call_type=%20%20").columnFilters).toEqual([]);
});
});

View file

@ -29,6 +29,8 @@ export const LOG_FILTER_IDS = {
PUBLIC_MODEL_OR_SEARCH_TOOL: "model",
REQUEST_ID: "request_id",
USER_ID: "user_id",
CALL_TYPE: "call_type",
CACHE_HIT: "cache_hit",
} as const;
export const LOG_FILTER_LABELS: Record<string, string> = {
@ -42,6 +44,32 @@ export const LOG_FILTER_LABELS: Record<string, string> = {
[LOG_FILTER_IDS.SESSION_ID]: "Session ID",
[LOG_FILTER_IDS.MODEL_ID]: "Model",
[LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool",
[LOG_FILTER_IDS.CALL_TYPE]: "Call Type",
[LOG_FILTER_IDS.CACHE_HIT]: "Cache Hit",
};
export interface LogsUrlSeed {
columnFilters: ColumnFiltersState;
startTime: string | null;
endTime: string | null;
}
export const readLogsUrlSeed = (search: string): LogsUrlSeed => {
const params = new URLSearchParams(search);
const columnFilters = Object.values(LOG_FILTER_IDS).flatMap((id) => {
const value = params.get(id)?.trim();
return value ? [{ id, value }] : [];
});
return {
columnFilters,
startTime: params.get("start_time"),
endTime: params.get("end_time"),
};
};
const parseCacheHitFilter = (value: string | undefined): "true" | "false" | undefined => {
if (value === "true" || value === "false") return value;
return undefined;
};
export interface LogsWindow {
@ -173,6 +201,8 @@ export function useLogFilterLogic({
key_alias: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_ALIAS),
error_code: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_CODE),
error_message: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_MESSAGE),
call_type: getFilterValue(columnFilters, LOG_FILTER_IDS.CALL_TYPE),
cache_hit: parseCacheHitFilter(getFilterValue(columnFilters, LOG_FILTER_IDS.CACHE_HIT)),
sort_by: sortBy,
sort_order: sortOrder,
},

View file

@ -49546,6 +49546,10 @@ export interface operations {
key_alias?: string | null;
/** @description Filter logs by end user */
end_user?: string | null;
/** @description Filter logs by call type (e.g., acompletion) */
call_type?: string | null;
/** @description Filter logs by response-cache outcome: true for cache hits, false for misses */
cache_hit?: boolean | null;
/** @description Filter logs by error code (e.g., '404', '500') */
error_code?: string | null;
/** @description Filter logs by error message (partial string match) */
@ -49654,6 +49658,10 @@ export interface operations {
key_alias?: string | null;
/** @description Filter logs by end user */
end_user?: string | null;
/** @description Filter logs by call type (e.g., acompletion) */
call_type?: string | null;
/** @description Filter logs by response-cache outcome: true for cache hits, false for misses */
cache_hit?: boolean | null;
/** @description Filter logs by error code (e.g., '404', '500') */
error_code?: string | null;
/** @description Filter logs by error message (partial string match) */