Skip to content

Commit 3eff04a

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
feat(webapp): add the session trace tree model and run load queue
Groundwork for showing all of a session's runs as one trace tree. Adds the tree builder, per-run timing helpers and a 3-at-a-time run load queue, plus a `live` option on the run trace hook. Nothing renders it yet. Mono-RevId: 5cae40c1b02fdd64fdada319c25da08111851f64
1 parent 31d39da commit 3eff04a

8 files changed

Lines changed: 966 additions & 18 deletions

File tree

‎apps/webapp/app/components/runs/v3/trace/traceStatus.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export type TraceStatusInput = {
1818
missingAnchor: boolean;
1919
loadFailed: boolean;
2020
isLiveReloading: boolean;
21+
/** False when this view doesn't follow the run live, e.g. a session's older runs. */
22+
live: boolean;
2123
liveTailEnabled: boolean;
2224
maximumLiveReloadingSetting: number;
2325
};
@@ -26,7 +28,7 @@ export type TraceStatusInput = {
2628
export function getTraceStatus(input: TraceStatusInput): TraceStatus {
2729
// The run row and the tree can disagree for a moment; either one saying "done" wins.
2830
const finished = input.runFinished || input.rootSpanStatus !== "executing";
29-
const liveStopped = !finished && !input.isLiveReloading;
31+
const liveStopped = input.live && !finished && !input.isLiveReloading;
3032

3133
if (input.loadFailed && !input.isTruncated) {
3234
const paused = liveStopped ? " Live updates are paused until it loads." : "";
@@ -50,6 +52,9 @@ export function getTraceStatus(input: TraceStatusInput): TraceStatus {
5052
}
5153
if (finished) return { kind: "allLoaded" };
5254
if (input.isLiveReloading) return { kind: "live" };
55+
if (!input.live) {
56+
return { kind: "liveOff", reason: "Live updates only follow the latest run." };
57+
}
5358

5459
if (!input.liveTailEnabled) {
5560
return {

‎apps/webapp/app/components/runs/v3/trace/useRunTrace.ts‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function useRunTrace({
7272
maximumLiveReloadingSetting,
7373
onRunDataStale,
7474
readContext,
75+
live = true,
7576
}: {
7677
run: RunTraceRun;
7778
trace: RunTraceInput;
@@ -80,6 +81,8 @@ export function useRunTrace({
8081
onRunDataStale: () => void;
8182
/** Sent with every chunk read; "session" bounds completed runs' reads. */
8283
readContext?: "session";
84+
/** False turns off the stream, tail and status polling, e.g. for a session's older runs. */
85+
live?: boolean;
8386
}) {
8487
const organization = useOrganization();
8588
const project = useProject();
@@ -125,13 +128,15 @@ export function useRunTrace({
125128

126129
// Tail path (flag on): delta-fetch per signal, skipped for truncated or partly
127130
// failed trees. Legacy path: revalidate per signal, with the old size cap.
128-
const isLiveReloading = liveTailEnabled
129-
? shouldLiveReload(run, rootSpanStatus) && !traceIsTruncated && !loadFailed
130-
: shouldLiveReloadLegacy({
131-
eventCount: events.length,
132-
maximumLiveReloadingSetting,
133-
run,
134-
});
131+
const isLiveReloading =
132+
live &&
133+
(liveTailEnabled
134+
? shouldLiveReload(run, rootSpanStatus) && !traceIsTruncated && !loadFailed
135+
: shouldLiveReloadLegacy({
136+
eventCount: events.length,
137+
maximumLiveReloadingSetting,
138+
run,
139+
}));
135140

136141
const traceStatus = getTraceStatus({
137142
// Same rule as live reload: only a finished time means the run is done.
@@ -142,6 +147,7 @@ export function useRunTrace({
142147
missingAnchor,
143148
loadFailed,
144149
isLiveReloading,
150+
live,
145151
liveTailEnabled,
146152
maximumLiveReloadingSetting,
147153
});
@@ -177,17 +183,17 @@ export function useRunTrace({
177183
useEffect(() => {
178184
const previous = prevRootSpanRef.current;
179185
prevRootSpanRef.current = { runId: run.friendlyId, status: rootSpanStatus };
180-
if (!liveTailEnabled || previous.runId !== run.friendlyId) return;
186+
if (!live || !liveTailEnabled || previous.runId !== run.friendlyId) return;
181187
if (previous.status === "executing" && rootSpanStatus !== "executing" && runDataUnfinished) {
182188
onRunDataStaleRef.current();
183189
}
184-
}, [run.friendlyId, rootSpanStatus, liveTailEnabled, runDataUnfinished]);
190+
}, [run.friendlyId, rootSpanStatus, live, liveTailEnabled, runDataUnfinished]);
185191

186192
// Tail path backstop: reload when the run starts and when it finishes, even if the
187193
// tail missed the root's final row. Polls whatever the stream is doing, and keeps
188194
// reloading until the page data agrees (a reload can read a lagging replica).
189195
useRunStatusBackstop({
190-
enabled: liveTailEnabled && runDataUnfinished,
196+
enabled: live && liveTailEnabled && runDataUnfinished,
191197
statusPath,
192198
shouldSkip: () => document.visibilityState === "hidden",
193199
onStatus: (data) => {
@@ -205,19 +211,20 @@ export function useRunTrace({
205211
pollMs: RUN_STATUS_BACKSTOP_POLL_MS,
206212
});
207213

208-
// SSE is off during the background load, so pick up rows written meanwhile. Runs
209-
// even if live reload already stopped, as long as the run finished recently.
214+
// SSE is off during the background load (and while not live), so pick up rows written
215+
// meanwhile. Runs even if live reload already stopped, as long as the run finished recently.
210216
const sinceFinished = msSinceFinished(run);
211217
const recentlyActive = sinceFinished === null || sinceFinished < LIVE_TAIL_UNRESOLVED_ROOT_MS;
212-
const prevIsCompleteRef = useRef(isComplete);
218+
const catchUpReady = live && isComplete;
219+
const prevCatchUpReadyRef = useRef(catchUpReady);
213220
useEffect(() => {
214-
const wasComplete = prevIsCompleteRef.current;
215-
prevIsCompleteRef.current = isComplete;
216-
if (!liveTailEnabled || wasComplete || !isComplete) return;
221+
const wasReady = prevCatchUpReadyRef.current;
222+
prevCatchUpReadyRef.current = catchUpReady;
223+
if (!liveTailEnabled || wasReady || !catchUpReady) return;
217224
if (recentlyActive && !traceIsTruncated && !loadFailed) {
218225
tailLive();
219226
}
220-
}, [isComplete, recentlyActive, traceIsTruncated, loadFailed, liveTailEnabled, tailLive]);
227+
}, [catchUpReady, recentlyActive, traceIsTruncated, loadFailed, liveTailEnabled, tailLive]);
221228

222229
// Tail path: the inspector loads separately, so reload it when the tree changes. After
223230
// the background load, rebuilds come from the tail or a revalidate merge.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import type { FlatTree, FlatTreeItem } from "~/components/primitives/TreeView/TreeView";
2+
import type { SessionTraceRun, SessionTraceSpan } from "./sessionTraceTypes";
3+
4+
/** Quiet periods longer than this between two runs fold into one row. */
5+
export const GAP_FOLD_MS = 10 * 60_000;
6+
7+
export type SessionTreeRow<TSpanData> =
8+
| { kind: "session"; runCount: number }
9+
| { kind: "loadEarlier"; count: number }
10+
| { kind: "notLoaded"; count: number; olderRunFriendlyId: string; newerRunFriendlyId: string }
11+
| { kind: "gap"; durationMs: number }
12+
| { kind: "run"; run: SessionTraceRun; spansLoaded: boolean }
13+
| { kind: "span"; runFriendlyId: string; spanId: string; span: TSpanData };
14+
15+
type SessionTreeNode<TSpanData> = FlatTreeItem<SessionTreeRow<TSpanData>>;
16+
17+
const SESSION_NODE_ID = "session";
18+
const LOAD_EARLIER_NODE_ID = "loadEarlier";
19+
20+
export function runNodeId(runFriendlyId: string) {
21+
return `run:${runFriendlyId}`;
22+
}
23+
24+
export function spanNodeId(runFriendlyId: string, spanId: string) {
25+
return `${runFriendlyId}:${spanId}`;
26+
}
27+
28+
/** Rows that only label the list: arrow keys skip them and they can't be selected. */
29+
export function isSelectableRow(row: SessionTreeRow<unknown>) {
30+
return row.kind === "session" || row.kind === "run" || row.kind === "span";
31+
}
32+
33+
/**
34+
* One flat list for the whole session: the session row, then runs oldest first, each followed
35+
* by its loaded spans. Runs can come from several pages; duplicates are merged by friendlyId,
36+
* the later copy winning, and missing run numbers between pages become a "not loaded" row.
37+
*/
38+
export function buildSessionTree<TSpanData>({
39+
runs,
40+
totalCount,
41+
spansByRun,
42+
}: {
43+
runs: SessionTraceRun[];
44+
totalCount: number;
45+
/** Loaded events per run friendlyId, in trace order (parents before children). */
46+
spansByRun: ReadonlyMap<string, SessionTraceSpan<TSpanData>[]>;
47+
}): FlatTree<SessionTreeRow<TSpanData>> {
48+
const session: SessionTreeNode<TSpanData> = {
49+
id: SESSION_NODE_ID,
50+
children: [],
51+
hasChildren: false,
52+
level: 0,
53+
data: { kind: "session", runCount: totalCount },
54+
};
55+
const tree: SessionTreeNode<TSpanData>[] = [session];
56+
57+
const addSessionChild = (id: string, data: SessionTreeRow<TSpanData>) => {
58+
session.children.push(id);
59+
const node: SessionTreeNode<TSpanData> = {
60+
id,
61+
parentId: SESSION_NODE_ID,
62+
children: [],
63+
hasChildren: false,
64+
level: 1,
65+
data,
66+
};
67+
tree.push(node);
68+
return node;
69+
};
70+
71+
const ordered = uniqueRunsOldestFirst(runs);
72+
const oldest = ordered[0];
73+
if (oldest && oldest.number > 1) {
74+
addSessionChild(LOAD_EARLIER_NODE_ID, { kind: "loadEarlier", count: oldest.number - 1 });
75+
}
76+
77+
let previous: SessionTraceRun | undefined;
78+
for (const run of ordered) {
79+
if (previous) {
80+
const missing = run.number - previous.number - 1;
81+
if (missing > 0) {
82+
addSessionChild(`notLoaded:${run.friendlyId}`, {
83+
kind: "notLoaded",
84+
count: missing,
85+
olderRunFriendlyId: previous.friendlyId,
86+
newerRunFriendlyId: run.friendlyId,
87+
});
88+
} else {
89+
const gapMs = quietGapMs(previous, run);
90+
if (gapMs !== undefined && gapMs > GAP_FOLD_MS) {
91+
addSessionChild(`gap:${run.friendlyId}`, { kind: "gap", durationMs: gapMs });
92+
}
93+
}
94+
}
95+
96+
const spans = spansByRun.get(run.friendlyId);
97+
const runNode = addSessionChild(runNodeId(run.friendlyId), {
98+
kind: "run",
99+
run,
100+
spansLoaded: spans !== undefined,
101+
});
102+
if (spans) graftSpans(tree, runNode, run.friendlyId, spans);
103+
previous = run;
104+
}
105+
106+
session.hasChildren = session.children.length > 0;
107+
return tree;
108+
}
109+
110+
function uniqueRunsOldestFirst(runs: SessionTraceRun[]) {
111+
const byId = new Map<string, SessionTraceRun>();
112+
for (const run of runs) byId.set(run.friendlyId, run);
113+
return [...byId.values()].sort((a, b) => a.number - b.number);
114+
}
115+
116+
function quietGapMs(previous: SessionTraceRun, next: SessionTraceRun) {
117+
if (!previous.finishedAt || !next.createdAt) return undefined;
118+
return new Date(next.createdAt).getTime() - new Date(previous.finishedAt).getTime();
119+
}
120+
121+
// The run's root span is hidden: the run row stands in for it, so its children move up to the run.
122+
function graftSpans<TSpanData>(
123+
tree: SessionTreeNode<TSpanData>[],
124+
runNode: SessionTreeNode<TSpanData>,
125+
runFriendlyId: string,
126+
spans: SessionTraceSpan<TSpanData>[]
127+
) {
128+
const grafted = new Map<string, SessionTreeNode<TSpanData>>();
129+
130+
for (const span of spans) {
131+
if (span.level === 0) continue;
132+
133+
const parent = (span.parentId && grafted.get(span.parentId)) || runNode;
134+
const node: SessionTreeNode<TSpanData> = {
135+
id: spanNodeId(runFriendlyId, span.id),
136+
parentId: parent.id,
137+
children: [],
138+
hasChildren: false,
139+
level: parent.level + 1,
140+
data: { kind: "span", runFriendlyId, spanId: span.id, span: span.data },
141+
};
142+
parent.children.push(node.id);
143+
parent.hasChildren = true;
144+
grafted.set(span.id, node);
145+
tree.push(node);
146+
}
147+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/** At most this many runs fetch their spans at once, first chunk and later pages included. */
2+
export const MAX_CONCURRENT_RUN_LOADS = 3;
3+
4+
type RunTraceUnavailableReason =
5+
| "legacyStore"
6+
| "emergencyCap"
7+
| "chunkUnsupported"
8+
| "noWriteTimes"
9+
| "anchorMissing"
10+
| "noSpans";
11+
12+
type SettledRunLoad =
13+
| { state: "progressive" }
14+
| { state: "empty" }
15+
| { state: "unavailable"; reason: RunTraceUnavailableReason }
16+
| { state: "logsDeleted" }
17+
| { state: "error"; message: string };
18+
19+
export type SessionRunLoad =
20+
| { state: "idle" }
21+
| { state: "queued" }
22+
/** `loadId` identifies this fetch; a settle for an older one is ignored. */
23+
| { state: "loading"; loadId: number }
24+
/** First chunk shown, later pages still loading; keeps its slot. */
25+
| { state: "paging"; loadId: number }
26+
| SettledRunLoad;
27+
28+
/** Where a run's read got to: paging after its first chunk, or how it ended. */
29+
type SessionRunLoadOutcome = { state: "paging" } | SettledRunLoad;
30+
31+
export type SessionRunLoads = {
32+
loads: Readonly<Record<string, SessionRunLoad>>;
33+
/** Queued runs in request order. */
34+
queue: readonly string[];
35+
nextLoadId: number;
36+
};
37+
38+
export type SessionRunLoadsAction =
39+
| { type: "request"; runFriendlyIds: string[] }
40+
| { type: "settle"; runFriendlyId: string; loadId: number; outcome: SessionRunLoadOutcome }
41+
| { type: "release"; runFriendlyIds: string[] };
42+
43+
const IDLE: SessionRunLoad = { state: "idle" };
44+
const REQUESTABLE = new Set<SessionRunLoad["state"]>(["idle", "error", "empty"]);
45+
46+
function isInFlight(load: SessionRunLoad): load is Extract<SessionRunLoad, { loadId: number }> {
47+
return load.state === "loading" || load.state === "paging";
48+
}
49+
50+
export const initialSessionRunLoads: SessionRunLoads = { loads: {}, queue: [], nextLoadId: 1 };
51+
52+
export function runLoad(loads: SessionRunLoads, runFriendlyId: string): SessionRunLoad {
53+
return loads.loads[runFriendlyId] ?? IDLE;
54+
}
55+
56+
/**
57+
* Requested runs queue up and start loading as slots free, oldest request first. A run holds its
58+
* slot until all its pages are in. Only idle, failed and empty runs can be requested. Release
59+
* puts runs back to idle and frees their slot, cancelling a fetch in flight: its late settle no
60+
* longer matches the run's load id.
61+
*/
62+
export function sessionRunLoadsReducer(
63+
current: SessionRunLoads,
64+
action: SessionRunLoadsAction
65+
): SessionRunLoads {
66+
switch (action.type) {
67+
case "request": {
68+
const requested = [...new Set(action.runFriendlyIds)].filter((id) =>
69+
REQUESTABLE.has(runLoad(current, id).state)
70+
);
71+
if (requested.length === 0) return current;
72+
73+
const loads = { ...current.loads };
74+
for (const id of requested) loads[id] = { state: "queued" };
75+
return startQueued({ ...current, loads, queue: [...current.queue, ...requested] });
76+
}
77+
case "settle": {
78+
const load = runLoad(current, action.runFriendlyId);
79+
if (!isInFlight(load) || load.loadId !== action.loadId) return current;
80+
81+
const next: SessionRunLoad =
82+
action.outcome.state === "paging"
83+
? { state: "paging", loadId: load.loadId }
84+
: action.outcome;
85+
return startQueued({
86+
...current,
87+
loads: { ...current.loads, [action.runFriendlyId]: next },
88+
});
89+
}
90+
case "release": {
91+
const released = new Set(action.runFriendlyIds.filter((id) => id in current.loads));
92+
if (released.size === 0) return current;
93+
94+
const loads = { ...current.loads };
95+
for (const id of released) delete loads[id];
96+
return startQueued({
97+
...current,
98+
loads,
99+
queue: current.queue.filter((id) => !released.has(id)),
100+
});
101+
}
102+
}
103+
}
104+
105+
function startQueued(current: SessionRunLoads): SessionRunLoads {
106+
let inFlight = Object.values(current.loads).filter(isInFlight).length;
107+
if (inFlight >= MAX_CONCURRENT_RUN_LOADS || current.queue.length === 0) return current;
108+
109+
const loads = { ...current.loads };
110+
const queue = [...current.queue];
111+
let nextLoadId = current.nextLoadId;
112+
while (inFlight < MAX_CONCURRENT_RUN_LOADS) {
113+
const id = queue.shift();
114+
if (!id) break;
115+
loads[id] = { state: "loading", loadId: nextLoadId++ };
116+
inFlight++;
117+
}
118+
return { loads, queue, nextLoadId };
119+
}

0 commit comments

Comments
 (0)