feat(logs): add span type filter to request logs (#42491)

* feat(logs): add span type filter to request logs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(logs): look up span type sql conditions from a mapping

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Mubashir Osmani <mubashir@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 12:44:03 -07:00 • committed by GitHub
parent cb2f22533c
commit 1373322e0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 220 additions and 1 deletions

View file

@ -69,6 +69,18 @@ _SESSION_KEY_EXPR: Final = "COALESCE(NULLIF(session_id, ''), request_id)"
_SESSION_GROUP_KEY_SQL: Final = f"{_SESSION_KEY_EXPR}, api_key"
_MCP_CALL_TYPES_SQL: Final = "('call_mcp_tool', 'list_mcp_tools')"
_AGENT_CALL_TYPE_SQL: Final = "'asend_message'"
_BATCH_CALL_TYPES_SQL: Final = "('acreate_batch', 'create_batch', 'aretrieve_batch', 'retrieve_batch')"
_SPAN_TYPE_SQL_CONDITIONS: Final[Mapping[str, str]] = MappingProxyType(
{
"mcp": f"call_type IN {_MCP_CALL_TYPES_SQL}",
"agent": f"call_type = {_AGENT_CALL_TYPE_SQL}",
"batch": f"call_type IN {_BATCH_CALL_TYPES_SQL}",
"llm": (
f"(call_type NOT IN {_MCP_CALL_TYPES_SQL} AND call_type != {_AGENT_CALL_TYPE_SQL} "
f"AND call_type NOT IN {_BATCH_CALL_TYPES_SQL})"
),
}
)
_SPEND_LOG_LIST_COLUMNS: Final = """
request_id, call_type, api_key, spend, total_tokens,
prompt_tokens, completion_tokens, "startTime", "endTime",
@ -2410,6 +2422,10 @@ async def ui_view_spend_logs(
default=None,
description="Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state",
),
span_type: str | None = fastapi.Query(
default=None,
description="Filter logs by span type: llm, agent, mcp, or batch",
),
model: str | None = fastapi.Query(default=None, description="Filter logs by model"),
model_id: str | None = fastapi.Query(
default=None,
@ -2512,6 +2528,13 @@ async def ui_view_spend_logs(
param="cache_hit_filter",
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(span_type, str) and span_type not in _SPAN_TYPE_SQL_CONDITIONS:
raise ProxyException(
message=f"Invalid span_type: {span_type}. Must be one of: llm, agent, mcp, batch",
type="bad_request",
param="span_type",
code=status.HTTP_400_BAD_REQUEST,
)
try:
is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
@ -2776,6 +2799,10 @@ async def ui_view_spend_logs(
elif cache_hit_filter == "miss":
sql_conditions.append("(cache_hit IS NULL OR LOWER(cache_hit) != 'true')")
span_type_condition: Final = _span_type_sql_condition(span_type)
if span_type_condition is not None:
sql_conditions.append(span_type_condition)
if exclude_internal_health_checks:
sql_conditions.append(f"api_key NOT IN (${p}, ${p + 1})")
sql_params.extend(_INTERNAL_HEALTH_CHECK_API_KEYS)
@ -4673,6 +4700,12 @@ def _build_status_filter_condition(status_filter: str | None) -> Mapping[str, ob
return {"status": {"equals": status_filter}}
def _span_type_sql_condition(span_type: str | None) -> str | None:
if span_type is None:
return None
return _SPAN_TYPE_SQL_CONDITIONS.get(span_type)
def _is_admin_view_safe(user_api_key_dict: UserAPIKeyAuth) -> bool:
"""
Safely determine if the current user has admin view permissions.

View file

@ -139,6 +139,15 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
where["cache_hit"] = "hit"
elif cond == "(cache_hit IS NULL OR LOWER(cache_hit) != 'true')":
where["cache_hit"] = "miss"
elif "call_type" in cond:
if "call_type NOT IN" in cond:
where["span_type"] = "llm"
elif "call_mcp_tool" in cond:
where["span_type"] = "mcp"
elif "call_type = 'asend_message'" in cond:
where["span_type"] = "agent"
elif "acreate_batch" in cond:
where["span_type"] = "batch"
elif sess:
where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")}
elif status:
@ -3418,6 +3427,95 @@ async def test_ui_view_spend_logs_with_cache_hit_filter(client, monkeypatch):
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_span_type_filter(client, monkeypatch):
base = {
"api_key": "sk-test-key",
"user": "test_user_1",
"team_id": "team1",
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
"status": "success",
}
mock_spend_logs = [
{**base, "id": "log1", "request_id": "req-llm", "call_type": "acompletion"},
{**base, "id": "log2", "request_id": "req-agent", "call_type": "asend_message"},
{**base, "id": "log3", "request_id": "req-mcp", "call_type": "call_mcp_tool"},
{**base, "id": "log4", "request_id": "req-batch", "call_type": "aretrieve_batch"},
]
call_types_by_span = {
"llm": lambda ct: ct not in {"call_mcp_tool", "list_mcp_tools", "asend_message"}
and ct not in {"acreate_batch", "create_batch", "aretrieve_batch", "retrieve_batch"},
"agent": lambda ct: ct == "asend_message",
"mcp": lambda ct: ct in {"call_mcp_tool", "list_mcp_tools"},
"batch": lambda ct: ct
in {"acreate_batch", "create_batch", "aretrieve_batch", "retrieve_batch"},
}
def filter_by_span_type(where):
span_type = where.get("span_type")
if span_type is None:
return mock_spend_logs
return [log for log in mock_spend_logs if call_types_by_span[span_type](log["call_type"])]
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_span_type),
)
start_date, end_date = _default_date_range()
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN
)
try:
for span_type, expected_ids in [
("batch", ["req-batch"]),
("llm", ["req-llm"]),
("mcp", ["req-mcp"]),
("agent", ["req-agent"]),
]:
response = client.get(
"/spend/logs/ui",
params={
"span_type": span_type,
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
data = response.json()
assert data["total"] == len(expected_ids)
assert [row["request_id"] for row in data["data"]] == expected_ids
response = client.get(
"/spend/logs/ui",
params={
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
assert response.json()["total"] == 4
response = client.get(
"/spend/logs/ui",
params={
"span_type": "invalid",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 400
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_model(client, monkeypatch):
mock_spend_logs = [

View file

@ -2003,6 +2003,7 @@ interface UiSpendLogsParams {
end_user?: string;
status_filter?: string;
cache_hit_filter?: string;
span_type?: string;
/** Filter by model name (e.g. "gpt-4") */
model?: string;
/** Filter by model ID (litellm model deployment id) */

View file

@ -84,6 +84,7 @@ describe("RequestLogsFilters", () => {
for (const label of [
"Team ID",
"Span Type",
"Status",
"Cache",
"Key Alias",
@ -286,6 +287,38 @@ describe("RequestLogsFilters", () => {
expect(await screen.findByText(label)).toBeInTheDocument();
});
it.each([
["", "All Types"],
["llm", "LLM"],
["agent", "Agent"],
["mcp", "MCP"],
["batch", "Batch"],
])("shows the human label on the Span Type trigger for %s", async (spanType, label) => {
renderFilters(spanType === "" ? {} : { [LOG_FILTER_IDS.SPAN_TYPE]: spanType });
expect(await screen.findByText(label)).toBeInTheDocument();
});
it("selecting Batch sets the span_type filter", async () => {
const user = userEvent.setup();
const { set } = renderFilters();
await user.click(await screen.findByText("All Types"));
await user.click(await screen.findByRole("option", { name: "Batch" }));
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.SPAN_TYPE, "batch");
});
it("selecting All Types clears the span_type filter", async () => {
const user = userEvent.setup();
const { set } = renderFilters({ [LOG_FILTER_IDS.SPAN_TYPE]: "batch" });
await user.click(await screen.findByText("Batch"));
await user.click(await screen.findByRole("option", { name: "All Types" }));
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.SPAN_TYPE, undefined);
});
it.each([
["Cache Hit", "hit"],
["Cache Miss", "miss"],

View file

@ -37,6 +37,14 @@ const CACHE_FILTER_ITEMS = [
{ value: "hit", label: "Cache Hit" },
{ value: "miss", label: "Cache Miss" },
] as const;
const SPAN_TYPE_FILTER_ITEMS = [
{ value: ALL_VALUE, label: "All Types" },
{ value: "llm", label: "LLM" },
{ value: "agent", label: "Agent" },
{ value: "mcp", label: "MCP" },
{ value: "batch", label: "Batch" },
] as const;
const PAGE_SIZE = 50;
const SEARCH_INPUT_REASONS: ReadonlySet<string> = new Set(["input-change", "input-clear", "clear-press"]);
@ -328,6 +336,27 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
teams={teams}
/>
<DataTableFilterField label="Span Type">
<Select
items={SPAN_TYPE_FILTER_ITEMS}
value={valueOf(LOG_FILTER_IDS.SPAN_TYPE) === "" ? ALL_VALUE : valueOf(LOG_FILTER_IDS.SPAN_TYPE)}
onValueChange={(next) =>
set(LOG_FILTER_IDS.SPAN_TYPE, next === null || next === ALL_VALUE ? undefined : next)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
{SPAN_TYPE_FILTER_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</DataTableFilterField>
<DataTableFilterField label="Status">
<Select
items={STATUS_FILTER_ITEMS}

View file

@ -9,7 +9,8 @@ import { DataTable, DataTableFilterDrawer, DataTableToolbar } from "@/components
import type { Team } from "../key_team_helpers/key_list";
import type { LogEntry } from "./columns";
import { LOG_FILTER_LABELS, type LogsWindow } from "./log_filter_logic";
import { SPAN_TYPE_LABELS } from "./constants";
import { LOG_FILTER_IDS, LOG_FILTER_LABELS, type LogsWindow } from "./log_filter_logic";
import { RequestLogsFilters } from "./RequestLogsFilters";
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
@ -35,6 +36,13 @@ interface RequestLogsTableProps {
toolbarChildren?: ReactNode;
}
const formatFilterValue = (columnId: string, value: unknown): string => {
if (columnId === LOG_FILTER_IDS.SPAN_TYPE) {
return SPAN_TYPE_LABELS[String(value)] ?? String(value);
}
return Array.isArray(value) ? value.join(", ") : String(value);
};
function RequestLogsEmptyState({ filtered }: { filtered: boolean }) {
return (
<div className="flex flex-col items-center gap-1 py-6">
@ -116,6 +124,7 @@ export function RequestLogsTable({
isRefreshing={isRefreshing}
onOpenFilters={() => setFiltersOpen(true)}
filterLabels={LOG_FILTER_LABELS}
formatFilterValue={formatFilterValue}
showViewOptions={false}
>
{toolbarChildren}

View file

@ -21,6 +21,13 @@ export const AGENT_CALL_TYPES = ["asend_message"];
/** Call types that represent Batch API operations (creation and retrieval, sync and async). */
export const BATCH_CALL_TYPES = ["acreate_batch", "create_batch", "aretrieve_batch", "retrieve_batch"];
export const SPAN_TYPE_LABELS: Record<string, string> = {
llm: "LLM",
agent: "Agent",
mcp: "MCP",
batch: "Batch",
};
export const QUICK_SELECT_OPTIONS: { label: string; value: number; unit: string }[] = [
{ label: "Last Minute", value: 1, unit: "minutes" },
{ label: "Last 15 Minutes", value: 15, unit: "minutes" },

View file

@ -85,6 +85,8 @@ describe("useLogFilterLogic", () => {
{ id: LOG_FILTER_IDS.STATUS, value: "failure", param: "status_filter" },
{ id: LOG_FILTER_IDS.CACHE_STATUS, value: "hit", param: "cache_hit_filter" },
{ id: LOG_FILTER_IDS.CACHE_STATUS, value: "miss", param: "cache_hit_filter" },
{ id: LOG_FILTER_IDS.SPAN_TYPE, value: "batch", param: "span_type" },
{ id: LOG_FILTER_IDS.SPAN_TYPE, value: "mcp", param: "span_type" },
{ id: LOG_FILTER_IDS.MODEL_ID, value: "model-uuid-1", param: "model_id" },
{ id: LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL, value: "gpt-4o", param: "model" },
{ id: LOG_FILTER_IDS.KEY_ALIAS, value: "alias-1", param: "key_alias" },

View file

@ -21,6 +21,7 @@ export interface PaginatedResponse {
export const LOG_FILTER_IDS = {
TEAM_ID: "team_id",
SPAN_TYPE: "span_type",
STATUS: "status",
CACHE_STATUS: "cache_hit",
KEY_ALIAS: "key_alias",
@ -38,6 +39,7 @@ export const LOG_FILTER_IDS = {
export const LOG_FILTER_LABELS: Record<string, string> = {
[LOG_FILTER_IDS.TEAM_ID]: "Team ID",
[LOG_FILTER_IDS.SPAN_TYPE]: "Span Type",
[LOG_FILTER_IDS.STATUS]: "Status",
[LOG_FILTER_IDS.CACHE_STATUS]: "Cache",
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
@ -183,6 +185,7 @@ export function useLogFilterLogic({
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS),
cache_hit_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.CACHE_STATUS),
span_type: getFilterValue(columnFilters, LOG_FILTER_IDS.SPAN_TYPE),
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),
model: getFilterValue(columnFilters, LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL),
key_alias: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_ALIAS),

View file

@ -61025,6 +61025,8 @@ export interface operations {
status_filter?: string | null;
/** @description Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state */
cache_hit_filter?: string | null;
/** @description Filter logs by span type: llm, agent, mcp, or batch */
span_type?: string | null;
/** @description Filter logs by model */
model?: string | null;
/** @description Filter logs by model ID (litellm model deployment id) */
@ -61143,6 +61145,8 @@ export interface operations {
status_filter?: string | null;
/** @description Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state */
cache_hit_filter?: string | null;
/** @description Filter logs by span type: llm, agent, mcp, or batch */
span_type?: string | null;
/** @description Filter logs by model */
model?: string | null;
/** @description Filter logs by model ID (litellm model deployment id) */