Merge pull request #21143 from BerriAI/litellm_model_id_search

[Feature] UI - Spend Logs: Sorting Columns
This commit is contained in:
yuneng-jiang 2026-02-13 11:53:16 -08:00 committed by GitHub
commit 6eeb9b109b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 910 additions and 186 deletions

View file

@ -1,87 +0,0 @@
import { uiSpendLogsCall } from '../../../ui/litellm-dashboard/src/components/networking';
// Mock the networking module
jest.mock('../../../ui/litellm-dashboard/src/components/networking', () => ({
uiSpendLogsCall: jest.fn(),
}));
const mockUiSpendLogsCall = uiSpendLogsCall as jest.MockedFunction<typeof uiSpendLogsCall>;
describe('Key Alias Filtering Integration Test', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call API with correct key_alias parameter', async () => {
// Mock API response with both success and failure logs
const mockResponse = {
data: [
{ request_id: 'req-1', status: 'success', metadata: { user_api_key_alias: 'test-key' } },
{ request_id: 'req-2', status: 'failure', metadata: { user_api_key_alias: 'test-key' } }
],
total: 2,
page: 1,
page_size: 50,
total_pages: 1
};
mockUiSpendLogsCall.mockResolvedValueOnce(mockResponse);
// Simulate the API call that would happen when filtering by key alias
const result = await uiSpendLogsCall(
'test-token',
undefined,
undefined,
undefined,
'2024-01-15 09:00:00',
'2024-01-15 11:00:00',
1,
50,
undefined,
undefined,
undefined,
undefined,
'test-key-alias' // key_alias - this is the fix
);
// Verify the API was called correctly
expect(mockUiSpendLogsCall).toHaveBeenCalledWith(
'test-token',
undefined,
undefined,
undefined,
'2024-01-15 09:00:00',
'2024-01-15 11:00:00',
1,
50,
undefined,
undefined,
undefined,
undefined,
'test-key-alias' // The key assertion - this parameter should be passed through
);
// Verify response contains both success and failure logs
expect(result.data).toHaveLength(2);
expect(result.data[0].status).toBe('success');
expect(result.data[1].status).toBe('failure');
});
it('should pass undefined for empty key alias', async () => {
mockUiSpendLogsCall.mockResolvedValueOnce({ data: [], total: 0, page: 1, page_size: 50, total_pages: 0 });
await uiSpendLogsCall(
'test-token', undefined, undefined, undefined,
'2024-01-15 09:00:00', '2024-01-15 11:00:00',
1, 50, undefined, undefined, undefined, undefined,
undefined // Empty string should become undefined
);
expect(mockUiSpendLogsCall).toHaveBeenCalledWith(
'test-token', undefined, undefined, undefined,
'2024-01-15 09:00:00', '2024-01-15 11:00:00',
1, 50, undefined, undefined, undefined, undefined,
undefined // Should be undefined for empty key alias
);
});
});

View file

@ -2581,46 +2581,66 @@ export const userSpendLogsCall = async (
}
};
export const uiSpendLogsCall = async (
accessToken: string,
api_key?: string,
team_id?: string,
request_id?: string,
start_date?: string,
end_date?: string,
page?: number,
page_size?: number,
user_id?: string,
end_user?: string,
status_filter?: string,
model?: string,
modelId?: string,
keyAlias?: string,
error_code?: string,
error_message?: string,
) => {
/**
* Optional query params for /spend/logs/ui - matches backend spend_management_endpoints.py
*/
export interface UiSpendLogsParams {
api_key?: string;
team_id?: string;
request_id?: string;
user_id?: string;
end_user?: string;
status_filter?: string;
/** Filter by model name (e.g. "gpt-4") */
model?: string;
/** Filter by model ID (litellm model deployment id) */
model_id?: string;
key_alias?: string;
error_code?: string;
error_message?: string;
sort_by?: string;
sort_order?: "asc" | "desc";
min_spend?: number;
max_spend?: number;
}
export interface UiSpendLogsCallOptions {
accessToken: string;
start_date: string;
end_date: string;
page?: number;
page_size?: number;
params?: UiSpendLogsParams;
}
export const uiSpendLogsCall = async ({
accessToken,
start_date,
end_date,
page = 1,
page_size = 50,
params = {},
}: UiSpendLogsCallOptions) => {
try {
// Construct base URL
let url = proxyBaseUrl ? `${proxyBaseUrl}/spend/logs/ui` : `/spend/logs/ui`;
// Add query parameters if they exist
const queryParams = new URLSearchParams();
if (api_key) queryParams.append("api_key", api_key);
if (team_id) queryParams.append("team_id", team_id);
if (request_id) queryParams.append("request_id", request_id);
if (start_date) queryParams.append("start_date", start_date);
if (end_date) queryParams.append("end_date", end_date);
if (page) queryParams.append("page", page.toString());
if (page_size) queryParams.append("page_size", page_size.toString());
if (user_id) queryParams.append("user_id", user_id);
if (end_user) queryParams.append("end_user", end_user);
if (status_filter) queryParams.append("status_filter", status_filter);
if (model) queryParams.append("model", model);
if (modelId) queryParams.append("model_id", modelId);
if (keyAlias) queryParams.append("key_alias", keyAlias);
if (error_code) queryParams.append("error_code", error_code);
if (error_message) queryParams.append("error_message", error_message);
// Append query parameters to URL if any exist
queryParams.append("start_date", start_date);
queryParams.append("end_date", end_date);
queryParams.append("page", page.toString());
queryParams.append("page_size", page_size.toString());
// Add optional params only when explicitly provided
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
if (key === "min_spend" || key === "max_spend") {
queryParams.append(key, value.toString());
} else if (typeof value === "string" && value !== "") {
queryParams.append(key, String(value));
}
}
const queryString = queryParams.toString();
if (queryString) {
url += `?${queryString}`;

View file

@ -4,10 +4,26 @@ import { Badge, Button } from "@tremor/react";
import { Tooltip } from "antd";
import React, { useState } from "react";
import { getProviderLogoAndName } from "../provider_info_helpers";
import { TableHeaderSortDropdown } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
import { TimeCell } from "./time_cell";
import { MCP_CALL_TYPES } from "./constants";
import { LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
/** API sort field mapping for /spend/logs/ui endpoint */
export const LOGS_SORT_FIELD_MAP = {
startTime: "startTime",
spend: "spend",
total_tokens: "total_tokens",
} as const;
export type LogsSortField = keyof typeof LOGS_SORT_FIELD_MAP;
export interface LogsSortProps {
sortBy: LogsSortField;
sortOrder: "asc" | "desc";
onSortChange: (sortBy: LogsSortField, sortOrder: "asc" | "desc") => void;
}
// Helper to get the appropriate logo URL
const getLogoUrl = (row: LogEntry, provider: string) => {
// Check if mcp_tool_call_metadata exists and contains mcp_server_logo_url
@ -56,9 +72,47 @@ export type LogEntry = {
onSessionClick?: (sessionId: string) => void;
};
export const columns: ColumnDef<LogEntry>[] = [
const SortableHeader = ({
label,
field,
sortBy,
sortOrder,
onSortChange,
}: {
label: string;
field: LogsSortField;
sortBy: LogsSortField;
sortOrder: "asc" | "desc";
onSortChange: (sortBy: LogsSortField, sortOrder: "asc" | "desc") => void;
}) => (
<div className="flex items-center gap-1">
<span>{label}</span>
<TableHeaderSortDropdown
sortState={sortBy === field ? sortOrder : false}
onSortChange={(newState) => {
if (newState === false) {
onSortChange("startTime", "desc");
} else {
onSortChange(field, newState);
}
}}
/>
</div>
);
export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[] => [
{
header: "Time",
header: sortProps
? () => (
<SortableHeader
label="Time"
field="startTime"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "Time",
accessorKey: "startTime",
cell: (info: any) => <TimeCell utcTime={info.getValue()} />,
},
@ -145,7 +199,17 @@ export const columns: ColumnDef<LogEntry>[] = [
),
},
{
header: "Cost",
header: sortProps
? () => (
<SortableHeader
label="Cost"
field="spend"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "Cost",
accessorKey: "spend",
cell: (info: any) => {
const row = info.row.original;
@ -240,7 +304,17 @@ export const columns: ColumnDef<LogEntry>[] = [
},
},
{
header: "Tokens",
header: sortProps
? () => (
<SortableHeader
label="Tokens"
field="total_tokens"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "Tokens",
accessorKey: "total_tokens",
cell: (info: any) => {
const row = info.row.original;
@ -308,6 +382,9 @@ export const columns: ColumnDef<LogEntry>[] = [
},
];
/** Default columns without sort (for backward compatibility) */
export const columns = createColumns();
const formatMessage = (message: any): string => {
if (!message) return "N/A";
if (typeof message === "string") return message;

View file

@ -1,11 +1,10 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import moment from "moment";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useDeferredValue, useEffect, useRef, useState } from "react";
import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { truncateString } from "@/utils/textUtils";
import { SettingOutlined } from "@ant-design/icons";
import { SettingOutlined, SyncOutlined } from "@ant-design/icons";
import { Row } from "@tanstack/react-table";
import { Switch, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import { Button, Tooltip } from "antd";
@ -20,7 +19,7 @@ import FilterComponent, { FilterOption } from "../molecules/filter";
import { allEndUsersCall, keyInfoV1Call, keyListCall, uiSpendLogsCall } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import AuditLogs from "./audit_logs";
import { columns, LogEntry } from "./columns";
import { createColumns, LogEntry, type LogsSortField } from "./columns";
import { ConfigInfoMessage } from "./ConfigInfoMessage";
import { ERROR_CODE_OPTIONS, MCP_CALL_TYPES, QUICK_SELECT_OPTIONS } from "./constants";
import { CostBreakdownViewer } from "./CostBreakdownViewer";
@ -77,7 +76,7 @@ export default function SpendLogsTable({
const [tempKeyHash, setTempKeyHash] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState("");
const [selectedKeyHash, setSelectedKeyHash] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [selectedModelId, setSelectedModelId] = useState("");
const [selectedKeyInfo, setSelectedKeyInfo] = useState<KeyResponse | null>(null);
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState("");
@ -90,6 +89,9 @@ export default function SpendLogsTable({
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [isSpendLogsSettingsModalVisible, setIsSpendLogsSettingsModalVisible] = useState(false);
const [sortBy, setSortBy] = useState<LogsSortField>("startTime");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const queryClient = useQueryClient();
const [isLiveTail, setIsLiveTail] = useState<boolean>(() => {
@ -168,7 +170,9 @@ export default function SpendLogsTable({
selectedKeyHash,
filterByCurrentUser ? userID : null,
selectedStatus,
selectedModel,
selectedModelId,
sortBy,
sortOrder,
],
queryFn: async () => {
if (!accessToken || !token || !userRole || !userID) {
@ -189,29 +193,38 @@ export default function SpendLogsTable({
// Get base response from API
// NOTE: We only fetch the list of logs here (lightweight).
// Log details (messages/response) are fetched on-demand when user clicks a row.
const response = await uiSpendLogsCall(
const response = await uiSpendLogsCall({
accessToken,
selectedKeyHash || undefined,
selectedTeamId || undefined,
undefined,
formattedStartTime,
formattedEndTime,
currentPage,
pageSize,
filterByCurrentUser ? userID : undefined,
selectedEndUser,
selectedStatus,
undefined,
selectedModel || undefined,
);
start_date: formattedStartTime,
end_date: formattedEndTime,
page: currentPage,
page_size: pageSize,
params: {
api_key: selectedKeyHash || undefined,
team_id: selectedTeamId || undefined,
user_id: filterByCurrentUser ? userID ?? undefined : undefined,
end_user: selectedEndUser || undefined,
status_filter: selectedStatus || undefined,
model_id: selectedModelId || undefined,
sort_by: sortBy,
sort_order: sortOrder,
},
});
return response;
},
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs",
refetchInterval: isLiveTail && currentPage === 1 ? 15000 : false,
placeholderData: keepPreviousData,
refetchIntervalInBackground: true,
});
// Defer the transition from "Fetching" to "Fetch" so the button stays loading until
// the table has rendered with the new data (avoids the visual gap where the button
// exits loading state before the table updates)
const isFetchingDeferred = useDeferredValue(logs.isFetching);
const isButtonLoading = logs.isFetching || isFetchingDeferred;
const logsData = logs.data || {
data: [],
total: 0,
@ -237,6 +250,9 @@ export default function SpendLogsTable({
setCurrentPage,
userID,
userRole,
sortBy,
sortOrder,
currentPage,
});
const fetchKeyHashForAlias = useCallback(
@ -268,7 +284,7 @@ export default function SpendLogsTable({
setSelectedTeamId("");
}
setSelectedStatus(filters["Status"] || "");
setSelectedModel(filters["Model"] || "");
setSelectedModelId(filters["Model"] || "");
setSelectedEndUser(filters["End User"] || "");
if (filters["Key Hash"]) {
@ -596,26 +612,15 @@ export default function SpendLogsTable({
<LiveTailControls />
<button
<Button
type="default"
icon={<SyncOutlined spin={isButtonLoading} />}
onClick={handleRefresh}
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
title="Refresh data"
disabled={isButtonLoading}
title="Fetch data"
>
<svg
className={`w-4 h-4 ${logs.isFetching ? "animate-spin" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>Refresh</span>
</button>
{isButtonLoading ? "Fetching" : "Fetch"}
</Button>
</div>
{isCustomDate && (
@ -694,9 +699,18 @@ export default function SpendLogsTable({
</div>
)}
<DataTable
columns={columns}
columns={createColumns({
sortBy,
sortOrder,
onSortChange: (newSortBy, newSortOrder) => {
setSortBy(newSortBy);
setSortOrder(newSortOrder);
setCurrentPage(1);
},
})}
data={filteredData}
onRowClick={handleRowClick}
isLoading={logs.isLoading}
/>
</div>
</>

View file

@ -0,0 +1,682 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import React, { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PaginatedResponse } from ".";
import type { LogEntry, LogsSortField } from "./columns";
import { useLogFilterLogic } from "./log_filter_logic";
vi.mock("../networking", () => ({
uiSpendLogsCall: vi.fn(),
}));
vi.mock("@/components/key_team_helpers/filter_helpers", () => ({
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
fetchAllTeams: vi.fn().mockResolvedValue([]),
}));
import { uiSpendLogsCall } from "../networking";
const createLogEntry = (overrides: Partial<LogEntry> = {}): LogEntry =>
({
request_id: "req-1",
api_key: "key-1",
team_id: "team-1",
model: "gpt-4",
model_id: "gpt-4",
call_type: "chat",
spend: 0,
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
startTime: "2025-01-01T00:00:00Z",
endTime: "2025-01-01T00:01:00Z",
cache_hit: "miss",
messages: [],
response: {},
metadata: {},
request_tags: {},
...overrides,
} as LogEntry);
const createPaginatedResponse = (data: LogEntry[]): PaginatedResponse => ({
data,
total: data.length,
page: 1,
page_size: 50,
total_pages: 1,
});
const defaultProps = {
logs: createPaginatedResponse([]),
accessToken: "test-token",
startTime: "2025-01-01T00:00:00",
endTime: "2025-01-01T23:59:59",
isCustomDate: true,
setCurrentPage: vi.fn(),
userID: "user-1",
userRole: "Admin",
};
describe("useLogFilterLogic", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
vi.clearAllMocks();
vi.mocked(uiSpendLogsCall).mockResolvedValue({
data: [],
total: 0,
page: 1,
page_size: 50,
total_pages: 0,
});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("should return filters, filteredLogs, allKeyAliases, allTeams, handleFilterChange, and handleFilterReset", () => {
const { result } = renderHook(
() =>
useLogFilterLogic({
...defaultProps,
logs: createPaginatedResponse([createLogEntry()]),
}),
{ wrapper },
);
expect(result.current.filters).toBeDefined();
expect(result.current.filteredLogs).toBeDefined();
expect(result.current.allKeyAliases).toBeDefined();
expect(result.current).toHaveProperty("allTeams");
expect(result.current.handleFilterChange).toBeDefined();
expect(result.current.handleFilterReset).toBeDefined();
});
it("should initialize filters with all keys empty", () => {
const { result } = renderHook(() => useLogFilterLogic(defaultProps), { wrapper });
const filters = result.current.filters;
expect(filters["Team ID"]).toBe("");
expect(filters["Key Hash"]).toBe("");
expect(filters["Request ID"]).toBe("");
expect(filters["Model"]).toBe("");
expect(filters["User ID"]).toBe("");
expect(filters["End User"]).toBe("");
expect(filters["Status"]).toBe("");
expect(filters["Key Alias"]).toBe("");
expect(filters["Error Code"]).toBe("");
expect(filters["Error Message"]).toBe("");
});
it("should return all logs when no filters are applied", () => {
const logs = createPaginatedResponse([
createLogEntry({ request_id: "req-1" }),
createLogEntry({ request_id: "req-2" }),
]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
expect(result.current.filteredLogs.data).toHaveLength(2);
expect(result.current.filteredLogs.data).toEqual(logs.data);
});
it("should filter logs by team_id when Team ID filter is set", () => {
const logs = createPaginatedResponse([
createLogEntry({ request_id: "req-1", team_id: "team-a" }),
createLogEntry({ request_id: "req-2", team_id: "team-b" }),
createLogEntry({ request_id: "req-3", team_id: "team-a" }),
]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Team ID": "team-a" });
});
expect(result.current.filteredLogs.data).toHaveLength(2);
expect(result.current.filteredLogs.data.every((log) => log.team_id === "team-a")).toBe(true);
});
it("should filter logs by status when Status filter is set to success", () => {
const logs = createPaginatedResponse([
createLogEntry({ request_id: "req-1", status: "success" }),
createLogEntry({ request_id: "req-2" }),
createLogEntry({ request_id: "req-3", status: "error" }),
]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ Status: "success" });
});
expect(result.current.filteredLogs.data).toHaveLength(2);
expect(result.current.filteredLogs.data.every((log) => !log.status || log.status === "success")).toBe(true);
});
it("should filter logs by status when Status filter is set to error", () => {
const logs = createPaginatedResponse([
createLogEntry({ request_id: "req-1", status: "success" }),
createLogEntry({ request_id: "req-2", status: "error" }),
]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ Status: "error" });
});
expect(result.current.filteredLogs.data).toHaveLength(1);
expect(result.current.filteredLogs.data[0].status).toBe("error");
});
it("should filter logs by model_id when Model filter is set", async () => {
const filteredLogs = [
createLogEntry({ request_id: "req-1", model_id: "gpt-4" }),
createLogEntry({ request_id: "req-3", model_id: "gpt-4" }),
];
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse(filteredLogs),
);
const logs = createPaginatedResponse([
createLogEntry({ request_id: "req-1", model_id: "gpt-4" }),
createLogEntry({ request_id: "req-2", model_id: "gpt-3.5" }),
createLogEntry({ request_id: "req-3", model_id: "gpt-4" }),
]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ Model: "gpt-4" });
});
await waitFor(
() => {
expect(result.current.filteredLogs.data).toHaveLength(2);
expect(result.current.filteredLogs.data.every((log) => log.model_id === "gpt-4")).toBe(true);
},
{ timeout: 500 },
);
});
it("should filter logs by api_key when Key Hash filter is set", async () => {
const filteredLog = createLogEntry({ request_id: "req-1", api_key: "key-x" });
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([filteredLog]),
);
const logs = createPaginatedResponse([
createLogEntry({ request_id: "req-1", api_key: "key-x" }),
createLogEntry({ request_id: "req-2", api_key: "key-y" }),
]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Key Hash": "key-x" });
});
await waitFor(
() => {
expect(result.current.filteredLogs.data).toHaveLength(1);
expect(result.current.filteredLogs.data[0].api_key).toBe("key-x");
},
{ timeout: 500 },
);
});
it("should filter logs by end_user when End User filter is set", async () => {
const filteredLog = createLogEntry({ request_id: "req-1", end_user: "user-a" });
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([filteredLog]),
);
const logs = createPaginatedResponse([
createLogEntry({ request_id: "req-1", end_user: "user-a" }),
createLogEntry({ request_id: "req-2", end_user: "user-b" }),
]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "End User": "user-a" });
});
await waitFor(
() => {
expect(result.current.filteredLogs.data).toHaveLength(1);
expect(result.current.filteredLogs.data[0].end_user).toBe("user-a");
},
{ timeout: 500 },
);
});
it("should filter logs by error_code when Error Code filter is set", async () => {
const filteredLog = createLogEntry({
request_id: "req-1",
metadata: { error_information: { error_code: "429" } },
});
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([filteredLog]),
);
const logs = createPaginatedResponse([
createLogEntry({
request_id: "req-1",
metadata: { error_information: { error_code: "429" } },
}),
createLogEntry({
request_id: "req-2",
metadata: { error_information: { error_code: "500" } },
}),
]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Error Code": "429" });
});
await waitFor(
() => {
expect(result.current.filteredLogs.data).toHaveLength(1);
expect(result.current.filteredLogs.data[0].metadata?.error_information?.error_code).toBe("429");
},
{ timeout: 500 },
);
});
it("should return empty data when logs is null or has no data", () => {
const { result } = renderHook(
() =>
useLogFilterLogic({
...defaultProps,
logs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 },
}),
{ wrapper },
);
expect(result.current.filteredLogs.data).toEqual([]);
expect(result.current.filteredLogs.total).toBe(0);
});
it("should reset filters when handleFilterReset is called", () => {
const logs = createPaginatedResponse([createLogEntry()]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Team ID": "team-1", Status: "success" });
});
expect(result.current.filters["Team ID"]).toBe("team-1");
expect(result.current.filters["Status"]).toBe("success");
act(() => {
result.current.handleFilterReset();
});
expect(result.current.filters["Team ID"]).toBe("");
expect(result.current.filters["Status"]).toBe("");
});
it("should call setCurrentPage with 1 when handleFilterChange is invoked", () => {
const setCurrentPage = vi.fn();
const logs = createPaginatedResponse([createLogEntry()]);
const { result } = renderHook(
() => useLogFilterLogic({ ...defaultProps, logs, setCurrentPage }),
{ wrapper },
);
act(() => {
result.current.handleFilterChange({ "Team ID": "team-1" });
});
expect(setCurrentPage).toHaveBeenCalledWith(1);
});
it("should call uiSpendLogsCall when backend filter is set and debounce elapses", async () => {
const logs = createPaginatedResponse([createLogEntry()]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(
() => {
expect(uiSpendLogsCall).toHaveBeenCalled();
},
{ timeout: 500 },
);
});
it("should not call uiSpendLogsCall when accessToken is null", async () => {
const logs = createPaginatedResponse([createLogEntry()]);
const { result } = renderHook(
() => useLogFilterLogic({ ...defaultProps, logs, accessToken: null }),
{ wrapper },
);
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await new Promise((resolve) => setTimeout(resolve, 350));
expect(uiSpendLogsCall).not.toHaveBeenCalled();
});
it("should use backend filtered logs when backend filters are active and API returns data", async () => {
const backendLog = createLogEntry({ request_id: "backend-req" });
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([backendLog]),
);
const logs = createPaginatedResponse([createLogEntry({ request_id: "client-req" })]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(
() => {
expect(result.current.filteredLogs.data).toHaveLength(1);
expect(result.current.filteredLogs.data[0].request_id).toBe("backend-req");
},
{ timeout: 500 },
);
});
it("should call uiSpendLogsCall with request_id when Request ID filter is set", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([createLogEntry({ request_id: "req-xyz" })]),
);
const logs = createPaginatedResponse([createLogEntry()]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Request ID": "req-xyz" });
});
await waitFor(
() => {
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ request_id: "req-xyz" }),
}),
);
},
{ timeout: 500 },
);
});
it("should call uiSpendLogsCall with user_id when User ID filter is set", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([createLogEntry()]),
);
const logs = createPaginatedResponse([createLogEntry()]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "User ID": "user-123" });
});
await waitFor(
() => {
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ user_id: "user-123" }),
}),
);
},
{ timeout: 500 },
);
});
it("should call uiSpendLogsCall with error_message when Error Message filter is set", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([createLogEntry()]),
);
const logs = createPaginatedResponse([createLogEntry()]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Error Message": "rate limit exceeded" });
});
await waitFor(
() => {
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ error_message: "rate limit exceeded" }),
}),
);
},
{ timeout: 500 },
);
});
it("should fall back to logs when backend filters are active but API returns empty", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue({
data: [],
total: 0,
page: 1,
page_size: 50,
total_pages: 0,
});
const clientLog = createLogEntry({ request_id: "client-req" });
const logs = createPaginatedResponse([clientLog]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(
() => {
expect(uiSpendLogsCall).toHaveBeenCalled();
},
{ timeout: 500 },
);
expect(result.current.filteredLogs.data).toHaveLength(1);
expect(result.current.filteredLogs.data[0].request_id).toBe("client-req");
});
it("should refetch when sortBy changes and backend filters are active", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([createLogEntry()]),
);
const logs = createPaginatedResponse([createLogEntry()]);
const { result, rerender } = renderHook(
(props: { sortBy?: LogsSortField }) =>
useLogFilterLogic({ ...defaultProps, logs, ...props }),
{ wrapper, initialProps: { sortBy: "startTime" as LogsSortField } },
);
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), {
timeout: 500,
});
rerender({ sortBy: "spend" as LogsSortField });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), {
timeout: 500,
});
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
expect.objectContaining({
params: expect.objectContaining({ sort_by: "spend" }),
}),
);
});
it("should refetch when sortOrder changes and backend filters are active", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([createLogEntry()]),
);
const logs = createPaginatedResponse([createLogEntry()]);
const { result, rerender } = renderHook(
(props: { sortOrder?: "asc" | "desc" }) =>
useLogFilterLogic({ ...defaultProps, logs, ...props }),
{ wrapper, initialProps: { sortOrder: "desc" } },
);
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), {
timeout: 500,
});
rerender({ sortOrder: "asc" });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), {
timeout: 500,
});
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
expect.objectContaining({
params: expect.objectContaining({ sort_order: "asc" }),
}),
);
});
it("should refetch when currentPage changes and backend filters are active", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([createLogEntry()]),
);
const logs = createPaginatedResponse([createLogEntry()]);
const { result, rerender } = renderHook(
(props) => useLogFilterLogic({ ...defaultProps, logs, ...props }),
{ wrapper, initialProps: { currentPage: 1 } },
);
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), {
timeout: 500,
});
rerender({ currentPage: 2 });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), {
timeout: 500,
});
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 2 }),
);
});
it("should not call setCurrentPage when handleFilterChange receives identical filters", async () => {
const setCurrentPage = vi.fn();
const logs = createPaginatedResponse([createLogEntry()]);
const { result } = renderHook(
() => useLogFilterLogic({ ...defaultProps, logs, setCurrentPage }),
{ wrapper },
);
act(() => {
result.current.handleFilterChange({ "Team ID": "team-1" });
});
await waitFor(() => expect(setCurrentPage).toHaveBeenCalledTimes(1), {
timeout: 500,
});
setCurrentPage.mockClear();
await act(async () => {
result.current.handleFilterChange({ "Team ID": "team-1" });
await new Promise((resolve) => setTimeout(resolve, 350));
});
expect(setCurrentPage).not.toHaveBeenCalled();
});
it("should not crash when uiSpendLogsCall throws", async () => {
vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("Network error"));
const logs = createPaginatedResponse([createLogEntry()]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), {
timeout: 500,
});
expect(result.current.filteredLogs).toBeDefined();
expect(result.current.filters).toBeDefined();
});
it("should clear backendFilteredLogs when handleFilterReset is called", async () => {
const backendLog = createLogEntry({ request_id: "backend-req" });
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([backendLog]),
);
const logs = createPaginatedResponse([createLogEntry({ request_id: "client-req" })]);
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(
() => {
expect(result.current.filteredLogs.data[0].request_id).toBe("backend-req");
},
{ timeout: 500 },
);
act(() => {
result.current.handleFilterReset();
});
expect(result.current.filteredLogs.data).toEqual(logs.data);
expect(result.current.filteredLogs.data[0].request_id).toBe("client-req");
});
it("should pass correct start_date, end_date, sort_by, and sort_order to uiSpendLogsCall", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(
createPaginatedResponse([createLogEntry()]),
);
const logs = createPaginatedResponse([createLogEntry()]);
const { result } = renderHook(
() =>
useLogFilterLogic({
...defaultProps,
logs,
startTime: "2025-01-15T00:00:00Z",
endTime: "2025-01-15T23:59:59Z",
isCustomDate: true,
sortBy: "spend",
sortOrder: "asc",
}),
{ wrapper },
);
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(
() => {
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
start_date: "2025-01-15 00:00:00",
end_date: "2025-01-15 23:59:59",
params: expect.objectContaining({
sort_by: "spend",
sort_order: "asc",
}),
}),
);
},
{ timeout: 500 },
);
});
});

View file

@ -7,6 +7,7 @@ import { fetchAllKeyAliases, fetchAllTeams } from "../../components/key_team_hel
import { debounce } from "lodash";
import { defaultPageSize } from "../constants";
import { PaginatedResponse } from ".";
import type { LogsSortField } from "./columns";
const FILTER_KEYS = {
TEAM_ID: "Team ID",
@ -34,6 +35,9 @@ export function useLogFilterLogic({
setCurrentPage,
userID,
userRole,
sortBy = "startTime",
sortOrder = "desc",
currentPage = 1,
}: {
logs: PaginatedResponse;
accessToken: string | null;
@ -44,6 +48,9 @@ export function useLogFilterLogic({
setCurrentPage: (page: number) => void;
userID: string | null;
userRole: string | null;
sortBy?: LogsSortField;
sortOrder?: "asc" | "desc";
currentPage?: number;
}) {
const defaultFilters = useMemo<LogFilterState>(
() => ({
@ -84,24 +91,27 @@ export function useLogFilterLogic({
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
try {
const response = await uiSpendLogsCall(
const response = await uiSpendLogsCall({
accessToken,
filters[FILTER_KEYS.KEY_HASH] || undefined,
filters[FILTER_KEYS.TEAM_ID] || undefined,
filters[FILTER_KEYS.REQUEST_ID] || undefined,
formattedStartTime,
formattedEndTime,
start_date: formattedStartTime,
end_date: formattedEndTime,
page,
pageSize,
filters[FILTER_KEYS.USER_ID] || undefined,
filters[FILTER_KEYS.END_USER] || undefined,
filters[FILTER_KEYS.STATUS] || undefined,
undefined,
filters[FILTER_KEYS.MODEL] || undefined,
filters[FILTER_KEYS.KEY_ALIAS] || undefined,
filters[FILTER_KEYS.ERROR_CODE] || undefined,
filters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
);
page_size: pageSize,
params: {
api_key: filters[FILTER_KEYS.KEY_HASH] || undefined,
team_id: filters[FILTER_KEYS.TEAM_ID] || undefined,
request_id: filters[FILTER_KEYS.REQUEST_ID] || undefined,
user_id: filters[FILTER_KEYS.USER_ID] || undefined,
end_user: filters[FILTER_KEYS.END_USER] || undefined,
status_filter: filters[FILTER_KEYS.STATUS] || undefined,
model_id: filters[FILTER_KEYS.MODEL] || undefined,
key_alias: filters[FILTER_KEYS.KEY_ALIAS] || undefined,
error_code: filters[FILTER_KEYS.ERROR_CODE] || undefined,
error_message: filters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
sort_by: sortBy,
sort_order: sortOrder,
},
});
if (currentTimestamp === lastSearchTimestamp.current && response.data) {
setBackendFilteredLogs(response);
@ -110,7 +120,7 @@ export function useLogFilterLogic({
console.error("Error searching users:", error);
}
},
[accessToken, startTime, endTime, isCustomDate, pageSize],
[accessToken, startTime, endTime, isCustomDate, pageSize, sortBy, sortOrder],
);
const debouncedSearch = useMemo(
@ -142,11 +152,19 @@ export function useLogFilterLogic({
filters[FILTER_KEYS.USER_ID] ||
filters[FILTER_KEYS.END_USER] ||
filters[FILTER_KEYS.ERROR_CODE] ||
filters[FILTER_KEYS.ERROR_MESSAGE]
filters[FILTER_KEYS.ERROR_MESSAGE] ||
filters[FILTER_KEYS.MODEL]
),
[filters],
);
// Refetch when sort or page changes (backend filters use their own fetch, not the main query)
useEffect(() => {
if (hasBackendFilters && accessToken) {
performSearch(filters, currentPage);
}
}, [sortBy, sortOrder, currentPage]);
// Compute client-side filtered logs directly from incoming logs and filters
const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => {
if (!logs || !logs.data) {
@ -180,7 +198,7 @@ export function useLogFilterLogic({
}
if (filters[FILTER_KEYS.MODEL]) {
filteredData = filteredData.filter((log) => log.model === filters[FILTER_KEYS.MODEL]);
filteredData = filteredData.filter((log) => log.model_id === filters[FILTER_KEYS.MODEL]);
}
if (filters[FILTER_KEYS.KEY_HASH]) {