diff --git a/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py b/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py index bf7b1b3b238..911d15090c8 100644 --- a/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py @@ -10,7 +10,7 @@ orphans), and logs missed their logical-name alias. import os import sys -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Optional from unittest.mock import AsyncMock, MagicMock, patch @@ -237,3 +237,48 @@ async def test_logs_resolves_config_guardrail_logical_name(): ) where = prisma.db.litellm_spendlogguardrailindex.find_many.call_args.kwargs["where"] assert where["guardrail_id"] == {"in": ["yaml-uuid", "yaml-pii"]} + + +# ---- date window ------------------------------------------------------------ + + +async def _logs_where(*, start_date: str, end_date: str) -> dict: + """Run the logs handler and hand back the Prisma filter it built.""" + prisma = _prisma(find_unique=None) + handler = _config_handler(_yaml_guardrail(guardrail_id="yaml-uuid", name="yaml-pii")) + p1, p2 = _patches(prisma, handler) + with p1, p2: + await guardrails_usage_logs( + guardrail_id="yaml-uuid", + policy_id=None, + page=1, + page_size=50, + action=None, + start_date=start_date, + end_date=end_date, + user_api_key_dict=ADMIN, + ) + return prisma.db.litellm_spendlogguardrailindex.find_many.call_args.kwargs["where"] + + +@pytest.mark.asyncio +async def test_logs_honors_an_explicit_instant(): + """ + Regression (#36515): the dashboard resolves the viewer's local day to instants, + because a bare date is padded to UTC midnight and lands the window offset-hours + away from the range that was picked. Dropping this branch silently reinstates + that bug in the UI, so the exact instant must survive to the filter. + """ + where = await _logs_where(start_date="2026-08-09T18:30:00Z", end_date="2026-08-10T18:29:59Z") + + assert where["start_time"]["gte"] == datetime(2026, 8, 9, 18, 30, tzinfo=timezone.utc) + assert where["start_time"]["lte"] == datetime(2026, 8, 10, 18, 29, 59, tzinfo=timezone.utc) + + +@pytest.mark.asyncio +async def test_logs_pads_a_bare_date_to_the_utc_day(): + """A bare date keeps its documented whole-UTC-day meaning for existing callers.""" + where = await _logs_where(start_date="2026-08-10", end_date="2026-08-10") + + assert where["start_time"]["gte"] == datetime(2026, 8, 10, 0, 0, tzinfo=timezone.utc) + assert where["start_time"]["lte"] == datetime(2026, 8, 10, 23, 59, 59, tzinfo=timezone.utc) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.integration.test.tsx new file mode 100644 index 00000000000..6bbdf299c64 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.integration.test.tsx @@ -0,0 +1,65 @@ +import { waitFor } from "@testing-library/react"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GuardrailDetail } from "./GuardrailDetail"; +import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils"; + +// Pinned off UTC on purpose: where the local day and the UTC day coincide, a bare +// date and the resolved instants are the same request and this proves nothing. +const originalTimezone = process.env.TZ; +beforeAll(() => { + process.env.TZ = "Asia/Kolkata"; +}); +afterAll(() => { + process.env.TZ = originalTimezone; +}); + +const fetchMock = vi.fn(); + +const emptyOkResponse = { + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ logs: [], total: 0, rows: [], chart: [], series: [] }), +}; + +const usageLogsQuery = (): URLSearchParams | undefined => { + const call = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes("/guardrails/usage/logs")); + return call === undefined ? undefined : new URL(call, "http://x").searchParams; +}; + +describe("GuardrailDetail sends the viewer's local day as instants", () => { + beforeEach(() => { + testQueryClient.clear(); + vi.clearAllMocks(); + fetchMock.mockResolvedValue(emptyOkResponse); + vi.stubGlobal("fetch", fetchMock); + + renderWithProviders( + {}} + accessToken="sk-test" + startDate="2026-08-10" + endDate="2026-08-10" + />, + ); + }); + + it("resolves the picker's dates instead of forwarding them verbatim", async () => { + await waitFor(() => expect(usageLogsQuery()).toBeDefined()); + const params = usageLogsQuery(); + + // 00:00 and 23:59:59 on 2026-08-10 in IST, expressed as instants. + expect(params?.get("start_date")).toBe("2026-08-09T18:30:00Z"); + expect(params?.get("end_date")).toBe("2026-08-10T18:29:59Z"); + }); + + it("never sends a bare calendar date, which the endpoint would read as UTC", async () => { + await waitFor(() => expect(usageLogsQuery()).toBeDefined()); + const params = usageLogsQuery(); + + expect(params?.get("start_date")).not.toBe("2026-08-10"); + expect(params?.get("end_date")).not.toBe("2026-08-10"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx index 2e007c06744..f5d4dfd312e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx @@ -4,6 +4,7 @@ import { Button, Col, Row, Spin, Tabs } from "antd"; import React, { useMemo, useState } from "react"; import { getGuardrailsUsageDetail, getGuardrailsUsageLogs } from "@/components/networking"; import { EvaluationSettingsModal } from "./EvaluationSettingsModal"; +import { toUtcInstantRange } from "@/components/GuardrailsMonitor/guardrailLogsWindow"; import { LogViewer } from "@/components/GuardrailsMonitor/LogViewer"; import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; import type { LogEntry } from "@/components/GuardrailsMonitor/mockData"; @@ -37,15 +38,16 @@ export function GuardrailDetail({ guardrailId, onBack, accessToken = null, start queryFn: () => getGuardrailsUsageDetail(accessToken!, guardrailId, startDate, endDate), enabled: !!accessToken && !!guardrailId, }); + const logsWindow = useMemo(() => toUtcInstantRange(startDate, endDate), [startDate, endDate]); const { data: logsData, isLoading: logsLoading } = useQuery({ - queryKey: ["guardrails-usage-logs", guardrailId, logsPage, logsPageSize], + queryKey: ["guardrails-usage-logs", guardrailId, logsPage, logsPageSize, logsWindow.start, logsWindow.end], queryFn: () => getGuardrailsUsageLogs(accessToken!, { guardrailId, page: logsPage, pageSize: logsPageSize, - startDate, - endDate, + startDate: logsWindow.start, + endDate: logsWindow.end, }), enabled: !!accessToken && !!guardrailId, }); diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/guardrailLogsWindow.test.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/guardrailLogsWindow.test.ts new file mode 100644 index 00000000000..433e74e2817 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/guardrailLogsWindow.test.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { toUtcInstantRange } from "./guardrailLogsWindow"; + +// The bug is invisible under UTC: there, the local day and the UTC day coincide and a +// naive implementation passes. Every case below pins a non-UTC zone so a regression +// fails instead of silently agreeing. +const withTimezone = (zone: string) => { + const original = process.env.TZ; + beforeAll(() => { + process.env.TZ = zone; + }); + afterAll(() => { + process.env.TZ = original; + }); +}; + +describe("toUtcInstantRange, east of UTC (Asia/Kolkata, +05:30)", () => { + withTimezone("Asia/Kolkata"); + + it("resolves the local day to instants rather than the UTC day", () => { + expect(toUtcInstantRange("2026-08-10", "2026-08-10")).toEqual({ + start: "2026-08-09T18:30:00Z", + end: "2026-08-10T18:29:59Z", + }); + }); + + it("keeps a row just after local midnight inside the range", () => { + const { start, end } = toUtcInstantRange("2026-08-10", "2026-08-10"); + // 2026-08-09T20:00:00Z is 01:30 on 2026-08-10 in IST, so it belongs to that local day. + // Padding the bare date to 2026-08-10T00:00:00Z, as the endpoint does, excludes it. + const row = Date.parse("2026-08-09T20:00:00Z"); + expect(row).toBeGreaterThanOrEqual(Date.parse(start)); + expect(row).toBeLessThanOrEqual(Date.parse(end)); + expect(row).toBeLessThan(Date.parse("2026-08-10T00:00:00Z")); + // Guards against snapping to a UTC midnight in either direction. + expect(Date.parse(start)).toBeGreaterThan(Date.parse("2026-08-09T00:00:00Z")); + expect(Date.parse(end)).toBeGreaterThan(Date.parse("2026-08-10T00:00:00Z")); + }); + + it("does not reach past the end of the local day", () => { + const { end } = toUtcInstantRange("2026-08-10", "2026-08-10"); + // 23:59:59Z is 05:29 on 2026-08-11 in IST, a day the viewer did not ask for. + expect(Date.parse(end)).toBeLessThan(Date.parse("2026-08-10T23:59:59Z")); + // ...but it must still cover the whole local day, which a UTC-day ceiling truncates. + expect(Date.parse(end)).toBeGreaterThan(Date.parse("2026-08-10T12:00:00Z")); + }); + + it("spans a multi-day range end to end", () => { + expect(toUtcInstantRange("2026-08-03", "2026-08-10")).toEqual({ + start: "2026-08-02T18:30:00Z", + end: "2026-08-10T18:29:59Z", + }); + }); +}); + +describe("toUtcInstantRange, west of UTC (America/Los_Angeles, -07:00 in August)", () => { + withTimezone("America/Los_Angeles"); + + it("resolves the local day to instants rather than the UTC day", () => { + expect(toUtcInstantRange("2026-08-10", "2026-08-10")).toEqual({ + start: "2026-08-10T07:00:00Z", + end: "2026-08-11T06:59:59Z", + }); + }); + + it("keeps a row late in the local day inside the range", () => { + const { start, end } = toUtcInstantRange("2026-08-10", "2026-08-10"); + // 2026-08-11T02:00:00Z is 19:00 on 2026-08-10 in PT, still the viewer's Aug 10. + // The endpoint's 2026-08-10T23:59:59Z ceiling would drop it. + const row = Date.parse("2026-08-11T02:00:00Z"); + expect(row).toBeGreaterThanOrEqual(Date.parse(start)); + expect(row).toBeLessThanOrEqual(Date.parse(end)); + expect(row).toBeGreaterThan(Date.parse("2026-08-10T23:59:59Z")); + }); +}); + +describe("toUtcInstantRange, on UTC", () => { + withTimezone("UTC"); + + it("matches the day the endpoint would have assumed", () => { + expect(toUtcInstantRange("2026-08-10", "2026-08-10")).toEqual({ + start: "2026-08-10T00:00:00Z", + end: "2026-08-10T23:59:59Z", + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/guardrailLogsWindow.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/guardrailLogsWindow.ts new file mode 100644 index 00000000000..b519b268f83 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/guardrailLogsWindow.ts @@ -0,0 +1,18 @@ +import moment from "moment"; + +export interface UtcInstantRange { + start: string; + end: string; +} + +/** + * `/guardrails/usage/logs` filters `start_time`, a real timestamp column, but pads a + * bare `YYYY-MM-DD` out to `T00:00:00+00:00` / `T23:59:59+00:00`. Sending the picker's + * local calendar date therefore shifts the window by the viewer's UTC offset. Resolving + * the local day here and sending instants keeps the bound exact; the endpoint already + * parses a value carrying a `T`. + */ +export const toUtcInstantRange = (startDate: string, endDate: string): UtcInstantRange => ({ + start: moment(startDate).startOf("day").utc().format(), + end: moment(endDate).endOf("day").utc().format(), +});