fix(ui): show all session logs in the drawer, not just the first 50 (#29795)

* fix(ui): show newest session logs first

* test(ui): keep session log pagination coverage

* fix(ui): show all session logs in the drawer, not just the first page

The session detail drawer fetched session logs via sessionSpendLogsCall
without page/page_size, so it only ever received the backend default of one
page (50 rows). Sessions with more than 50 calls had the rest unreachable in
the UI (#29153).

sessionSpendLogsCall now takes page/page_size, and the drawer fetches the
first page, reads total_pages, then fetches the remaining pages and
accumulates them before the existing client-side sort. This keeps the single
continuous list (and the selected-log lookup and keyboard navigation, which
all assume the full session) correct. Fetching is bounded by a page cap, and
the sidebar shows a "showing most recent N" note if a session exceeds it.

The rows are lightweight metadata (the endpoint excludes messages/response),
so the full set is small; request/response bodies are still loaded per log on
demand.

* fix(ui): default session drawer to most recent log, newest first

Open a session with its most recent log selected, and order the sidebar
newest-first to match the all-sessions logs overview. MCP calls stay
grouped last. The latest log by time is computed explicitly, since the
MCP grouping means it is not always the first row.

* Apply fetching pages in batches suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(ui): derive session total from accumulated rows when backend omits it

Compute the session total after all pages are fetched, falling back to the
accumulated row count rather than the first page's. Guards the truncation
note against a backend response that omits total but spans multiple pages.

---------

Co-authored-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Ceder Dens 2026-06-09 13:22:43 +02:00 • committed by Sameer Kankute
parent d737ace293
commit d4dc145bd9
No known key found for this signature in database
5 changed files with 145 additions and 19 deletions

View file

@ -3405,7 +3405,7 @@ async def ui_view_session_spend_logs(
session_id, status, mcp_namespaced_tool_name, agent_id
FROM "LiteLLM_SpendLogs"
WHERE session_id = $1
ORDER BY "startTime" ASC
ORDER BY "startTime" DESC
LIMIT $2 OFFSET $3
"""
result = await prisma_client.db.query_raw(

View file

@ -1314,7 +1314,8 @@ async def test_ui_view_session_spend_logs_pagination(client, monkeypatch):
assert session_id == "session-123"
assert page_size == 1
assert skip == 1 # page=2, page_size=1
return [mock_spend_logs[1]]
assert 'ORDER BY "startTime" DESC' in sql_query
return [mock_spend_logs[0]]
class MockPrismaClient:
def __init__(self):
@ -1337,7 +1338,7 @@ async def test_ui_view_session_spend_logs_pagination(client, monkeypatch):
assert data["page_size"] == 1
assert data["total_pages"] == 2
assert len(data["data"]) == 1
assert data["data"][0]["request_id"] == "req2"
assert data["data"][0]["request_id"] == "req1"
@pytest.mark.asyncio

View file

@ -467,3 +467,50 @@ describe("teamInfoCall", () => {
expect(parsed.searchParams.has("team_id")).toBe(false);
});
});
describe("sessionSpendLogsCall", () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
it("should request the first page with defaults so the caller can page through the session", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: [], total: 0, page: 1, page_size: 100, total_pages: 1 }),
} as any);
global.fetch = mockFetch as any;
await Networking.sessionSpendLogsCall("token", "session-123");
expect(mockFetch).toHaveBeenCalledOnce();
const [url] = mockFetch.mock.calls[0];
const urlStr = typeof url === "string" ? url : (url as Request).url;
const parsed = typeof url === "string" ? new URL(url, "http://example.com") : new URL((url as Request).url);
expect(urlStr).toContain("/spend/logs/session/ui");
expect(parsed.searchParams.get("session_id")).toBe("session-123");
expect(parsed.searchParams.get("page")).toBe("1");
expect(parsed.searchParams.get("page_size")).toBe("100");
});
it("should pass explicit page and page_size query params for later pages", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: [], total: 250, page: 3, page_size: 100, total_pages: 3 }),
} as any);
global.fetch = mockFetch as any;
await Networking.sessionSpendLogsCall("token", "session-123", 3, 100);
const [url] = mockFetch.mock.calls[0];
const parsed = typeof url === "string" ? new URL(url, "http://example.com") : new URL((url as Request).url);
expect(parsed.searchParams.get("page")).toBe("3");
expect(parsed.searchParams.get("page_size")).toBe("100");
});
});

View file

@ -5616,13 +5616,27 @@ export const teamPermissionsUpdateCall = async (accessToken: string, teamId: str
};
/**
* Get all spend logs for a particular session
* Get a page of spend logs for a particular session.
*
* The backend paginates this endpoint (page / page_size, returning
* { data, total, page, page_size, total_pages }). Callers that need the whole
* session should page through total_pages and accumulate the results.
*/
export const sessionSpendLogsCall = async (accessToken: string, session_id: string) => {
export const sessionSpendLogsCall = async (
accessToken: string,
session_id: string,
page: number = 1,
page_size: number = 100,
) => {
try {
const params = new URLSearchParams({
session_id,
page: String(page),
page_size: String(page_size),
});
let url = proxyBaseUrl
? `${proxyBaseUrl}/spend/logs/session/ui?session_id=${encodeURIComponent(session_id)}`
: `/spend/logs/session/ui?session_id=${encodeURIComponent(session_id)}`;
? `${proxyBaseUrl}/spend/logs/session/ui?${params.toString()}`
: `/spend/logs/session/ui?${params.toString()}`;
const response = await fetch(url, {
method: "GET",

View file

@ -28,6 +28,14 @@ export interface LogDetailsDrawerProps {
const SIDEBAR_WIDTH_PX = 224;
// Session logs are fetched page-by-page from the paginated backend and
// accumulated so the drawer can show the whole session. page_size is the
// backend maximum (le=100); the page cap bounds the fetch and the
// (un-virtualized) sidebar list for pathological sessions, keeping the most
// recent logs since the endpoint returns newest-first.
const SESSION_PAGE_SIZE = 100;
const MAX_SESSION_PAGES = 50;
/* ------------------------------------------------------------------ */
/* TraceEventRow — compact event row used in both session & non- */
/* session sidebar lists. Extracted to avoid JSX duplication. */
@ -112,13 +120,39 @@ export function LogDetailsDrawer({
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [copiedLeftPanelId, setCopiedLeftPanelId] = useState(false);
const { data: sessionLogs = [] } = useQuery({
const { data: sessionData } = useQuery({
queryKey: ["sessionLogs", sessionId],
queryFn: async () => {
if (!sessionId || !accessToken) return [];
const response = await sessionSpendLogsCall(accessToken, sessionId);
const allSessionLogs: LogEntry[] = response.data || response || [];
return allSessionLogs
if (!sessionId || !accessToken) return { logs: [] as LogEntry[], total: 0 };
// Fetch the first page, then page through the rest so sessions with more
// than one page of logs are shown in full (capped for safety).
const firstPage = await sessionSpendLogsCall(accessToken, sessionId, 1, SESSION_PAGE_SIZE);
let rows: LogEntry[] = firstPage.data || firstPage || [];
const pagesToFetch = Math.min(firstPage.total_pages ?? 1, MAX_SESSION_PAGES);
if (pagesToFetch > 1) {
const BATCH = 5;
const remaining: Awaited<ReturnType<typeof sessionSpendLogsCall>>[] = [];
for (let start = 2; start <= pagesToFetch; start += BATCH) {
const end = Math.min(start + BATCH - 1, pagesToFetch);
const batch = await Promise.all(
Array.from({ length: end - start + 1 }, (_, i) =>
sessionSpendLogsCall(accessToken, sessionId, start + i, SESSION_PAGE_SIZE),
),
);
remaining.push(...batch);
}
for (const page of remaining) {
rows = rows.concat(page.data || []);
}
}
// Fall back to the accumulated row count (not just the first page) when the
// backend omits total, so the truncation note reflects what was fetched.
const total: number = firstPage.total ?? rows.length;
const logs = rows
.map((row) => ({
...row,
request_duration_ms: row.request_duration_ms ?? Date.parse(row.endTime) - Date.parse(row.startTime),
@ -127,24 +161,49 @@ export function LogDetailsDrawer({
const aIsMcp = MCP_CALL_TYPES.includes(a.call_type) ? 1 : 0;
const bIsMcp = MCP_CALL_TYPES.includes(b.call_type) ? 1 : 0;
if (aIsMcp !== bIsMcp) return aIsMcp - bIsMcp;
return new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
// Newest first, matching the all-sessions logs overview. MCP calls
// stay grouped last (above), newest-first within that group too.
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
});
return { logs, total };
},
enabled: Boolean(open && isSessionMode && sessionId && accessToken),
});
const sessionLogs: LogEntry[] = sessionData?.logs ?? [];
// total reported by the backend; when the page cap truncates the fetch this
// exceeds sessionLogs.length, which drives the "showing most recent" note.
const sessionTotalCount = sessionData?.total ?? sessionLogs.length;
const sessionTruncated = sessionTotalCount > sessionLogs.length;
// Default selection for a freshly opened session: the most recent log (latest
// startTime). The list is sorted newest-first, but MCP calls are grouped last,
// so the latest log by time is not necessarily sessionLogs[0]; compute it
// explicitly. A clicked/remembered log still wins over this default.
const mostRecentLog = useMemo<LogEntry | null>(
() =>
sessionLogs.reduce<LogEntry | null>(
(latest, row) =>
!latest || new Date(row.startTime).getTime() > new Date(latest.startTime).getTime() ? row : latest,
null,
),
[sessionLogs],
);
const currentLog = useMemo(() => {
if (!isSessionMode) return logEntry;
if (!sessionLogs.length) return null;
const fallbackLog = mostRecentLog ?? sessionLogs[0];
if (selectedSessionRequestId) {
return sessionLogs.find((row) => row.request_id === selectedSessionRequestId) || sessionLogs[0];
return sessionLogs.find((row) => row.request_id === selectedSessionRequestId) || fallbackLog;
}
if (logEntry?.request_id) {
const clickedLog = sessionLogs.find((row) => row.request_id === logEntry.request_id);
return clickedLog || sessionLogs[0];
return clickedLog || fallbackLog;
}
return sessionLogs[0];
}, [isSessionMode, logEntry, selectedSessionRequestId, sessionLogs]);
return fallbackLog;
}, [isSessionMode, logEntry, selectedSessionRequestId, sessionLogs, mostRecentLog]);
useEffect(() => {
if (!isSessionMode || !sessionLogs.length) return;
@ -152,10 +211,10 @@ export function LogDetailsDrawer({
const fallbackRequestId =
logEntry?.request_id && sessionLogs.some((row) => row.request_id === logEntry.request_id)
? logEntry.request_id
: sessionLogs[0].request_id;
: (mostRecentLog ?? sessionLogs[0]).request_id;
setSelectedSessionRequestId(fallbackRequestId);
}
}, [isSessionMode, logEntry, selectedSessionRequestId, sessionLogs]);
}, [isSessionMode, logEntry, selectedSessionRequestId, sessionLogs, mostRecentLog]);
// Reset transient UI state when the drawer opens or closes.
useEffect(() => {
@ -327,6 +386,11 @@ export function LogDetailsDrawer({
</>
)}
</div>
{isSessionMode && sessionTruncated && (
<div className="mt-1 text-[11px] text-amber-600 font-mono">
Showing most recent {logsForList.length} of {sessionTotalCount}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">