Skip to content

fix-spend-logs - #20720

Merged
yuneng-jiang merged 14 commits into
BerriAI:litellm_yj_release_changes_feb12from
OrionCodeDev:fix-spend-logs
Feb 13, 2026
Merged

fix-spend-logs#20720
yuneng-jiang merged 14 commits into
BerriAI:litellm_yj_release_changes_feb12from
OrionCodeDev:fix-spend-logs

Conversation

@OrionCodeDev

Copy link
Copy Markdown
Contributor

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:

  1. One API call fetches the paginated list of 50 logs: GET /spend/logs/ui?page=1&page_size=50
  2. Immediately after, the UI fires off 50 parallel API calls — one for each log — to fetch detailed request/response data: GET /spend/logs/ui/{request_id} × 50

This means every page load generates 51 HTTP requests instead of 1.

With Live Tail enabled (auto-refresh every 15 seconds), this compounds dramatically:

  • Every 15 seconds: 51 requests
  • Per hour: ~12,240 requests
  • Over 1-2 hours: ~24,000+ requests just from the Spend Logs tab

Each /spend/logs/ui/{request_id} call on the backend iterates through all registered custom loggers calling get_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 an async function. Python's lru_cache does 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.ts which is called from the main logs useQuery in index.tsx:

// prefetch.ts - fires for ALL 50 logs immediately
const promises = logs.map((log) => {
  return queryClient.prefetchQuery({
    queryFn: async () => {
      return await uiSpendLogDetailsCall(accessToken, log.request_id, formattedStartTime);
    },
  });
});
await Promise.all(promises); // 50 concurrent requests!

This was called inside the main useQuery that also runs on every Live Tail refresh cycle.

Solution

1. Remove aggressive prefetching (index.tsx)

File: ui/litellm-dashboard/src/components/view_logs/index.tsx

  • Removed import { prefetchLogDetails } from "./prefetch"
  • Removed the await prefetchLogDetails(response.data, ...) call from the main logs useQuery
  • Removed the response.data.map() block that injected prefetched data back into log entries

The table columns (columns.tsx) only use lightweight fields like startTime, 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.tsx

  • Added new props: accessToken?: string and startTime?: string to LogDetailsDrawerProps
  • Added a useQuery hook that fetches log details (messages/response) only when the drawer is open — i.e., when the user clicks on a specific log row:
const logDetails = useQuery({
  queryKey: ["logDetails", logEntry?.request_id, startTime],
  queryFn: async () => {
    if (!accessToken || !logEntry?.request_id || !startTime) return null;
    return await uiSpendLogDetailsCall(accessToken, logEntry.request_id, startTime);
  },
  enabled: open && !!accessToken && !!logEntry?.request_id && !!startTime,
  staleTime: 10 * 60 * 1000, // 10 minutes cache
  gcTime: 10 * 60 * 1000,
});
  • The fetched messages/response are merged with existing log data via effectiveMessages and effectiveResponse variables
  • Updated getRawRequest() and getFormattedResponse() to use the lazy-loaded data
  • Added a loading spinner (<Spin>) while details are being fetched
  • The missingData check now accounts for loading state to avoid premature "missing data" warnings
  • Results are cached for 10 minutes via React Query, so re-opening the same log doesn't trigger another API call

The parent index.tsx now passes accessToken and startTime to the drawer component.

3. Remove broken @lru_cache on async endpoint (spend_management_endpoints.py)

File: litellm/proxy/spend_tracking/spend_management_endpoints.py

  • Removed @lru_cache(maxsize=128) decorator from the async def ui_view_request_response_for_request_id() endpoint

Python's functools.lru_cache is designed for synchronous functions. When applied to an async function, it caches the coroutine object itself rather than the awaited result. This means:

  • The cache never actually works (each call creates a new coroutine)
  • Previously cached coroutines may have already been consumed, leading to errors
  • It can cause memory leaks since coroutine objects may hold references

Added a comment explaining why the decorator was removed.

Impact Summary

Metric Before After
Requests per page load 51 (1 list + 50 details) 1 (list only)
Requests per row click 0 (prefetched) 1 (lazy loaded)
Live Tail requests per 15s 51 1
Requests over 2 hours (Live Tail) ~24,000+ ~480
Detail data cached 10 min (React Query) 10 min (React Query)

What was NOT changed

  • The /spend/logs/ui list endpoint — no backend changes needed; it already returns lightweight paginated data
  • The /spend/logs/ui/{request_id} detail endpoint — only the @lru_cache decorator was removed; the endpoint logic remains unchanged
  • The prefetch.ts file — left in place (unused now) in case it's useful for future single-item prefetch scenarios
  • The columns.tsx table column definitions — no changes needed
  • Audit logs, session logs, and all other tabs — completely unaffected
  • Keyboard navigation (J/K) in the drawer — still works; details load when navigating to a new log

Testing Notes

  • Verified that the table columns don't depend on messages or response fields
  • The drawer now shows a loading spinner while fetching details, then renders the full request/response view once loaded
  • Navigating between logs with keyboard shortcuts (J/K) triggers a new lazy fetch for each log (cached for 10 min)
  • Live Tail auto-refresh now only refreshes the lightweight list — no detail prefetching
  • Error states (failed requests) still display correctly since error info comes from metadata.error_information which is available in the list response

@vercel

vercel Bot commented Feb 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Feb 13, 2026 3:00am

Request Review

@greptile-apps

greptile-apps Bot commented Feb 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Overview

Greptile Summary

This 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 @lru_cache decorator from an async FastAPI endpoint (/spend/logs/ui/{request_id}), which previously would cache coroutine objects rather than awaited results.

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

  • This PR is broadly safe to merge once the React Query cache scoping and minor cleanup are addressed.
  • The change meaningfully reduces backend load by removing aggressive prefetching and fixes a clearly incorrect async lru_cache usage. The main remaining concern is correctness/security of client caching: the detail query key is not scoped by access token, which can reuse cached detail payloads across auth contexts. Also, an unused backend import remains after removing the decorator.
  • ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx; litellm/proxy/spend_tracking/spend_management_endpoints.py

Important Files Changed

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
Loading

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

@greptile-apps

greptile-apps Bot commented Feb 8, 2026

Copy link
Copy Markdown
Contributor
Additional Comments (1)

litellm/proxy/spend_tracking/spend_management_endpoints.py
Unused import after change
lru_cache is still imported (from functools import lru_cache) even though the decorator was removed in this PR. This will now fail linting / unused-import checks in repositories that enforce them; please remove the import since it’s no longer used.

@superpoussin22

Copy link
Copy Markdown
Contributor

To speed up also, perhaps the default value could be 1h instead of 24 hours ?

@OrionCodeDev

Copy link
Copy Markdown
Contributor Author

@superpoussin22 i changed it to default "4 hours". I think it's good value.

@OrionCodeDev

OrionCodeDev commented Feb 9, 2026

Copy link
Copy Markdown
Contributor Author

@Sameerlite @ishaan-jaff @krrishdholakia you need anything else or no? I just ask because i don't know if it's good.
UPDATE: I tested it on locally, dev version and all working really smooth and fast

@superpoussin22

Copy link
Copy Markdown
Contributor

@krrishdholakia @ishaan-jaff what do you think ?

@OrionCodeDev

Copy link
Copy Markdown
Contributor Author

@yuneng-jiang maybe you can look into this? I don't know who be responisible of UI and can accept UI changes.
It's small change. It's fast, smooth and don't lagging backend server anymore.

@OrionCodeDev

Copy link
Copy Markdown
Contributor Author

@superpoussin22 do you have any contact or maybe you know who can look into this?

@ghost
ghost requested a review from yuneng-jiang February 11, 2026 06:50
@ghost

ghost commented Feb 11, 2026

Copy link
Copy Markdown

under review, thanks for bumping this @superpoussin22

@yuneng-jiang yuneng-jiang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great idea. Thanks for the contribution! Left some comments to request changes, and to get the thought process behind some changes.

Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py Outdated
Comment thread ui/litellm-dashboard/src/components/view_logs/index.tsx Outdated
Comment thread ui/litellm-dashboard/src/components/view_logs/index.tsx Outdated
@OrionCodeDev

OrionCodeDev commented Feb 12, 2026

Copy link
Copy Markdown
Contributor Author

@yuneng-jiang

Thanks for the thorough review @yuneng-jiang! I've addressed all your comments. Here's what changed:


1. Removed @lru_cache comment ✅

Nit: We can remove the comments here

Done - removed the comment entirely. The PR description already explains why @lru_cache was removed.


2. Replaced accessToken prop drilling with useAuthorized hook ✅

accessToken is available on the useAuthorized hook, you should use this in order to prevent prop drilling

Done - removed accessToken from LogDetailsDrawerProps and from the parent component. The hook now calls useAuthorized() internally to get accessToken, following the established project pattern.


3. Extracted useQuery hook to its own file ✅

This should be in its own file inside of src/app/(dashboard)/hooks

Done - created src/app/(dashboard)/hooks/logDetails/useLogDetails.ts. This hook:

  • Internally calls useAuthorized() for accessToken (solving point 2)
  • Wraps the useQuery call with proper staleTime/gcTime settings
  • Follows the same pattern as useModels, useTeams, useKeys, etc.

4. Kept merge logic with explanation ✅

Not sure why we will need to merge, did you run into cases where the detailsData did not have everything you needed?

Yes - the fallback is intentional and necessary. There are two different data sources:

  1. List endpoint (/spend/logs/ui) - returns messages and response directly from the DB when store_prompts_in_spend_logs is enabled. This data is already available in logEntry.
  2. Detail endpoint (/spend/logs/ui/{request_id}) - fetches from custom loggers (S3, GCS, etc.) which is a separate storage backend.

The fallback ensures data displays from either source:

// Detail endpoint data takes priority, falls back to list endpoint data
const effectiveMessages = detailsData?.messages || logEntry.messages;
const effectiveResponse = detailsData?.response || logEntry.response;

Added a comment explaining this reasoning in the code. Without that te response tab will be empty.


5. Duplicate imports

This fails the build due to identical imports

My bad, i pulled new main. Fixed. Could you confirm if you still see the issue after the rebase?


6. Reverted 24h → 4h default time range ✅

Can you elaborate on why you are changing this to 4 hours?

Reverted back to the original 24-hour default. If there's interest in changing the default time range, that should be a separate discussion/PR.


Summary of changes in this update:

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.pyui_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.pyui_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

@superpoussin22

superpoussin22 commented Feb 12, 2026

Copy link
Copy Markdown
Contributor

@yuneng-jiang 24h for me this is 76K-77k pages :P that's why it was reduce to 4h by default :)

@yuneng-jiang yuneng-jiang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py
ishaan-jaff and others added 3 commits February 12, 2026 17:59
* 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.
@OrionCodeDev

Copy link
Copy Markdown
Contributor Author

@yuneng-jiang

Thanks for the continued review! I've resolved the merge conflicts with the latest main (specifically the MCP Tracing refactor from #21018 which split LogDetailsDrawer into LogDetailsDrawer + LogDetailContent). Our lazy-loading changes now work cleanly with the new architecture.


Merge conflict resolution

The upstream MCP Tracing feature completely restructured the drawer component:

  • LogDetailsDrawer.tsx became a shell managing sessions/sidebar
  • Detail rendering moved to a new LogDetailContent.tsx

Our lazy-loading was adapted to this new structure:

  • useLogDetails hook is called in the drawer and produces an enrichedLog object (merging lazy-loaded data with list endpoint data via useMemo)
  • LogDetailContent receives the enriched log + a new isLoadingDetails prop (shows spinner, suppresses false "missing data" warnings)
  • The index.tsx prefetch removal (our core N+1 fix) merges cleanly with the new session dedup/MCP logic from upstream

Addressing the raw SQL question

Looks like the scope of this PR was expanded. It is not immediately obvious why you need a raw query for this. If you are omitting rows, wouldn't the select field on the prisma client suffice?

Great question. You're right that in theory Prisma's select field could work. However, there are practical reasons we went with raw SQL:

1. Prisma Client for Python doesn't support select on find_many

Unlike the Node.js Prisma Client which has select: { field: true }, the Python Prisma Client used by LiteLLM (prisma-client-py) does not expose a select parameter on find_many(). The Python client always returns full model objects with all columns. There is no way to exclude specific columns from the ORM query.

You can verify this by checking the find_many signature in prisma-client-py — it accepts where, order, skip, take, cursor, distinct, but no select or include.

2. Why excluding heavy columns matters so much

The messages, response, and proxy_server_request columns in LiteLLM_SpendLogs can each contain hundreds of kilobytes of JSON data per row. When fetching 50 rows for a single page:

  • With all columns (Prisma ORM): 50 rows × ~200-500KB per row = 10-25 MB transferred from DB per page load. This is what caused the 20-25 second response times we observed in production.
  • Without heavy columns (raw SQL): 50 rows × ~2-5KB per row = 100-250 KB per page load. Response time: < 1 second.

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 (/spend/logs/ui/{request_id}) fetches only the 3 heavy columns for a single row using a primary key lookup:

SELECT messages, response, proxy_server_request
FROM "LiteLLM_SpendLogs"
WHERE request_id = $1
LIMIT 1

This 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 (/spend/logs/ui/{request_id}). Previously, this endpoint only checked custom loggers (S3, GCS, etc.) — if no custom logger was configured (which is the default setup), it returned null. This meant the drawer would never show request/response data for default installations.

The fallback ensures that:

  1. Custom loggers are checked first (preserving existing behavior)
  2. If no logger returns data, we fall back to fetching from the DB directly
  3. This makes the lazy-loading approach work for all LiteLLM installations, not just those with custom loggers

On PR scope separation

I suggest having separate PRs for these

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:

  • If we only do frontend lazy-loading but keep the Prisma ORM (fetching all columns), the list endpoint still takes 20-25 seconds → the table appears slow
  • If we only do raw SQL but keep the N+1 prefetching, we still fire 51 requests per page → backend overload

That said, if you strongly prefer, I can split this into:

  1. PR A (frontend): Remove prefetching, add lazy-loading → reduces requests from 51 to 1 per page
  2. PR B (backend): Raw SQL for list endpoint, DB fallback for detail endpoint → reduces response time from 20s to <1s

Let me know your preference and I'll restructure accordingly.


Summary of all changes in this PR

Layer File Change
Frontend index.tsx Removed prefetchLogDetails import and usage in useQuery (core N+1 fix). Added startTime prop to drawer.
Frontend LogDetailsDrawer.tsx Added useLogDetails hook for on-demand fetching. Creates enrichedLog merging lazy-loaded data. Passes isLoadingDetails to content.
Frontend LogDetailContent.tsx Added isLoadingDetails prop — shows spinner for Request & Response section, suppresses false "missing data" warning.
Frontend useLogDetails.ts New hook in hooks/logDetails/ — wraps useQuery with useAuthorized(), 10min cache.
Backend spend_management_endpoints.py Removed @lru_cache on async endpoint. Raw SQL in list + session endpoints (exclude heavy columns). DB fallback in detail endpoint.

@yuneng-jiang
yuneng-jiang changed the base branch from main to litellm_yj_release_changes_feb12 February 13, 2026 05:31
@yuneng-jiang

Copy link
Copy Markdown
Contributor

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

@yuneng-jiang
yuneng-jiang merged commit f0a6162 into BerriAI:litellm_yj_release_changes_feb12 Feb 13, 2026
6 of 19 checks passed
yuneng-jiang added a commit that referenced this pull request Feb 13, 2026
@yuneng-jiang yuneng-jiang mentioned this pull request Feb 13, 2026
7 tasks
sameetn pushed a commit to sameetn/litellm that referenced this pull request Feb 16, 2026
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Spend logs on v1.81.7 loading really slow [Bug]: Application unresponsive during spend logs retrieval

4 participants