fix-spend-logs - #20720
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile OverviewGreptile SummaryThis PR eliminates the Spend Logs UI N+1 request pattern by removing eager per-row detail prefetching from the main list query, and instead lazy-loads request/response details only when the log-details drawer is opened. On the backend, it removes an incorrect Net effect: list view becomes a single lightweight request per refresh cycle; detailed payloads are fetched on demand and cached client-side for a short window. Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| litellm/proxy/spend_tracking/spend_management_endpoints.py | Removes broken @lru_cache from async spend-log detail endpoint; leaves an unused lru_cache import that should be removed. |
| ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx | Adds lazy-loaded details fetch via React Query when drawer opens; queryKey omits accessToken, allowing cache reuse across auth contexts. |
| ui/litellm-dashboard/src/components/view_logs/index.tsx | Removes eager prefetching of per-row log details; passes accessToken/startTime to drawer for on-demand fetching. |
Sequence Diagram
sequenceDiagram
autonumber
participant U as User
participant UI as Spend Logs UI
participant RQ as React Query
participant API as LiteLLM Proxy API
U->>UI: Open Spend Logs tab
UI->>API: GET /spend/logs/ui?page=…&page_size=…&start_date=…
API-->>UI: 200 { data: logs[] }
Note over UI: No per-row prefetching
U->>UI: Click a log row
UI->>RQ: useQuery enabled (drawer open)
RQ->>API: GET /spend/logs/ui/{request_id}?start_date=…
API-->>RQ: 200 { messages, response, … }
RQ-->>UI: Render request/response in drawer
Additional Comments (1)
|
|
To speed up also, perhaps the default value could be 1h instead of 24 hours ? |
|
@superpoussin22 i changed it to default "4 hours". I think it's good value. |
|
@Sameerlite @ishaan-jaff @krrishdholakia you need anything else or no? I just ask because i don't know if it's good. |
|
@krrishdholakia @ishaan-jaff what do you think ? |
|
@yuneng-jiang maybe you can look into this? I don't know who be responisible of UI and can accept UI changes. |
|
@superpoussin22 do you have any contact or maybe you know who can look into this? |
|
under review, thanks for bumping this @superpoussin22 |
yuneng-jiang
left a comment
There was a problem hiding this comment.
This is a great idea. Thanks for the contribution! Left some comments to request changes, and to get the thought process behind some changes.
|
Thanks for the thorough review @yuneng-jiang! I've addressed all your comments. Here's what changed: 1. Removed
|
| File | Change |
|---|---|
spend_management_endpoints.py |
Removed @lru_cache comment (kept the decorator removal) |
New: hooks/logDetails/useLogDetails.ts |
Extracted hook with useAuthorized() internally |
LogDetailsDrawer.tsx |
Uses useLogDetails hook, removed accessToken prop, simplified data usage |
index.tsx |
Removed accessToken prop from drawer, reverted 24h default |
Additional fix: List endpoint still fetching heavy columns from DB
After the initial review changes were merged and tested, we discovered that the first page load of Spend Logs was still taking 20-25 seconds despite pagination being correctly limited to 50 records. The root cause was that the Prisma ORM find_many call in the list endpoint fetched all columns from LiteLLM_SpendLogs, including the heavyweight messages, response, and proxy_server_request fields — which can be hundreds of KB per row.
Example slow request observed in production:
INFO: 93.159.2.115:0 - "GET /spend/logs/ui?start_date=2026-02-11+06%3A19%3A00&end_date=2026-02-12+06%3A19%3A37&page=1&page_size=50 HTTP/1.1" 200 OK
# Response time: 20-25 seconds
Even though we paginate to 50 rows, pulling messages + response + proxy_server_request for each row meant transferring megabytes of JSON data per page load. These fields are only needed when a user clicks on a specific log row (in the detail drawer), not for the table view.
Changes
1. Replaced Prisma ORM with raw SQL in list endpoint (excluding heavy columns) ✅
File: litellm/proxy/spend_tracking/spend_management_endpoints.py — ui_view_spend_logs()
Replaced the Prisma ORM find_many call:
# Before: fetches ALL columns including messages, response, proxy_server_request
data = await prisma_client.db.litellm_spendlogs.find_many(
where=where_conditions,
order={"startTime": "desc"},
skip=skip,
take=page_size,
)With a raw SQL query that explicitly selects only the lightweight columns needed for the table view:
# After: explicit SELECT without heavy columns
sql_query = f"""
SELECT
request_id, call_type, api_key, spend, total_tokens,
prompt_tokens, completion_tokens, "startTime", "endTime",
"completionStartTime", model, model_id, model_group,
custom_llm_provider, api_base, "user", metadata,
cache_hit, cache_key, request_tags, team_id,
organization_id, end_user, requester_ip_address,
session_id, status, mcp_namespaced_tool_name, agent_id
FROM "LiteLLM_SpendLogs"
WHERE {" AND ".join(sql_conditions)}
ORDER BY "startTime" DESC
LIMIT ${p} OFFSET ${p + 1}
"""
data = await prisma_client.db.query_raw(sql_query, *sql_params)All existing filter logic (date range, team_id, user, api_key, status, spend range, metadata JSONB filters) was faithfully ported to parameterized SQL conditions.
The same optimization was applied to the session logs endpoint (ui_view_session_spend_logs).
2. Added database fallback in detail endpoint ✅
File: litellm/proxy/spend_tracking/spend_management_endpoints.py — ui_view_request_response_for_request_id()
The detail endpoint (/spend/logs/ui/{request_id}) previously only checked custom loggers (S3, GCS, etc.) for request/response data. If no custom logger was configured (which is the default), the endpoint returned null — meaning the lazy-loaded drawer would never show request/response data.
Added a database fallback after the custom logger loop:
# After custom loggers loop, if none returned data:
# Fallback: fetch heavy columns directly from the database.
from litellm.proxy.proxy_server import prisma_client
if prisma_client is not None:
sql_query = """
SELECT messages, response, proxy_server_request
FROM "LiteLLM_SpendLogs"
WHERE request_id = $1
LIMIT 1
"""
db_result = await prisma_client.db.query_raw(sql_query, request_id)
if db_result and len(db_result) > 0:
row = db_result[0]
return {
"messages": row.get("messages"),
"response": row.get("response"),
"proxy_server_request": row.get("proxy_server_request"),
}This is an ultra-fast query — request_id is the primary key, so it's a single-row index lookup. Custom loggers still have priority; the DB fallback only fires when no logger returns data.
3. Fixed drawer not displaying lazy-loaded request data ✅
File: ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx
After the raw SQL optimization, the drawer was displaying response data correctly but showing {} for request. The cause: getRawRequest() referenced logEntry.proxy_server_request (from the list endpoint, now undefined since we exclude it), but never checked detailsData.proxy_server_request (from the detail endpoint).
Added effectiveProxyServerRequest to bridge the lazy-loaded data:
// Before: only checked list endpoint data (now undefined)
const getRawRequest = () => {
return formatData(logEntry.proxy_server_request || effectiveMessages);
};
// After: checks lazy-loaded detail data first
const effectiveProxyServerRequest = detailsData?.proxy_server_request || logEntry.proxy_server_request;
const getRawRequest = () => {
return formatData(effectiveProxyServerRequest || effectiveMessages);
};This follows the same pattern already established for effectiveMessages and effectiveResponse.
Performance impact
| Metric | Before | After |
|---|---|---|
| List endpoint response time (50 rows, 24h range, thousands of logs in DB) | 20-25 seconds | < 1 second |
| Data transferred per page load | Megabytes (messages + response payloads) | Kilobytes (metadata only) |
| Detail endpoint (click on row) | N/A (data was prefetched / included in list) | ~50ms (single PK lookup) |
| Session logs endpoint | Same issue (all columns) | Fixed (lightweight columns only) |
Summary of additional changes:
| File | Change |
|---|---|
spend_management_endpoints.py |
Raw SQL in list endpoint (exclude heavy columns), DB fallback in detail endpoint, raw SQL in session endpoint |
LogDetailsDrawer.tsx |
Added effectiveProxyServerRequest for proper lazy-loading of request data |
|
@yuneng-jiang 24h for me this is 76K-77k pages :P that's why it was reduce to 4h by default :) |
yuneng-jiang
left a comment
There was a problem hiding this comment.
I also see some merge conflicts, please adjust those as well. In general we want PRs to be isolated in the case we need to revert. This PR's scope has been expanded from frontend focused fetching to writing raw queries. I suggest having separate PRs for these
* feat: add curated MCP server registry for discovery UI Curated list of 31 well-known MCP servers with names, icons, categories, transport config, and registry URLs. Includes HTTP endpoints for GitHub, Atlassian, Sentry, Snowflake, and Cloudflare. * feat: add GET /v1/mcp/discover endpoint for MCP discovery Admin-only endpoint that serves the curated MCP registry with optional query and category filters. Used by the UI discovery modal. * feat: add DiscoverableMCPServer types for MCP discovery * feat: add fetchDiscoverableMCPServers network function * feat: add MCP discovery modal component Compact list-row layout with category filters, search, and grouped server list. Follows dev-tool aesthetic. * feat: wire MCP discovery modal into server management page Add MCP Server button now opens discovery modal. Card click pre-fills the create form. Custom Server opens blank form. * feat: add prefill from discovery and back-to-registry link Create form accepts prefillData from discovery selection and shows a Browse MCP Registry link to return to discovery modal. * test: add unit tests for MCP discovery endpoint and registry Tests for registry JSON structure validation and endpoint query/category filtering logic. 15 tests total. * fix: sync registry with official MCP API and fix stdio prefill - Updated transport types and URLs from registry.modelcontextprotocol.io API - GitHub: streamable-http at api.githubcopilot.com/mcp/ - GitLab: streamable-http at gitlab.com/api/v4/mcp (remote only) - Atlassian: SSE at mcp.atlassian.com/v1/sse (remote only) - Linear: SSE at mcp.linear.app/sse (remote only) - Notion: SSE at mcp.notion.com/sse (remote only) - Stripe: streamable-http at mcp.stripe.com (remote only) - Exa: streamable-http at mcp.exa.ai/mcp (remote only) - Cloudflare: SSE at bindings.mcp.cloudflare.com/sse (remote only) - Sentry: stdio via @sentry/mcp-server (npm, correct package) - Snowflake: stdio via snowflake-labs-mcp (pypi/uvx, not npm) - Brave Search: stdio via @brave/brave-search-mcp-server (correct package) - Fixed stdio prefill to generate stdio_config JSON instead of separate fields - Discovery modal matches create modal width and header style - Back arrow positioned on left of create modal header * Update ui/litellm-dashboard/src/components/mcp_tools/mcp_discovery.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update litellm/proxy/management_endpoints/mcp_management_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: address Greptile review feedback - Move `import json` and `import os` to module top level - Move mcp_registry.json into litellm/proxy/ for pip distribution - Fix `Text` component: destructure from antd Typography instead of deprecated Tremor - Update test fixture path to match new registry location --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
…ges_feb12 [Infra] CI/CD Fixes - Nightly Release Feb 12
…tracing refactor Resolved merge conflicts caused by upstream commit 89565c9 (MCP Tracing feature): - LogDetailsDrawer.tsx: Adapted to upstream's new split architecture (LogDetailsDrawer shell + LogDetailContent). Integrated lazy-loading via useLogDetails hook with enrichedLog pattern that merges detail data before passing to LogDetailContent. - LogDetailContent.tsx: Added isLoadingDetails prop for loading spinner and suppressed missingData warning during lazy-load. - index.tsx: Merged upstream's session dedup/MCP logic. Removed prefetchLogDetails (N+1 fix). Added startTime prop to drawer. Our core fix (removing N+1 prefetch storm) is preserved and works with the new upstream component structure.
|
Thanks for the continued review! I've resolved the merge conflicts with the latest Merge conflict resolutionThe upstream MCP Tracing feature completely restructured the drawer component:
Our lazy-loading was adapted to this new structure:
Addressing the raw SQL question
Great question. You're right that in theory Prisma's 1. Prisma Client for Python doesn't support Unlike the Node.js Prisma Client which has You can verify this by checking the 2. Why excluding heavy columns matters so much The
This is a 100x reduction in data transfer per page load. With Live Tail refreshing every 15 seconds, this difference compounds dramatically. 3. The detail endpoint is ultra-fast as compensation When a user clicks on a specific log row, the detail endpoint ( SELECT messages, response, proxy_server_request
FROM "LiteLLM_SpendLogs"
WHERE request_id = $1
LIMIT 1This is a single-row PK index scan — typically < 50ms. So the user experience is: instant table load + brief spinner when clicking a row. 4. The DB fallback in the detail endpoint We also added a database fallback in the detail endpoint ( The fallback ensures that:
On PR scope separation
I understand the preference for isolated PRs. However, the raw SQL change and the frontend lazy-loading are tightly coupled — one doesn't work well without the other:
That said, if you strongly prefer, I can split this into:
Let me know your preference and I'll restructure accordingly. Summary of all changes in this PR
|
|
Thanks for this! I did some manual testing and everything looks good. However this breaks all of the current tests around this endpoint, but I can take it from here. Planning to merge it into a different branch first, then adjusts the tests before merging into main |
f0a6162
into
BerriAI:litellm_yj_release_changes_feb12
fix-spend-logs
fix-spend-logs
Fix: Spend Logs UI causes N+1 request storm and crashes LiteLLM instances
Relevant issues
Fixes #19555
Fixes #20401
Related discussion: After 1-2 hours of running LiteLLM, navigating to Spend Logs tab in UI causes extremely slow loading or crashes the LiteLLM process entirely.
Type
🐛 Bug Fix
Changes
Problem Analysis
When a user opens the Spend Logs tab in the LiteLLM UI, the following happens:
GET /spend/logs/ui?page=1&page_size=50GET /spend/logs/ui/{request_id}× 50This means every page load generates 51 HTTP requests instead of 1.
With Live Tail enabled (auto-refresh every 15 seconds), this compounds dramatically:
Each
/spend/logs/ui/{request_id}call on the backend iterates through all registered custom loggers callingget_request_response_payload(), which can be expensive. This avalanche of requests overwhelms the backend, leading to extreme slowness or process crashes.Additionally, the backend endpoint had
@lru_cache(maxsize=128)applied to anasyncfunction. Python'slru_cachedoes not work with async functions — it caches the coroutine object rather than the resolved result, leading to incorrect behavior and potential memory issues.Root Cause
The root cause is in
ui/litellm-dashboard/src/components/view_logs/prefetch.tswhich is called from the main logsuseQueryinindex.tsx:This was called inside the main
useQuerythat also runs on every Live Tail refresh cycle.Solution
1. Remove aggressive prefetching (
index.tsx)File:
ui/litellm-dashboard/src/components/view_logs/index.tsximport { prefetchLogDetails } from "./prefetch"await prefetchLogDetails(response.data, ...)call from the main logsuseQueryresponse.data.map()block that injected prefetched data back into log entriesThe table columns (
columns.tsx) only use lightweight fields likestartTime,status,model,spend,tokens,request_id, etc. — none of which require the expensive messages/response payload. So the list endpoint alone provides everything needed for the table view.Before: 1 list request + 50 detail requests = 51 requests per page load
After: 1 list request = 1 request per page load
2. Lazy-load details on demand (
LogDetailsDrawer.tsx)File:
ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsxaccessToken?: stringandstartTime?: stringtoLogDetailsDrawerPropsuseQueryhook that fetches log details (messages/response) only when the drawer is open — i.e., when the user clicks on a specific log row:effectiveMessagesandeffectiveResponsevariablesgetRawRequest()andgetFormattedResponse()to use the lazy-loaded data<Spin>) while details are being fetchedmissingDatacheck now accounts for loading state to avoid premature "missing data" warningsThe parent
index.tsxnow passesaccessTokenandstartTimeto the drawer component.3. Remove broken
@lru_cacheon async endpoint (spend_management_endpoints.py)File:
litellm/proxy/spend_tracking/spend_management_endpoints.py@lru_cache(maxsize=128)decorator from theasync def ui_view_request_response_for_request_id()endpointPython's
functools.lru_cacheis designed for synchronous functions. When applied to anasyncfunction, it caches the coroutine object itself rather than the awaited result. This means:Added a comment explaining why the decorator was removed.
Impact Summary
What was NOT changed
/spend/logs/uilist endpoint — no backend changes needed; it already returns lightweight paginated data/spend/logs/ui/{request_id}detail endpoint — only the@lru_cachedecorator was removed; the endpoint logic remains unchangedprefetch.tsfile — left in place (unused now) in case it's useful for future single-item prefetch scenarioscolumns.tsxtable column definitions — no changes neededTesting Notes
messagesorresponsefieldsmetadata.error_informationwhich is available in the list response