Skip to content

Commit 67fdbbd

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
feat(webapp): keep the run inspector and queued time fresh on live runs
With progressive trace loading, the span inspector now stays up to date while a run is live, and the timeline's queued time corrects itself as soon as a queued run starts. Mono-RevId: 8e43c0dc3369b6bca9731cca71cc306eeb0216c4
1 parent 21e927e commit 67fdbbd

7 files changed

Lines changed: 291 additions & 44 deletions

File tree

‎apps/webapp/app/hooks/useRunStatusBackstop.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,28 @@ import type { RunStatusData } from "~/routes/resources.orgs.$organizationSlug.pr
44

55
const DEFAULT_POLL_MS = 15_000;
66

7-
// Polls the run status and calls `onFinished` on each poll that sees the run finished,
8-
// until the caller disables it. The reload `onFinished` triggers can read a lagging
9-
// replica or be interrupted, so polling continues until the page data shows the run
10-
// finished. Failed polls are ignored and retried on the next interval.
7+
// Polls the run status and passes each result to `onStatus` until the caller disables
8+
// it. Reloads the caller triggers can read a lagging replica or be interrupted, so the
9+
// caller keeps polling until the page data agrees. Failed polls are ignored and retried
10+
// on the next interval.
1111
export function useRunStatusBackstop({
1212
enabled,
1313
statusPath,
1414
shouldSkip,
15-
onFinished,
15+
onStatus,
1616
pollMs = DEFAULT_POLL_MS,
1717
}: {
1818
enabled: boolean;
1919
statusPath: string;
2020
shouldSkip: () => boolean;
21-
onFinished: () => void;
21+
onStatus: (data: RunStatusData) => void;
2222
pollMs?: number;
2323
}) {
2424
const shouldSkipRef = useRef(shouldSkip);
25-
const onFinishedRef = useRef(onFinished);
25+
const onStatusRef = useRef(onStatus);
2626
useEffect(() => {
2727
shouldSkipRef.current = shouldSkip;
28-
onFinishedRef.current = onFinished;
28+
onStatusRef.current = onStatus;
2929
});
3030

3131
useEffect(() => {
@@ -38,8 +38,8 @@ export function useRunStatusBackstop({
3838
);
3939
if (stopped || fetchError || !response.ok) return;
4040
const [parseError, data] = await tryCatch(response.json() as Promise<RunStatusData>);
41-
if (stopped || parseError || (!data.isFinished && data.completedAt === null)) return;
42-
onFinishedRef.current();
41+
if (stopped || parseError) return;
42+
onStatusRef.current(data);
4343
};
4444
const id = window.setInterval(() => void poll(), pollMs);
4545
return () => {

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
131131
import { shouldRevalidateRunPage } from "./shouldRevalidateRunPage";
132132
import { settleUnloadedRoot } from "./settleUnloadedRoot";
133133
import { getTraceStatus, type TraceStatus } from "./traceStatus";
134+
import { useThrottledRefresh } from "./useThrottledRefresh";
134135
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
135136
import { pageMeta } from "~/utils/pageTitle";
136137

@@ -679,6 +680,7 @@ function TraceFilters({
679680

680681
// Coarse on purpose: the tail normally flips the root to terminal itself.
681682
const RUN_STATUS_BACKSTOP_POLL_MS = 15_000;
683+
const INSPECTOR_REFRESH_MS = 5_000;
682684

683685
// Tail path: no size cap. Keeps tailing while the root still looks unfinished, so
684686
// final rows that become visible late still land, but not long after completion.
@@ -837,17 +839,24 @@ function TraceView({
837839
}
838840
}, [run.friendlyId, rootSpanStatus, liveTailEnabled, runDataUnfinished]);
839841

840-
// Tail path backstop: reload once the run finishes, even if the tail missed the
841-
// root's final row. Polls whatever the stream is doing, and keeps reloading until the
842-
// page data shows the run finished (a reload can read a lagging replica).
842+
// Tail path backstop: reload when the run starts and when it finishes, even if the
843+
// tail missed the root's final row. Polls whatever the stream is doing, and keeps
844+
// reloading until the page data agrees (a reload can read a lagging replica).
843845
useRunStatusBackstop({
844846
enabled: liveTailEnabled && runDataUnfinished,
845847
statusPath,
846848
shouldSkip: () => document.visibilityState === "hidden",
847-
onFinished: () => {
848-
// The revalidate alone won't refresh the tree if the span count didn't change.
849-
tailLive();
850-
revalidatorRef.current.revalidate();
849+
onStatus: (data) => {
850+
if (data.isFinished || data.completedAt !== null) {
851+
// The revalidate alone won't refresh the tree if the span count didn't change.
852+
tailLive();
853+
revalidatorRef.current.revalidate();
854+
return;
855+
}
856+
// Run started: reload once for the queued offset, retried until the page data has it.
857+
if (data.startedAt !== null && run.startedAt === null) {
858+
revalidatorRef.current.revalidate();
859+
}
851860
},
852861
pollMs: RUN_STATUS_BACKSTOP_POLL_MS,
853862
});
@@ -866,6 +875,15 @@ function TraceView({
866875
}
867876
}, [isComplete, recentlyActive, traceIsTruncated, loadFailed, liveTailEnabled, tailLive]);
868877

878+
// Tail path: the inspector loads separately, so reload it when the tree changes. After
879+
// the background load, rebuilds come from the tail or a revalidate merge.
880+
const inspectorRefreshKey = useThrottledRefresh(events, {
881+
enabled: liveTailEnabled && isLiveReloading && isComplete,
882+
intervalMs: INSPECTOR_REFRESH_MS,
883+
});
884+
// Frozen while the panel is closed, so closing doesn't reload it and reopening catches up.
885+
const displayRefreshKey = useFrozenValue(selectedSpanId ? inspectorRefreshKey : undefined);
886+
869887
const spanOverrides = selectedSpanId ? overridesBySpanId?.[selectedSpanId] : undefined;
870888
const frozenSpanOverrides = useFrozenValue(spanOverrides);
871889
const displaySpanOverrides = selectedSpanId ? spanOverrides : frozenSpanOverrides;
@@ -961,6 +979,7 @@ function TraceView({
961979
spanOverrides={displaySpanOverrides as SpanOverride | undefined}
962980
closePanel={() => replaceSearchParam("span")}
963981
linkedRunId={displayLinkedRunId}
982+
refreshKey={displayRefreshKey ?? undefined}
964983
/>
965984
)}
966985
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useEffect, useRef, useState } from "react";
2+
3+
type ThrottleState = {
4+
lastBumpAt: number;
5+
trailing: ReturnType<typeof setTimeout> | null;
6+
hiddenChange: boolean;
7+
};
8+
9+
const isHidden = () => document.visibilityState === "hidden";
10+
11+
function requestBump(state: ThrottleState, intervalMs: number, bump: () => void) {
12+
if (isHidden()) {
13+
state.hiddenChange = true;
14+
return;
15+
}
16+
const wait = state.lastBumpAt + intervalMs - Date.now();
17+
if (wait <= 0) {
18+
state.lastBumpAt = Date.now();
19+
bump();
20+
return;
21+
}
22+
if (state.trailing !== null) return;
23+
state.trailing = setTimeout(() => {
24+
state.trailing = null;
25+
requestBump(state, intervalMs, bump);
26+
}, wait);
27+
}
28+
29+
/**
30+
* Returns a key that bumps when `signal` changes, at most once per `intervalMs` with one
31+
* trailing bump. Changes while the tab is hidden bump once when it's visible again.
32+
*/
33+
export function useThrottledRefresh(
34+
signal: unknown,
35+
{ enabled, intervalMs }: { enabled: boolean; intervalMs: number }
36+
): number {
37+
const [refreshKey, setRefreshKey] = useState(0);
38+
const stateRef = useRef<ThrottleState>({ lastBumpAt: 0, trailing: null, hiddenChange: false });
39+
const prevSignalRef = useRef(signal);
40+
41+
useEffect(() => {
42+
const changed = prevSignalRef.current !== signal;
43+
prevSignalRef.current = signal;
44+
if (!enabled || !changed) return;
45+
requestBump(stateRef.current, intervalMs, () => setRefreshKey((key) => key + 1));
46+
}, [signal, enabled, intervalMs]);
47+
48+
useEffect(() => {
49+
if (!enabled) return;
50+
const state = stateRef.current;
51+
const onVisibilityChange = () => {
52+
if (isHidden() || !state.hiddenChange) return;
53+
state.hiddenChange = false;
54+
requestBump(state, intervalMs, () => setRefreshKey((key) => key + 1));
55+
};
56+
document.addEventListener("visibilitychange", onVisibilityChange);
57+
return () => {
58+
document.removeEventListener("visibilitychange", onVisibilityChange);
59+
if (state.trailing !== null) clearTimeout(state.trailing);
60+
state.trailing = null;
61+
state.hiddenChange = false;
62+
};
63+
}, [enabled, intervalMs]);
64+
65+
return refreshKey;
66+
}

‎apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,15 @@ export function SpanView({
237237
spanOverrides,
238238
closePanel,
239239
linkedRunId,
240+
refreshKey,
240241
}: {
241242
runParam: string;
242243
spanId: string | undefined;
243244
spanOverrides?: SpanOverride;
244245
closePanel?: () => void;
245246
linkedRunId?: string;
247+
/** Changing it reloads the span. */
248+
refreshKey?: number;
246249
}) {
247250
const organization = useOrganization();
248251
const project = useProject();
@@ -256,7 +259,16 @@ export function SpanView({
256259
environment.slug
257260
}/runs/${runParam}/spans/${spanId}${linkedRunId ? `?linkedRunId=${linkedRunId}` : ""}`;
258261
load(url);
259-
}, [organization.slug, project.slug, environment.slug, runParam, spanId, linkedRunId, load]);
262+
}, [
263+
organization.slug,
264+
project.slug,
265+
environment.slug,
266+
runParam,
267+
spanId,
268+
linkedRunId,
269+
load,
270+
refreshKey,
271+
]);
260272

261273
if (spanId === undefined) {
262274
return null;

‎apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.status.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.
77
import { runStore } from "~/v3/runStore.server";
88
import { isFinalRunStatus } from "~/v3/taskStatus";
99

10-
// Run terminal-state probe polled by the trace viewer's live tail backstop.
11-
export type RunStatusData = { isFinished: boolean; completedAt: string | null };
10+
// Run start/finish probe polled by the trace viewer's live tail backstop.
11+
export type RunStatusData = {
12+
isFinished: boolean;
13+
startedAt: string | null;
14+
completedAt: string | null;
15+
};
1216

1317
export async function loader({ request, params }: LoaderFunctionArgs) {
1418
const userId = await requireUserId(request);
@@ -17,7 +21,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
1721
const run = await runStore.findRun(
1822
{ friendlyId: runParam },
1923
{
20-
select: { status: true, completedAt: true, projectId: true, runtimeEnvironmentId: true },
24+
select: {
25+
status: true,
26+
startedAt: true,
27+
completedAt: true,
28+
projectId: true,
29+
runtimeEnvironmentId: true,
30+
},
2131
}
2232
);
2333

@@ -47,6 +57,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
4757

4858
return json<RunStatusData>({
4959
isFinished: isFinalRunStatus(run.status),
60+
startedAt: run.startedAt ? run.startedAt.toISOString() : null,
5061
completedAt: run.completedAt ? run.completedAt.toISOString() : null,
5162
});
5263
}

‎apps/webapp/test/useRunStatusBackstop.dom.test.ts‎

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client";
66
import { act } from "react-dom/test-utils";
77
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
88
import { useRunStatusBackstop } from "~/hooks/useRunStatusBackstop";
9+
import type { RunStatusData } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.status";
910

1011
type Reply = { status: number; body?: unknown };
1112

@@ -49,13 +50,13 @@ function poll() {
4950
}
5051

5152
function render(props: { enabled?: boolean; skip?: () => boolean }) {
52-
let finished = 0;
53+
const seen: RunStatusData[] = [];
5354
function Harness({ enabled }: { enabled: boolean }) {
5455
useRunStatusBackstop({
5556
enabled,
5657
statusPath: `${baseUrl}/status`,
5758
shouldSkip: props.skip ?? (() => false),
58-
onFinished: () => finished++,
59+
onStatus: (data) => seen.push(data),
5960
pollMs: POLL_MS,
6061
});
6162
return null;
@@ -65,56 +66,60 @@ function render(props: { enabled?: boolean; skip?: () => boolean }) {
6566
root = createRoot(container);
6667
act(() => root!.render(createElement(Harness, { enabled: props.enabled ?? true })));
6768
return {
68-
finished: () => finished,
69+
seen: () => seen,
70+
count: () => seen.length,
6971
disable: () => act(() => root!.render(createElement(Harness, { enabled: false }))),
7072
};
7173
}
7274

7375
describe("useRunStatusBackstop", () => {
74-
it("calls onFinished on each poll that sees the run finished until disabled", async () => {
76+
it("passes each poll's data to onStatus until disabled", async () => {
7577
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
78+
const queued = { isFinished: false, startedAt: null, completedAt: null };
79+
const started = { isFinished: false, startedAt: "2026-10-06T10:00:00.000Z", completedAt: null };
7680
replies = [
77-
{ status: 200, body: { isFinished: false, completedAt: null } },
78-
{ status: 200, body: { isFinished: true, completedAt: null } },
81+
{ status: 200, body: queued },
82+
{ status: 200, body: started },
7983
];
8084
const harness = render({});
8185
poll();
82-
await expect.poll(() => requests, ASSERTION_POLL_OPTIONS).toBe(1);
83-
expect(harness.finished()).toBe(0);
86+
await expect.poll(harness.count, ASSERTION_POLL_OPTIONS).toBe(1);
87+
expect(harness.seen()[0]).toEqual(queued);
8488
poll();
85-
await expect.poll(harness.finished, ASSERTION_POLL_OPTIONS).toBe(1);
86-
poll();
87-
await expect.poll(harness.finished, ASSERTION_POLL_OPTIONS).toBe(2);
89+
await expect.poll(harness.count, ASSERTION_POLL_OPTIONS).toBe(2);
90+
expect(harness.seen()[1]).toEqual(started);
8891

8992
harness.disable();
90-
const finishedAtDisable = harness.finished();
93+
const seenAtDisable = harness.count();
9194
const requestsAtDisable = requests;
9295
act(() => vi.advanceTimersByTime(POLL_MS * 5));
9396
await act(() => sleep(100));
94-
expect(harness.finished()).toBe(finishedAtDisable);
97+
expect(harness.count()).toBe(seenAtDisable);
9598
expect(requests).toBe(requestsAtDisable);
9699
});
97100

98-
it("ignores failed polls and keeps polling", async () => {
99-
replies = [
100-
{ status: 404 },
101-
{ status: 500 },
102-
{ status: 200, body: { isFinished: false, completedAt: "2026-10-06T10:00:00.000Z" } },
103-
];
101+
it("ignores failed and unparseable polls and keeps polling", async () => {
102+
const finished = {
103+
isFinished: true,
104+
startedAt: "2026-10-06T10:00:00.000Z",
105+
completedAt: "2026-10-06T10:01:00.000Z",
106+
};
107+
replies = [{ status: 404 }, { status: 500 }, { status: 200 }, { status: 200, body: finished }];
104108
const harness = render({});
105-
await expect.poll(harness.finished).toBeGreaterThanOrEqual(1);
109+
await expect.poll(harness.count).toBeGreaterThanOrEqual(1);
110+
expect(harness.seen().every((data) => data.isFinished)).toBe(true);
106111
});
107112

108113
it("does not poll while shouldSkip is true", async () => {
109-
replies = [{ status: 200, body: { isFinished: true, completedAt: null } }];
114+
replies = [{ status: 200, body: { isFinished: true, startedAt: null, completedAt: null } }];
110115
const harness = render({ skip: () => true });
111116
await act(() => sleep(150));
112117
expect(requests).toBe(0);
113-
expect(harness.finished()).toBe(0);
118+
expect(harness.count()).toBe(0);
114119
});
115120

116121
it("does not poll when disabled", async () => {
117-
replies = [{ status: 200, body: { isFinished: true, completedAt: null } }];
122+
replies = [{ status: 200, body: { isFinished: true, startedAt: null, completedAt: null } }];
118123
render({ enabled: false });
119124
await act(() => sleep(100));
120125
expect(requests).toBe(0);

0 commit comments

Comments
 (0)