mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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:
parent
d737ace293
commit
d4dc145bd9
5 changed files with 145 additions and 19 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue