From e0f184096bd5b95ab5e750bf15d7d26294f3a19f Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 6 Nov 2026 08:02:06 +0000 Subject: [PATCH] fix(lens): cap spend-log trace rows per trace, read Responses API inputs, gate on logs page access Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/tracing_endpoints.py | 7 +++- litellm/tracing/spend_logs.py | 42 ++++++++++++++-------- tests/unit/proxy/test_tracing_endpoints.py | 13 +++++++ tests/unit/tracing/test_spend_logs.py | 11 +++++- 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/tracing_endpoints.py b/litellm/proxy/tracing_endpoints.py index 94ae1703a05..ca5470cf744 100644 --- a/litellm/proxy/tracing_endpoints.py +++ b/litellm/proxy/tracing_endpoints.py @@ -29,6 +29,7 @@ from litellm.constants import ( from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.authorization import AllRows, ReadScope, resolve_trace_read_scope from litellm.proxy.auth.authorization_dependencies import LogTeamLookupDependency +from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.http_parsing_utils import is_otlp_trace_request from litellm.proxy.tracing_runtime import provide_receiver, require_receiver @@ -62,6 +63,7 @@ from litellm.types.llms.base import LiteLLMBaseModel router = APIRouter(tags=["agent tracing"]) MS_PER_DAY: Final = 24 * 60 * 60 * 1000 +SPEND_LOGS_UI_ROUTE: Final = "/spend/logs/ui" _SQL_NEEDS_LENS_DETAIL: Final = "Trace SQL queries need the Lens service. Configure LITELLM_LENS_URL." @@ -73,11 +75,14 @@ async def current_time_ms() -> int: class TraceAccessContext: receiver: TraceReceiver | None read_scope: ReadScope | None + auth: UserAPIKeyAuth def reader(self) -> tuple[TraceReceiver, TraceScope]: tracing: Final = require_receiver(self.receiver) if self.read_scope is None: raise HTTPException(status_code=403, detail="Not allowed to view agent traces") + if isinstance(tracing.storage, SpendLogTraceStorage): + RouteChecks.is_virtual_key_allowed_to_call_route(route=SPEND_LOGS_UI_ROUTE, valid_token=self.auth) return tracing, _trace_scope(self.read_scope) @@ -87,7 +92,7 @@ async def provide_trace_access( log_team_lookup: LogTeamLookupDependency, ) -> TraceAccessContext: read_scope: Final = await resolve_trace_read_scope(auth, partial(log_team_lookup, auth)) - return TraceAccessContext(tracing, read_scope) + return TraceAccessContext(tracing, read_scope, auth) def _trace_scope(scope: ReadScope) -> TraceScope: diff --git a/litellm/tracing/spend_logs.py b/litellm/tracing/spend_logs.py index ef88892ca0e..e304b9133c3 100644 --- a/litellm/tracing/spend_logs.py +++ b/litellm/tracing/spend_logs.py @@ -4,6 +4,7 @@ A trace is every spend log row sharing a session id. A row's agent comes from an `parent_agent:` tag), the row's agent_id, a Claude Agent SDK subagent whose first prompt matches a parent's Task/Agent tool call, or the calling framework read from the user agent.""" +import asyncio import base64 import json from collections.abc import Callable, Mapping, Sequence @@ -17,6 +18,7 @@ from pydantic import TypeAdapter from typing_extensions import ReadOnly, TypedDict from litellm.constants import AGENT_TRACING_LIST_PAGE_SIZE +from litellm.litellm_core_utils.prompt_templates.factory import resolve_structured_messages from litellm.tracing.generated.models import TraceAgentRow, TraceAgentsParams from litellm.tracing.generated.types import ( AgentNode, @@ -288,7 +290,11 @@ def _request_messages(row: SpendLogRow) -> object: stored: Final = _loads(row["messages"]) if _sequence(stored): return stored - return _mapping(_loads(row["proxy_server_request"])).get("messages") + request: Final = dict(_mapping(_loads(row["proxy_server_request"]))) + messages: Final = request.get("messages") + if _sequence(messages): + return messages + return resolve_structured_messages(None, request) if request.get("input") else messages def _call(row: SpendLogRow) -> _Call: @@ -616,6 +622,11 @@ def _scope_sql(scope: TraceScope, first_index: int) -> tuple[str, tuple[object, ) +def _build_traces(rows: Sequence[SpendLogRow], keys: Sequence[str]) -> tuple[Trace, ...]: + groups: Final = tuple(tuple(row for row in rows if row["trace_key"] == key) for key in keys) + return tuple(build_trace(own) for own in groups if own) + + class SpendLogTraceStorage: def __init__(self, database: Callable[[], SpendLogReader | None]) -> None: self._database: Final = database @@ -665,16 +676,20 @@ class SpendLogTraceStorage: scope_sql, scope_args = _scope_sql(scope, 2) rows: Final = await self._query( f""" - SELECT request_id, {TRACE_KEY_SQL} AS trace_key, call_type, model, status, spend, prompt_tokens, - completion_tokens, "startTime"::text AS start_time, "endTime"::text AS end_time, agent_id, - request_tags::text AS request_tags, metadata::text AS metadata, messages::text AS messages, - proxy_server_request::text AS proxy_server_request, response::text AS response - FROM "LiteLLM_SpendLogs" - WHERE (session_id = ANY($1::text[]) OR request_id = ANY($1::text[])) - AND {TRACE_KEY_SQL} = ANY($1::text[]) - {scope_sql} - ORDER BY "startTime", request_id - LIMIT {TRACE_ROW_LIMIT * len(trace_keys)} + SELECT * FROM ( + SELECT request_id, {TRACE_KEY_SQL} AS trace_key, call_type, model, status, spend, prompt_tokens, + completion_tokens, "startTime"::text AS start_time, "endTime"::text AS end_time, agent_id, + request_tags::text AS request_tags, metadata::text AS metadata, messages::text AS messages, + proxy_server_request::text AS proxy_server_request, response::text AS response, + "startTime" AS started_at, + ROW_NUMBER() OVER (PARTITION BY {TRACE_KEY_SQL} ORDER BY "startTime", request_id) AS trace_row + FROM "LiteLLM_SpendLogs" + WHERE (session_id = ANY($1::text[]) OR request_id = ANY($1::text[])) + AND {TRACE_KEY_SQL} = ANY($1::text[]) + {scope_sql} + ) AS trace_rows + WHERE trace_row <= {TRACE_ROW_LIMIT} + ORDER BY started_at, request_id """, list(trace_keys), *scope_args, @@ -683,8 +698,7 @@ class SpendLogTraceStorage: async def _traces(self, keys: Sequence[str], scope: TraceScope) -> tuple[Trace, ...]: rows: Final = await self._rows(keys, scope) - groups: Final = tuple(tuple(row for row in rows if row["trace_key"] == key) for key in keys) - return tuple(build_trace(own) for own in groups if own) + return await asyncio.to_thread(_build_traces, rows, keys) async def list_traces( self, @@ -715,7 +729,7 @@ class SpendLogTraceStorage: async def get_span(self, trace_id: str, span_id: str, scope: TraceScope, trace_ref: str = "") -> SpanDetail | None: rows: Final = await self._rows((trace_id,), scope) - return _span_detail(rows, span_id) if rows else None + return await asyncio.to_thread(_span_detail, rows, span_id) if rows else None async def get_span_error( self, trace_id: str, span_id: str, scope: TraceScope, trace_ref: str = "", cursor: str | None = None diff --git a/tests/unit/proxy/test_tracing_endpoints.py b/tests/unit/proxy/test_tracing_endpoints.py index 8ff0b0cbf20..301eda74051 100644 --- a/tests/unit/proxy/test_tracing_endpoints.py +++ b/tests/unit/proxy/test_tracing_endpoints.py @@ -705,6 +705,19 @@ def test_sql_routes_need_the_lens_service_when_traces_come_from_spend_logs( assert "Lens service" in response.json()["detail"] +@pytest.mark.parametrize(("allowed_routes", "status_code"), ((["llm_api_routes"], 403), (None, 200))) +def test_spend_log_traces_need_the_same_route_access_as_the_logs_page( + client: TestClient, allowed_routes: list[str] | None, status_code: int +) -> None: + spend_logs: Final = TraceReceiver(storage=SpendLogTraceStorage(lambda: None)) + client.app.dependency_overrides[tracing_endpoints.provide_receiver] = lambda: spend_logs + client.app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="user", token="hashed-key", allowed_routes=allowed_routes + ) + response: Final = client.get("/v1/traces") + assert response.status_code == status_code, response.text + + def test_disabled_receiver_precedes_read_scope_rejection(client: TestClient) -> None: client.app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( user_role=LitellmUserRoles.INTERNAL_USER diff --git a/tests/unit/tracing/test_spend_logs.py b/tests/unit/tracing/test_spend_logs.py index 7cd451d46e9..474f9956f2e 100644 --- a/tests/unit/tracing/test_spend_logs.py +++ b/tests/unit/tracing/test_spend_logs.py @@ -28,6 +28,7 @@ def row( error: str | None = None, spend: float = 0.01, agent_id: str | None = None, + request: Mapping[str, object] | None = None, ) -> SpendLogRow: metadata: Final = { "user_agent": user_agent, @@ -48,7 +49,7 @@ def row( request_tags=json.dumps(list(tags)), metadata=json.dumps(metadata), messages="{}", - proxy_server_request=json.dumps({"messages": messages, "tags": list(tags)}), + proxy_server_request=json.dumps(request or {"messages": messages, "tags": list(tags)}), response=json.dumps(response or {}), ) @@ -268,3 +269,11 @@ async def test_no_database_reads_as_no_traces() -> None: page: Final = await SpendLogTraceStorage(lambda: None).list_traces(ALL, 0, 99) assert page == {"data": (), "next_cursor": None} + + +def test_responses_api_input_is_read_from_the_stored_request() -> None: + trace: Final = build_trace( + (row("r1", "00.000", "01.000", tags=("agent:triage",), request={"model": "gpt-5", "input": "refund me"}),) + ) + + assert trace["summary"]["input_preview"] == "refund me"