Skip to content

Commit 6be6c18

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
feat(webapp): progressive loading and live tail for child run pages
Child run pages now read only the child's part of the trace, and load progressively with a live tail when progressive trace loading is enabled. Mono-RevId: b4ad15459382223eeec11b5445b0454e5fc5c865
1 parent bdb2f35 commit 6be6c18

12 files changed

Lines changed: 800 additions & 111 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Large run traces now load in chunks and update live without reloading the page, on child run pages as well as root runs.

‎apps/webapp/app/presenters/v3/RunPresenter.server.ts‎

Lines changed: 83 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { millisecondsToNanoseconds, RunAnnotations } from "@trigger.dev/core/v3";
22
import { prisma, type PrismaClient } from "~/db.server";
33
import { logger } from "~/services/logger.server";
4-
import { stripAdminOnlyEventRows } from "~/utils/timelineSpanEvents";
54
import { getUsername } from "~/utils/username";
65
import type { SpanSummary } from "~/v3/eventRepository/eventRepository.types";
76
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
@@ -10,8 +9,8 @@ import { env } from "~/env.server";
109
import { FEATURE_FLAG } from "~/v3/featureFlags";
1110
import { makeFlag } from "~/v3/featureFlags.server";
1211
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
13-
import { canLiveTail, hasWriteTimes } from "./liveTailGate";
14-
import { TraceChunkAssembler } from "~/v3/eventRepository/traceChunkAssembler";
12+
import { canLiveTail } from "./liveTailGate";
13+
import { assembleFirstTraceChunk, type TraceReadScope } from "./traceFirstChunk.server";
1514
import { applyAncestorOverrides, buildTraceView } from "~/v3/eventRepository/traceViewBuilder";
1615
import { runStore } from "~/v3/runStore.server";
1716
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
@@ -104,14 +103,12 @@ export class RunPresenter {
104103
select: {
105104
friendlyId: true,
106105
spanId: true,
107-
createdAt: true,
108106
},
109107
},
110108
parentTaskRun: {
111109
select: {
112110
friendlyId: true,
113111
spanId: true,
114-
createdAt: true,
115112
},
116113
},
117114
runtimeEnvironmentId: true,
@@ -194,8 +191,9 @@ export class RunPresenter {
194191
environment.organizationId
195192
);
196193

194+
// A child view only reads its own lifetime, not the root's.
197195
const traceTimeBounds = {
198-
startCreatedAt: run.rootTaskRun?.createdAt ?? run.createdAt,
196+
startCreatedAt: run.createdAt,
199197
endCreatedAt: run.completedAt ?? undefined,
200198
};
201199

@@ -207,8 +205,6 @@ export class RunPresenter {
207205
? millisecondsToNanoseconds(run.startedAt.getTime() - triggeredAt.getTime())
208206
: undefined;
209207

210-
const isRootRunView = !run.rootTaskRun || run.rootTaskRun.spanId === run.spanId;
211-
212208
const orgFeatureFlags =
213209
(authorizedProject.organization?.featureFlags as Record<string, unknown>) ?? {};
214210

@@ -222,9 +218,7 @@ export class RunPresenter {
222218
});
223219

224220
const firstChunkPromise = progressivePromise.then((progressiveEnabled) =>
225-
isRootRunView &&
226-
progressiveEnabled &&
227-
canLiveTail(run.taskEventStore, env.TRACE_VIEW_EMERGENCY_SPAN_CAP)
221+
progressiveEnabled && canLiveTail(run.taskEventStore, env.TRACE_VIEW_EMERGENCY_SPAN_CAP)
228222
? repository.getTraceChunk(
229223
getTaskEventStoreTableForRun(run),
230224
environment.id,
@@ -252,98 +246,106 @@ export class RunPresenter {
252246
isAdmin: user?.admin ?? false,
253247
};
254248

255-
if (firstChunk && hasWriteTimes(firstChunk.events)) {
256-
const firstEvents = stripAdminOnlyEventRows(firstChunk.events, buildOptions.isAdmin);
249+
const traceScope: TraceReadScope = {
250+
storeTable: getTaskEventStoreTableForRun(run),
251+
environmentId: environment.id,
252+
traceId: run.traceId,
253+
startCreatedAt: traceTimeBounds.startCreatedAt,
254+
endCreatedAt: traceTimeBounds.endCreatedAt,
255+
};
257256

258-
const assembler = new TraceChunkAssembler();
259-
assembler.mergeChunk(firstEvents);
257+
const first = firstChunk
258+
? await assembleFirstTraceChunk({
259+
repository,
260+
scope: traceScope,
261+
firstChunk,
262+
anchorSpanId: run.spanId,
263+
selectedSpanId,
264+
showDebug,
265+
isAdmin: buildOptions.isAdmin,
266+
})
267+
: undefined;
260268

261-
if (assembler.hasSpan(run.spanId)) {
262-
let supplementaryFirstEvents: typeof firstChunk.events | undefined;
269+
if (first?.kind === "progressive") {
270+
const { spans, overridesBySpanId } = applyAncestorOverrides(first.assembler.spans);
271+
const view = buildTraceView(spans, buildOptions);
263272

264-
if (selectedSpanId && selectedSpanId !== run.spanId && !assembler.hasSpan(selectedSpanId)) {
265-
const selectedEvents = await repository.getTraceSpanWithAncestors(
266-
getTaskEventStoreTableForRun(run),
267-
environment.id,
268-
run.traceId,
269-
traceTimeBounds.startCreatedAt,
270-
traceTimeBounds.endCreatedAt,
271-
selectedSpanId,
272-
{ includeDebugLogs: showDebug }
273-
);
274-
if (selectedEvents && selectedEvents.length > 0) {
275-
const visibleSelected = stripAdminOnlyEventRows(selectedEvents, buildOptions.isAdmin);
276-
assembler.mergeChunk(visibleSelected, { source: "deeplink" });
277-
supplementaryFirstEvents = visibleSelected;
278-
}
279-
}
280-
281-
const { spans, overridesBySpanId } = applyAncestorOverrides(assembler.spans);
282-
const view = buildTraceView(spans, buildOptions);
283-
284-
return {
285-
run: runData,
286-
trace: {
287-
events: view.events,
288-
duration: view.duration,
289-
rootStartedAt: view.rootStartedAt,
290-
rootSpanStatus: view.rootSpanStatus,
291-
startedAt: run.startedAt,
292-
queuedDuration,
293-
overridesBySpanId,
294-
linkedRunIdBySpanId: view.linkedRunIdBySpanId,
295-
isTruncated: false,
296-
missingAnchor: false,
297-
progressive: {
298-
firstEvents,
299-
supplementaryFirstEvents,
300-
nextCursor: firstChunk.nextCursor,
301-
hasMore: firstChunk.hasMore,
302-
buildOptions,
303-
showDebug,
304-
maxSpans: repository.maximumTraceViewCount,
305-
liveTailEnabled: true,
306-
firstChunkReadAt,
307-
},
273+
return {
274+
run: runData,
275+
trace: {
276+
events: view.events,
277+
duration: view.duration,
278+
rootStartedAt: view.rootStartedAt,
279+
rootSpanStatus: view.rootSpanStatus,
280+
startedAt: run.startedAt,
281+
queuedDuration,
282+
overridesBySpanId,
283+
linkedRunIdBySpanId: view.linkedRunIdBySpanId,
284+
isTruncated: false,
285+
missingAnchor: false,
286+
progressive: {
287+
firstEvents: first.firstEvents,
288+
supplementaryFirstEvents: first.supplementaryFirstEvents,
289+
nextCursor: first.nextCursor,
290+
hasMore: first.hasMore,
291+
buildOptions,
292+
showDebug,
293+
maxSpans: repository.maximumTraceViewCount,
294+
liveTailEnabled: true,
295+
firstChunkReadAt,
308296
},
309-
maximumLiveReloadingSetting: repository.maximumLiveReloadingSetting,
310-
};
311-
}
297+
},
298+
maximumLiveReloadingSetting: repository.maximumLiveReloadingSetting,
299+
};
312300
}
313301

314-
// Fast path: full trace summary. Slow path: subtree fetch when the anchor
315-
// span fell past the row cap (large traces ordered by start_time ASC).
316-
let traceSummary = await repository.getTraceSummary(
317-
getTaskEventStoreTableForRun(run),
318-
environment.id,
319-
run.traceId,
320-
traceTimeBounds.startCreatedAt,
321-
traceTimeBounds.endCreatedAt,
322-
{ includeDebugLogs: showDebug }
323-
);
324-
325-
let isTruncated = traceSummary?.isTruncated ?? false;
326-
const hasAnchorSpan = traceSummary?.spans.some((span) => span.id === run.spanId) ?? false;
302+
// An empty first chunk, or an anchor the span-id lookup didn't find, means the
303+
// summary and subtree reads would search the same window for nothing.
304+
const anchorMissing = first?.kind === "fallback" && first.reason === "anchorMissing";
305+
const skipSummary = first?.kind === "empty" || anchorMissing;
327306

328-
if (traceSummary && !hasAnchorSpan) {
329-
logger.warn("Trace summary missing anchor span, falling back to subtree fetch", {
307+
if (anchorMissing) {
308+
logger.warn("Trace first chunk missing anchor span", {
330309
runId: run.friendlyId,
331310
spanId: run.spanId,
332311
traceId: run.traceId,
333-
spanCount: traceSummary.spans.length,
334312
});
313+
}
314+
315+
// Fast path: summary rooted at the viewed run. Slow path: subtree fetch when the
316+
// anchor fell past the row cap (large traces ordered by start_time ASC).
317+
let traceSummary = skipSummary
318+
? undefined
319+
: await repository.getTraceSummary(
320+
getTaskEventStoreTableForRun(run),
321+
environment.id,
322+
run.traceId,
323+
traceTimeBounds.startCreatedAt,
324+
traceTimeBounds.endCreatedAt,
325+
{ includeDebugLogs: showDebug, anchorSpanId: run.spanId }
326+
);
327+
328+
let isTruncated = traceSummary?.isTruncated ?? false;
335329

330+
if (!traceSummary && !skipSummary) {
336331
const subtreeSummary = await repository.getTraceSubtreeSummary(
337332
getTaskEventStoreTableForRun(run),
338333
environment.id,
339334
run.traceId,
340335
run.spanId,
341336
traceTimeBounds.startCreatedAt,
342337
traceTimeBounds.endCreatedAt,
343-
{ includeDebugLogs: showDebug }
338+
// The anchor is the viewed run: a root has no ancestors, a child doesn't need them.
339+
{ includeDebugLogs: showDebug, includeAncestors: false }
344340
);
345341

346342
if (subtreeSummary) {
343+
// Only an anchor past the row cap lands here; an empty trace finds nothing.
344+
logger.warn("Trace summary missing anchor span, used subtree fetch", {
345+
runId: run.friendlyId,
346+
spanId: run.spanId,
347+
traceId: run.traceId,
348+
});
347349
traceSummary = subtreeSummary;
348350
isTruncated = subtreeSummary.isTruncated ?? false;
349351
}

‎apps/webapp/app/presenters/v3/TraceChunkPresenter.server.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,6 @@ export class TraceChunkPresenter {
5050
completedAt: true,
5151
logsDeletedAt: true,
5252
runtimeEnvironmentId: true,
53-
rootTaskRun: {
54-
select: {
55-
createdAt: true,
56-
},
57-
},
5853
},
5954
}
6055
);
@@ -94,7 +89,8 @@ export class TraceChunkPresenter {
9489
);
9590

9691
const storeTable = getTaskEventStoreTableForRun(run);
97-
const startCreatedAt = run.rootTaskRun?.createdAt ?? run.createdAt;
92+
// A child view only reads its own lifetime, not the root's.
93+
const startCreatedAt = run.createdAt;
9894
const endCreatedAt = run.completedAt ?? undefined;
9995

10096
if (filter === "errors") {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { stripAdminOnlyEventRows } from "~/utils/timelineSpanEvents";
2+
import type {
3+
IEventRepository,
4+
TraceChunk,
5+
TraceChunkCursor,
6+
TraceChunkEvent,
7+
} from "~/v3/eventRepository/eventRepository.types";
8+
import { TraceChunkAssembler } from "~/v3/eventRepository/traceChunkAssembler";
9+
import type { TaskEventStoreTable } from "~/v3/taskEventStore.server";
10+
import { hasWriteTimes } from "./liveTailGate";
11+
12+
export type TraceReadScope = {
13+
storeTable: TaskEventStoreTable;
14+
environmentId: string;
15+
traceId: string;
16+
startCreatedAt: Date;
17+
endCreatedAt: Date | undefined;
18+
};
19+
20+
export type FirstTraceChunk =
21+
| {
22+
kind: "progressive";
23+
assembler: TraceChunkAssembler;
24+
firstEvents: TraceChunkEvent[];
25+
supplementaryFirstEvents: TraceChunkEvent[] | undefined;
26+
nextCursor: TraceChunkCursor | null;
27+
hasMore: boolean;
28+
}
29+
| { kind: "empty" }
30+
| { kind: "fallback"; reason: "noWriteTimes" | "anchorMissing" };
31+
32+
export async function assembleFirstTraceChunk({
33+
repository,
34+
scope,
35+
firstChunk,
36+
anchorSpanId,
37+
selectedSpanId,
38+
showDebug,
39+
isAdmin,
40+
}: {
41+
repository: Pick<IEventRepository, "getTraceSpanWithAncestors">;
42+
scope: TraceReadScope;
43+
firstChunk: TraceChunk;
44+
anchorSpanId: string;
45+
selectedSpanId: string | undefined;
46+
showDebug: boolean;
47+
isAdmin: boolean;
48+
}): Promise<FirstTraceChunk> {
49+
if (firstChunk.events.length === 0) {
50+
return { kind: "empty" };
51+
}
52+
53+
if (!hasWriteTimes(firstChunk.events)) {
54+
return { kind: "fallback", reason: "noWriteTimes" };
55+
}
56+
57+
const firstEvents = stripAdminOnlyEventRows(firstChunk.events, isAdmin);
58+
const assembler = new TraceChunkAssembler();
59+
assembler.mergeChunk(firstEvents);
60+
61+
// Each walk returns every row of each span it visits, and both walks can visit the
62+
// same ancestors, so take a span's rows from the first walk that returns it.
63+
const supplementary: TraceChunkEvent[] = [];
64+
const supplementarySpanIds = new Set<string>();
65+
const hasSpan = (spanId: string) => assembler.hasSpan(spanId) || supplementarySpanIds.has(spanId);
66+
67+
const fetchMissingSpan = async (spanId: string) => {
68+
const events = await repository.getTraceSpanWithAncestors(
69+
scope.storeTable,
70+
scope.environmentId,
71+
scope.traceId,
72+
scope.startCreatedAt,
73+
scope.endCreatedAt,
74+
spanId,
75+
{ includeDebugLogs: showDebug }
76+
);
77+
const visible = stripAdminOnlyEventRows(events ?? [], isAdmin).filter(
78+
(event) => !supplementarySpanIds.has(event.spanId)
79+
);
80+
supplementary.push(...visible);
81+
for (const event of visible) {
82+
supplementarySpanIds.add(event.spanId);
83+
}
84+
};
85+
86+
// The selected span's walk usually reaches the viewed run's span too, so look it up
87+
// first. Earlier siblings can fill the first chunk before either span.
88+
if (selectedSpanId && selectedSpanId !== anchorSpanId && !hasSpan(selectedSpanId)) {
89+
await fetchMissingSpan(selectedSpanId);
90+
}
91+
if (!hasSpan(anchorSpanId)) {
92+
await fetchMissingSpan(anchorSpanId);
93+
}
94+
95+
if (supplementary.length > 0) {
96+
assembler.mergeChunk(supplementary, { source: "deeplink" });
97+
}
98+
99+
if (!assembler.hasSpan(anchorSpanId)) {
100+
return { kind: "fallback", reason: "anchorMissing" };
101+
}
102+
103+
return {
104+
kind: "progressive",
105+
assembler,
106+
firstEvents,
107+
supplementaryFirstEvents: supplementary.length > 0 ? supplementary : undefined,
108+
nextCursor: firstChunk.nextCursor,
109+
hasMore: firstChunk.hasMore,
110+
};
111+
}

0 commit comments

Comments
 (0)