fix(ui): keep untimed guardrail entries on the request lifecycle

#39050 changed RequestLifecycle from sorting every entry with
(a.start_time ?? 0) to filtering on isTimed, which drops any entry whose
start_time/end_time are null. That was the right call for the not_run entries
the PR introduced, but it also drops entries that DID run and simply carry no
timing, and those are pre-existing: add_standard_logging_guardrail_information_to_request_data
defaults start_time, end_time and duration to None, and the conduct guardrail
passes none of them. One such entry used to draw the whole four-row lifecycle
and now draws nothing, so an admin opening that log sees an empty
Request Lifecycle panel.

An entry now stays on the lifecycle when it is timed OR when it ran, so not_run
keeps the exclusion #39050 wanted and every other shape comes back. Offsets are
number | null and render as an em dash rather than a fabricated T+0ms, which is
what a null minus a null used to produce on the base. Entries without timing
sort after the timed ones and the base time comes from the timed entries, so
real offsets are unchanged.

The two new tests fail on the base component and pass here; #39050's own
not_run tests keep passing untouched, which is what makes this additive rather
than a revert.
This commit is contained in:
Yuneng Jiang 2026-09-15 21:44:19 -07:00
parent 174c1ac4ed
commit 51a243e3ce
No known key found for this signature in database
2 changed files with 60 additions and 18 deletions

View file

@ -24,6 +24,15 @@ const skippedPreCall: Partial<GuardrailInformation> = {
duration: null,
};
const untimedPreCall: Partial<GuardrailInformation> = {
guardrail_name: "conduct",
guardrail_status: "success",
guardrail_mode: "pre_call",
start_time: null,
end_time: null,
duration: null,
};
const ranPostCall: Partial<GuardrailInformation> = {
guardrail_name: "ran-rail",
guardrail_status: "success",
@ -98,6 +107,30 @@ describe("GuardrailViewer", () => {
expect(screen.getByText("—")).toBeInTheDocument();
});
it("keeps a guardrail that ran without any timing on the lifecycle", () => {
renderWithProviders(<GuardrailViewer data={makeGuardrailInformation(untimedPreCall)} />);
expect(screen.getByText("Request received")).toBeInTheDocument();
expect(screen.getByText(/Pre-call guardrail: conduct/)).toBeInTheDocument();
expect(screen.getByText("LLM call")).toBeInTheDocument();
expect(screen.getByText("Response returned")).toBeInTheDocument();
expect(screen.queryByText(/^T\+/)).not.toBeInTheDocument();
});
it("anchors offsets on the timed entries and gives the untimed one no fabricated offset", () => {
const untimed = makeGuardrailInformation(untimedPreCall);
const ran = makeGuardrailInformation(ranPostCall);
renderWithProviders(<GuardrailViewer data={[untimed, ran]} />);
expect(screen.getByText("Request received").parentElement).toHaveTextContent("T+0ms");
expect(screen.getByText(/Post-call guardrail: ran-rail/).parentElement).toHaveTextContent("T+250ms");
expect(screen.getByText("Response returned").parentElement).toHaveTextContent("T+251ms");
const untimedRow = screen.getByText(/Pre-call guardrail: conduct/).parentElement;
expect(untimedRow).toHaveTextContent("—");
expect(untimedRow).not.toHaveTextContent(/T\+/);
});
it("calculates and displays masked entity totals", async () => {
const user = userEvent.setup();
const data = makeGuardrailInformation({

View file

@ -361,7 +361,7 @@ const GenericGuardrailResponse = ({ response }: { response: any }) => {
interface TimelineEntry {
type: "request" | "guardrail" | "llm" | "response";
label: string;
offsetMs: number;
offsetMs: number | null;
outcome?: EntryOutcome;
}
@ -370,17 +370,26 @@ type TimedGuardrailInformation = GuardrailInformation & { start_time: number; en
const isTimed = (e: GuardrailInformation): e is TimedGuardrailInformation =>
typeof e.start_time === "number" && typeof e.end_time === "number";
const belongsOnLifecycle = (e: GuardrailInformation): boolean => isTimed(e) || getEntryOutcome(e) !== "not_run";
const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
const sorted = useMemo(() => entries.filter(isTimed).sort((a, b) => a.start_time - b.start_time), [entries]);
const sorted = useMemo(() => {
const onLifecycle = entries.filter(belongsOnLifecycle);
const timed = onLifecycle.filter(isTimed).sort((a, b) => a.start_time - b.start_time);
return [...timed, ...onLifecycle.filter((e) => !isTimed(e))];
}, [entries]);
const timeline = useMemo(() => {
if (sorted.length === 0) return [];
const baseTime = sorted[0].start_time;
const timed = sorted.filter(isTimed);
const baseTime = timed.length > 0 ? timed[0].start_time : null;
const offsetOf = (e: GuardrailInformation): number | null =>
baseTime === null || !isTimed(e) ? null : Math.round((e.end_time - baseTime) * 1000);
const items: TimelineEntry[] = [];
// Request received
items.push({ type: "request", label: "Request received", offsetMs: 0 });
items.push({ type: "request", label: "Request received", offsetMs: baseTime === null ? null : 0 });
// Pre-call guardrails — use modeMatches so array modes (e.g. ["pre_call", "post_call"])
// place the entry in every matching bucket.
@ -391,52 +400,50 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
const duringCalls = sorted.filter((e) => modeMatches(e.guardrail_mode, "during_call"));
for (const e of preCalls) {
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
items.push({
type: "guardrail",
label: `Pre-call guardrail: ${getDisplayName(e)}`,
offsetMs,
offsetMs: offsetOf(e),
outcome: getEntryOutcome(e),
});
}
// LLM call — infer from gap between pre-call end and post-call start
const lastPreEnd = preCalls.length > 0 ? Math.max(...preCalls.map((e) => e.end_time)) : baseTime;
const firstPostStart = postCalls.length > 0 ? Math.min(...postCalls.map((e) => e.start_time)) : undefined;
const llmEndTime = firstPostStart ?? lastPreEnd + 1;
const llmOffsetMs = Math.round((llmEndTime - baseTime) * 1000);
const timedPre = preCalls.filter(isTimed);
const timedPost = postCalls.filter(isTimed);
const lastPreEnd = timedPre.length > 0 ? Math.max(...timedPre.map((e) => e.end_time)) : baseTime;
const firstPostStart = timedPost.length > 0 ? Math.min(...timedPost.map((e) => e.start_time)) : undefined;
const llmEndTime = firstPostStart ?? (lastPreEnd === null ? null : lastPreEnd + 1);
items.push({
type: "llm",
label: "LLM call",
offsetMs: llmOffsetMs,
offsetMs: llmEndTime === null || baseTime === null ? null : Math.round((llmEndTime - baseTime) * 1000),
});
// During-call guardrails (rare)
for (const e of duringCalls) {
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
items.push({
type: "guardrail",
label: `During-call guardrail: ${getDisplayName(e)}`,
offsetMs,
offsetMs: offsetOf(e),
outcome: getEntryOutcome(e),
});
}
// Post-call guardrails
for (const e of postCalls) {
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
items.push({
type: "guardrail",
label: `Post-call guardrail: ${getDisplayName(e)}`,
offsetMs,
offsetMs: offsetOf(e),
outcome: getEntryOutcome(e),
});
}
// Response returned
const maxEnd = Math.max(...sorted.map((e) => e.end_time));
const responseOffsetMs = Math.round((maxEnd - baseTime) * 1000) + 1;
const maxEnd = timed.length > 0 ? Math.max(...timed.map((e) => e.end_time)) : null;
const responseOffsetMs = maxEnd === null || baseTime === null ? null : Math.round((maxEnd - baseTime) * 1000) + 1;
items.push({ type: "response", label: "Response returned", offsetMs: responseOffsetMs });
return items;
@ -475,7 +482,9 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
{OUTCOME_LABEL[item.outcome]}
</span>
)}
<span className="text-xs text-muted-foreground font-mono ml-auto shrink-0">T+{item.offsetMs}ms</span>
<span className="text-xs text-muted-foreground font-mono ml-auto shrink-0">
{item.offsetMs === null ? "—" : `T+${item.offsetMs}ms`}
</span>
</div>
</div>
</div>