Skip to content

Commit 07441a2

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
feat(webapp): page a session's runs for the session trace view
Adds a dashboard route that returns a page of a session's runs, newest first, with stable run numbers, for the upcoming session trace view. It's behind an org flag and nothing in the UI calls it yet. Mono-RevId: bd63c7cbf0ecf2e5140cfc5525b29021cdb5757f
1 parent 7432ca5 commit 07441a2

9 files changed

Lines changed: 1240 additions & 8 deletions
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { RunId } from "@trigger.dev/core/v3/isomorphic";
2+
import type { PrismaClientOrTransaction, TaskRunStatus } from "@trigger.dev/database";
3+
import { z } from "zod";
4+
import {
5+
countSessionRuns,
6+
countSessionRunsAtOrBefore,
7+
decodeSessionRunCursor,
8+
encodeSessionRunCursor,
9+
findSessionRunCursor,
10+
findSessionRunsPage,
11+
type SessionRunCursor,
12+
type SessionRunRow,
13+
} from "~/services/realtime/sessionRunPage.server";
14+
import { getRunFinishedAt } from "~/v3/eventRepository/traceInsertedAtBound";
15+
import type { runTraceUnavailableBeforeRead } from "./runTraceSetup.server";
16+
import { startActiveSpan } from "~/v3/tracer.server";
17+
18+
/** Query params, following `GET /api/v1/sessions`. A bad cursor fails the parse (400). */
19+
export const SessionRunsSearchParams = z
20+
.object({
21+
"page[size]": z.coerce.number().int().min(1).max(100).default(20),
22+
"page[after]": z
23+
.string()
24+
.transform((value, ctx) => {
25+
const cursor = decodeSessionRunCursor(value);
26+
if (!cursor) {
27+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid cursor" });
28+
return z.NEVER;
29+
}
30+
return cursor;
31+
})
32+
.optional(),
33+
run: z
34+
.string()
35+
.regex(/^run_[a-z0-9]+$/, "Invalid run ID")
36+
.optional(),
37+
})
38+
.refine((value) => !(value["page[after]"] && value.run), {
39+
message: "Cannot pass both page[after] and run",
40+
path: ["run"],
41+
});
42+
43+
export const sessionRunSelect = {
44+
id: true,
45+
friendlyId: true,
46+
projectId: true,
47+
runtimeEnvironmentId: true,
48+
status: true,
49+
createdAt: true,
50+
startedAt: true,
51+
completedAt: true,
52+
updatedAt: true,
53+
taskEventStore: true,
54+
logsDeletedAt: true,
55+
} as const;
56+
57+
export type SessionRunRecord = {
58+
id: string;
59+
friendlyId: string;
60+
projectId: string;
61+
runtimeEnvironmentId: string;
62+
status: TaskRunStatus;
63+
createdAt: Date;
64+
startedAt: Date | null;
65+
completedAt: Date | null;
66+
updatedAt: Date;
67+
taskEventStore: string;
68+
logsDeletedAt: Date | null;
69+
};
70+
71+
type RunTraceUnavailableReason = NonNullable<ReturnType<typeof runTraceUnavailableBeforeRead>>;
72+
73+
type SessionRunItem = {
74+
number: number;
75+
friendlyId: string;
76+
reason: string;
77+
afterCrash: boolean;
78+
triggeredAt: string;
79+
status: TaskRunStatus | null;
80+
createdAt: string | null;
81+
startedAt: string | null;
82+
completedAt: string | null;
83+
finishedAt: string | null;
84+
};
85+
86+
export type SessionRunsPage = {
87+
data: SessionRunItem[];
88+
pagination: { next?: string };
89+
totalCount: number;
90+
view: Record<string, { unavailable?: RunTraceUnavailableReason }>;
91+
};
92+
93+
export type SessionRunsPresenterDeps = {
94+
replica: PrismaClientOrTransaction;
95+
findRunsByIds: (ids: string[]) => Promise<Map<string, SessionRunRecord>>;
96+
unavailableBeforeRead: typeof runTraceUnavailableBeforeRead;
97+
emergencySpanCap: number | undefined;
98+
v1RerouteEnabled: boolean;
99+
};
100+
101+
export type SessionRunsQuery = {
102+
session: { id: string; projectId: string; runtimeEnvironmentId: string };
103+
pageSize: number;
104+
/** Older runs than this cursor. */
105+
after?: SessionRunCursor;
106+
/** The page ending at this run, for deep links. */
107+
runFriendlyId?: string;
108+
showDeletedLogs: boolean;
109+
};
110+
111+
const CRASH_STATUSES: TaskRunStatus[] = ["CRASHED", "SYSTEM_FAILURE"];
112+
113+
export class SessionRunsPresenter {
114+
constructor(private readonly deps: SessionRunsPresenterDeps) {}
115+
116+
/** Null when `runFriendlyId` isn't a run of this session. */
117+
public call(query: SessionRunsQuery): Promise<SessionRunsPage | null> {
118+
return startActiveSpan("SessionRunsPresenter.call", (span) => {
119+
span.setAttribute("session.id", query.session.id);
120+
span.setAttribute("sessionRuns.pageSize", query.pageSize);
121+
span.setAttribute("sessionRuns.deepLink", query.runFriendlyId !== undefined);
122+
return this.#call(query).then((page) => {
123+
span.setAttribute("sessionRuns.rows", page?.data.length ?? 0);
124+
return page;
125+
});
126+
});
127+
}
128+
129+
async #call(query: SessionRunsQuery): Promise<SessionRunsPage | null> {
130+
const { replica } = this.deps;
131+
const sessionId = query.session.id;
132+
133+
const anchor = query.runFriendlyId
134+
? await findSessionRunCursor(replica, {
135+
sessionId,
136+
runId: RunId.fromFriendlyId(query.runFriendlyId),
137+
})
138+
: undefined;
139+
if (anchor === null) return null;
140+
141+
const [rows, totalCount] = await Promise.all([
142+
findSessionRunsPage(replica, {
143+
sessionId,
144+
before: query.after,
145+
atOrBefore: anchor,
146+
take: query.pageSize + 1,
147+
}),
148+
countSessionRuns(replica, { sessionId }),
149+
]);
150+
151+
const pageRows = rows.slice(0, query.pageSize);
152+
const olderRow = rows.at(query.pageSize);
153+
const oldestRow = pageRows.at(-1);
154+
155+
if (!oldestRow) {
156+
return { data: [], pagination: {}, totalCount, view: {} };
157+
}
158+
159+
const [oldestNumber, runsById] = await Promise.all([
160+
olderRow ? countSessionRunsAtOrBefore(replica, { sessionId, cursor: oldestRow }) : 1,
161+
this.#findSessionRuns(query.session, rows),
162+
]);
163+
164+
const data = pageRows.map((row, index) => {
165+
const predecessor = pageRows[index + 1] ?? olderRow;
166+
return toItem({
167+
row,
168+
number: oldestNumber + (pageRows.length - 1 - index),
169+
run: runsById.get(row.runId),
170+
predecessorRun: predecessor ? runsById.get(predecessor.runId) : undefined,
171+
});
172+
});
173+
174+
return {
175+
data,
176+
pagination: olderRow ? { next: encodeSessionRunCursor(oldestRow) } : {},
177+
totalCount,
178+
view: this.#view(pageRows, runsById, query.showDeletedLogs),
179+
};
180+
}
181+
182+
// Runs outside the session's project and environment are treated as missing.
183+
async #findSessionRuns(
184+
session: SessionRunsQuery["session"],
185+
rows: SessionRunRow[]
186+
): Promise<Map<string, SessionRunRecord>> {
187+
const runs = await this.deps.findRunsByIds(rows.map((row) => row.runId));
188+
const inSession = new Map<string, SessionRunRecord>();
189+
for (const [id, run] of runs) {
190+
if (
191+
run.projectId === session.projectId &&
192+
run.runtimeEnvironmentId === session.runtimeEnvironmentId
193+
) {
194+
inSession.set(id, run);
195+
}
196+
}
197+
return inSession;
198+
}
199+
200+
#view(
201+
rows: SessionRunRow[],
202+
runsById: Map<string, SessionRunRecord>,
203+
showDeletedLogs: boolean
204+
): SessionRunsPage["view"] {
205+
const view: SessionRunsPage["view"] = {};
206+
for (const row of rows) {
207+
const run = runsById.get(row.runId);
208+
if (!run) continue;
209+
view[run.friendlyId] = {
210+
unavailable: this.deps.unavailableBeforeRead(run, {
211+
showDeletedLogs,
212+
emergencySpanCap: this.deps.emergencySpanCap,
213+
v1RerouteEnabled: this.deps.v1RerouteEnabled,
214+
}),
215+
};
216+
}
217+
return view;
218+
}
219+
}
220+
221+
function toItem({
222+
row,
223+
number,
224+
run,
225+
predecessorRun,
226+
}: {
227+
row: SessionRunRow;
228+
number: number;
229+
run: SessionRunRecord | undefined;
230+
predecessorRun: SessionRunRecord | undefined;
231+
}): SessionRunItem {
232+
return {
233+
number,
234+
friendlyId: run?.friendlyId ?? RunId.toFriendlyId(row.runId),
235+
reason: row.reason,
236+
afterCrash: predecessorRun ? CRASH_STATUSES.includes(predecessorRun.status) : false,
237+
triggeredAt: row.triggeredAt.toISOString(),
238+
status: run?.status ?? null,
239+
createdAt: run?.createdAt.toISOString() ?? null,
240+
startedAt: run?.startedAt?.toISOString() ?? null,
241+
completedAt: run?.completedAt?.toISOString() ?? null,
242+
finishedAt: (run && getRunFinishedAt(run)?.toISOString()) ?? null,
243+
};
244+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { PrismaClientOrTransaction, Session } from "@trigger.dev/database";
2+
import { $replica } from "~/db.server";
3+
import { findProjectWithOrgFlagsBySlug } from "~/models/project.server";
4+
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
5+
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
6+
import { canAccessSessionTrace } from "~/v3/canAccessSessionTrace.server";
7+
8+
export type SessionRunsAccess =
9+
| { ok: true; session: Session }
10+
| {
11+
ok: false;
12+
error: "Project not found" | "Not found" | "Environment not found" | "Session not found";
13+
};
14+
15+
/** The session behind a session runs request, if the user may see its runs. */
16+
export async function findSessionForRunsView(options: {
17+
organizationSlug: string;
18+
projectSlug: string;
19+
environmentSlug: string;
20+
sessionParam: string;
21+
userId: string;
22+
isAdmin: boolean;
23+
isManagedCloud: boolean;
24+
db?: PrismaClientOrTransaction;
25+
}): Promise<SessionRunsAccess> {
26+
const db = options.db ?? $replica;
27+
28+
const project = await findProjectWithOrgFlagsBySlug(
29+
options.organizationSlug,
30+
options.projectSlug,
31+
options.userId,
32+
db
33+
);
34+
if (!project) return { ok: false, error: "Project not found" };
35+
36+
const hasAccess = await canAccessSessionTrace({
37+
isManagedCloud: options.isManagedCloud,
38+
isAdmin: options.isAdmin,
39+
orgFeatureFlags: project.organization.featureFlags,
40+
db: options.db,
41+
});
42+
if (!hasAccess) return { ok: false, error: "Not found" };
43+
44+
const environment = await findEnvironmentBySlug(
45+
project.id,
46+
options.environmentSlug,
47+
options.userId,
48+
db
49+
);
50+
if (!environment) return { ok: false, error: "Environment not found" };
51+
52+
const session = await resolveSessionByIdOrExternalId(db, environment.id, options.sessionParam);
53+
if (!session) return { ok: false, error: "Session not found" };
54+
55+
return { ok: true, session };
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { trace } from "@opentelemetry/api";
2+
import { json } from "@remix-run/server-runtime";
3+
import { z } from "zod";
4+
import { $replica } from "~/db.server";
5+
import { env } from "~/env.server";
6+
import { featuresForRequest } from "~/features.server";
7+
import { runTraceUnavailableBeforeRead } from "~/presenters/v3/runTraceSetup.server";
8+
import {
9+
SessionRunsPresenter,
10+
SessionRunsSearchParams,
11+
sessionRunSelect,
12+
type SessionRunsPage,
13+
} from "~/presenters/v3/SessionRunsPresenter.server";
14+
import { findSessionForRunsView } from "~/presenters/v3/sessionRunsAccess.server";
15+
import { resolveProjectAuthScope } from "~/services/projectAuthScope.server";
16+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
17+
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
18+
import { runStore } from "~/v3/runStore.server";
19+
20+
const ParamsSchema = EnvironmentParamSchema.extend({ sessionParam: z.string() });
21+
22+
// One body for every miss, so a response doesn't reveal whether the flag is on.
23+
function notFound(reason: string) {
24+
trace.getActiveSpan()?.setAttribute("sessionRuns.notFoundReason", reason);
25+
return json({ error: "Not found" }, { status: 404 });
26+
}
27+
28+
// GET: a page of a session's runs for the Trace tab, newest first.
29+
export const loader = dashboardLoader(
30+
{
31+
params: ParamsSchema,
32+
searchParams: SessionRunsSearchParams,
33+
context: (params) => resolveProjectAuthScope(params.organizationSlug, params.projectParam),
34+
authorization: { action: "read", resource: { type: "sessions" } },
35+
},
36+
async ({ params, searchParams, user, request }) => {
37+
const access = await findSessionForRunsView({
38+
organizationSlug: params.organizationSlug,
39+
projectSlug: params.projectParam,
40+
environmentSlug: params.envParam,
41+
sessionParam: params.sessionParam,
42+
userId: user.id,
43+
isAdmin: user.admin,
44+
isManagedCloud: featuresForRequest(request).isManagedCloud,
45+
});
46+
if (!access.ok) return notFound(access.error);
47+
48+
const { session } = access;
49+
const presenter = new SessionRunsPresenter({
50+
replica: $replica,
51+
findRunsByIds: (ids) => runStore.findRunsByIds(ids, { select: sessionRunSelect }, $replica),
52+
unavailableBeforeRead: runTraceUnavailableBeforeRead,
53+
emergencySpanCap: env.TRACE_VIEW_EMERGENCY_SPAN_CAP,
54+
v1RerouteEnabled: env.EVENT_REPOSITORY_V1_REROUTE_ENABLED,
55+
});
56+
57+
const page = await presenter.call({
58+
session: {
59+
id: session.id,
60+
projectId: session.projectId,
61+
runtimeEnvironmentId: session.runtimeEnvironmentId,
62+
},
63+
pageSize: searchParams["page[size]"],
64+
after: searchParams["page[after]"],
65+
runFriendlyId: searchParams.run,
66+
showDeletedLogs: user.isImpersonating,
67+
});
68+
if (!page) return notFound("Run not in session");
69+
70+
return json<SessionRunsPage>(page);
71+
}
72+
);

0 commit comments

Comments
 (0)