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
This commit is contained in:
ujitkumar1 2026-08-11 13:04:29 +05:30
parent 7e80e094c4
commit d37f1c81dc
5 changed files with 221 additions and 4 deletions

View file

@ -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)

View file

@ -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(
<GuardrailDetail
guardrailId="g-1"
onBack={() => {}}
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");
});
});

View file

@ -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,
});

View file

@ -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",
});
});
});

View file

@ -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(),
});