From d37f1c81dce0f0fadf68a2aa31c74e6825d81539 Mon Sep 17 00:00:00 2001 From: ujitkumar1 Date: Tue, 11 Aug 2026 13:04:29 +0530 Subject: [PATCH] fix: guardrail logs miss rows at the date range edges outside UTC The picker sends a plain date like 2026-08-10, and the endpoint pads it out to midnight UTC. Those are not the same moment unless you happen to be on UTC. On IST the window ends up 5.5 hours off, so a row logged at 01:30 local falls outside its own day and never shows up. Resolve the local day to instants before calling. The endpoint already accepts a value with a T in it, so nothing changes server side. The window was also missing from the query key, so changing the date range never refetched. Left alone, that would have hidden the fix. The tests pin Asia/Kolkata, America/Los_Angeles and UTC. On UTC the old code looks correct, so a test that does not pin a zone passes either way. There are two python tests as well. Nothing changed server side, but the fix now leans on the branch that leaves an explicit instant alone, and that branch had no test. Without one it could be tidied away and quietly break the dashboard again. The integration test covers the wiring the unit tests cannot reach, so dropping the conversion and passing the picker dates straight through fails loudly. Refs #36515 --- .../proxy/guardrails/test_usage_endpoints.py | 47 +++++++++- .../GuardrailDetail.integration.test.tsx | 65 ++++++++++++++ .../_components/GuardrailDetail.tsx | 8 +- .../guardrailLogsWindow.test.ts | 87 +++++++++++++++++++ .../GuardrailsMonitor/guardrailLogsWindow.ts | 18 ++++ 5 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/guardrailLogsWindow.test.ts create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/guardrailLogsWindow.ts 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(), +});