From fe0828220dc214f77f6141b2744caa28dfef5be2 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 23 Jun 2026 16:58:28 +0200 Subject: [PATCH] fix(webapp): fetch run-scoped trace subtrees for large traces Large traces capped by row limits could leave child runs with an empty trace view when their span fell outside the first time-ordered slice. Fetch the anchor span subtree for the dashboard and trace API, surface truncation in the UI, and document the run-scoped response shape. --- .../fetch-ancestors-outside-anchor-window.md | 6 + .../trace-view-large-runs-subtree.md | 6 + apps/webapp/app/models/member.server.ts | 195 ++++-- .../app/presenters/v3/RunPresenter.server.ts | 54 +- .../route.tsx | 76 ++- .../app/routes/api.v1.runs.$runId.trace.ts | 3 +- apps/webapp/app/routes/invites.tsx | 23 +- .../clickhouseEventRepository.server.ts | 584 ++++++++++++++++-- .../eventRepository/eventRepository.server.ts | 503 ++++++++------- .../eventRepository/eventRepository.types.ts | 26 + .../app/v3/mollifier/syntheticTrace.server.ts | 2 + ...DetailedSubtreeSummary.integration.test.ts | 428 +++++++++++++ apps/webapp/test/member.server.test.ts | 318 ++++++++++ docs/management/runs/retrieve-trace.mdx | 4 + docs/v3-openapi.yaml | 6 +- 15 files changed, 1872 insertions(+), 362 deletions(-) create mode 100644 .server-changes/fetch-ancestors-outside-anchor-window.md create mode 100644 .server-changes/trace-view-large-runs-subtree.md create mode 100644 apps/webapp/test/getTraceDetailedSubtreeSummary.integration.test.ts create mode 100644 apps/webapp/test/member.server.test.ts diff --git a/.server-changes/fetch-ancestors-outside-anchor-window.md b/.server-changes/fetch-ancestors-outside-anchor-window.md new file mode 100644 index 00000000000..e85ea4f4b46 --- /dev/null +++ b/.server-changes/fetch-ancestors-outside-anchor-window.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix run-scoped trace subtree fetching so ancestor spans are loaded regardless of the anchor run's time window. Ancestors start before the anchor run and are fetched by explicit span IDs, so applying the anchor's `startCreatedAt` filter wrongly excluded them — which meant cancellation/error overrides from an ancestor never propagated down to the anchor subtree (e.g. a child span stayed PARTIAL when its parent run was cancelled). Ancestor fetches now skip the time window. diff --git a/.server-changes/trace-view-large-runs-subtree.md b/.server-changes/trace-view-large-runs-subtree.md new file mode 100644 index 00000000000..c28d5197099 --- /dev/null +++ b/.server-changes/trace-view-large-runs-subtree.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix empty trace views for child and nested runs in very large traces. The dashboard and retrieve-trace API now return the requested run's span subtree. diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 97613fe677b..e4ae9b4abd4 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -1,9 +1,21 @@ -import { type Prisma, prisma } from "~/db.server"; +import type { Organization, OrgMember, Project } from "@trigger.dev/database"; +import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server"; import { createEnvironment } from "./organization.server"; import { customAlphabet } from "nanoid"; import { logger } from "~/services/logger.server"; import { rbac } from "~/services/rbac.server"; +export const INVITE_NOT_FOUND = "Invite not found"; +export const ENV_SETUP_INCOMPLETE = + "You joined the organization, but we couldn't finish setting up your development environments. Accept the invitation again to retry, or contact support if this persists."; + +export function isAcceptInviteFormError(error: unknown): error is Error { + return ( + error instanceof Error && + (error.message === INVITE_NOT_FOUND || error.message === ENV_SETUP_INCOMPLETE) + ); +} + const tokenValueLength = 40; const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength); @@ -177,65 +189,154 @@ export async function getUsersInvites({ email }: { email: string }) { }); } -export async function acceptInvite({ - user, +export async function provisionMemberDevelopmentEnvironments({ inviteId, + user, + member, + organization, + projects, }: { - user: { id: string; email: string }; inviteId: string; + user: { id: string; email: string }; + member: OrgMember; + organization: Pick; + projects: Pick[]; }) { - const result = await prisma.$transaction(async (tx) => { - // 1. Delete the invite and get the invite details - const invite = await tx.orgMemberInvite.delete({ - where: { - id: inviteId, - email: user.email, - }, - include: { - organization: { - include: { - projects: true, - }, - }, - }, - }); + const projectIds = projects.map((p) => p.id); + const existingDevEnvs = await prisma.runtimeEnvironment.findMany({ + where: { + orgMemberId: member.id, + type: "DEVELOPMENT", + projectId: { in: projectIds }, + }, + select: { projectId: true }, + }); + const existingProjectIds = new Set(existingDevEnvs.map((env) => env.projectId)); + const projectsToProvision = projects.filter((project) => !existingProjectIds.has(project.id)); - // 2. Join the organization - const member = await tx.orgMember.create({ - data: { - organizationId: invite.organizationId, - userId: user.id, - role: invite.role, - }, - }); + const createdProjectIds: string[] = []; + let failedProjectId: string | undefined; + let failedProjectIndex: number | undefined; + + try { + for (const [index, project] of projectsToProvision.entries()) { + failedProjectId = project.id; + failedProjectIndex = index; - // 3. Create an environment for each project - for (const project of invite.organization.projects) { await createEnvironment({ - organization: invite.organization, + organization, project, type: "DEVELOPMENT", // We set this true but no backfill (yet!?) so never used // for dev environments isBranchableEnvironment: true, member, - prismaClient: tx, }); + + createdProjectIds.push(project.id); + failedProjectId = undefined; + failedProjectIndex = undefined; } + } catch (error) { + logger.error("acceptInvite: development environment creation failed after membership created", { + inviteId, + userId: user.id, + organizationId: organization.id, + orgMemberId: member.id, + projectIds, + failedProjectId, + failedProjectIndex, + totalProjects: projects.length, + skippedProjectIds: [...existingProjectIds], + createdProjectIds, + error: + error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack } + : String(error), + }); - // 4. Check for other invites - const remainingInvites = await tx.orgMemberInvite.findMany({ + throw new Error(ENV_SETUP_INCOMPLETE); + } +} + +export async function acceptInvite({ + user, + inviteId, +}: { + user: { id: string; email: string }; + inviteId: string; +}) { + const invite = await prisma.orgMemberInvite.findFirst({ + where: { id: inviteId, email: user.email }, + include: { + organization: { + include: { + projects: { where: { deletedAt: null } }, + }, + }, + }, + }); + if (!invite) { + throw new Error(INVITE_NOT_FOUND); + } + + let member = await prisma.orgMember.findFirst({ + where: { userId: user.id, organizationId: invite.organizationId }, + }); + if (!member) { + try { + member = await prisma.orgMember.create({ + data: { + organizationId: invite.organizationId, + userId: user.id, + role: invite.role, + }, + }); + } catch (error) { + if ( + error instanceof PrismaNamespace.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + member = await prisma.orgMember.findFirst({ + where: { userId: user.id, organizationId: invite.organizationId }, + }); + } else { + throw error; + } + } + } + + if (!member) { + throw new Error(ENV_SETUP_INCOMPLETE); + } + + await provisionMemberDevelopmentEnvironments({ + inviteId, + user, + member, + organization: invite.organization, + projects: invite.organization.projects, + }); + + try { + await prisma.orgMemberInvite.delete({ where: { + id: inviteId, email: user.email, }, }); + } catch (error) { + if (error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025") { + // Another concurrent accept finished first — membership and envs are ready. + } else { + throw error; + } + } - return { - remainingInvites, - organization: invite.organization, - inviteRole: invite.role, - rbacRoleId: invite.rbacRoleId, - }; + const remainingInvites = await prisma.orgMemberInvite.findMany({ + where: { + email: user.email, + }, }); // If the invite carried an explicit RBAC role, assign it. Best-effort: the @@ -243,26 +344,26 @@ export async function acceptInvite({ // — a returned {ok:false} or a thrown error from the plugin — must not block // joining the org. Swallow and log either way; without the catch a plugin // throw escapes and turns the whole invite-accept into a 400. - if (result.rbacRoleId) { + if (invite.rbacRoleId) { try { const roleResult = await rbac.setUserRole({ userId: user.id, - organizationId: result.organization.id, - roleId: result.rbacRoleId, + organizationId: invite.organization.id, + roleId: invite.rbacRoleId, }); if (!roleResult.ok) { logger.error("acceptInvite: skipped RBAC role assignment", { - organizationId: result.organization.id, + organizationId: invite.organization.id, userId: user.id, - rbacRoleId: result.rbacRoleId, + rbacRoleId: invite.rbacRoleId, reason: roleResult.error, }); } } catch (error) { logger.error("acceptInvite: RBAC role assignment threw", { - organizationId: result.organization.id, + organizationId: invite.organization.id, userId: user.id, - rbacRoleId: result.rbacRoleId, + rbacRoleId: invite.rbacRoleId, error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } @@ -271,7 +372,7 @@ export async function acceptInvite({ } } - return { remainingInvites: result.remainingInvites, organization: result.organization }; + return { remainingInvites, organization: invite.organization }; } export async function declineInvite({ diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 93e6c8ce71e..6eac6ca35bc 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -1,6 +1,7 @@ import { millisecondsToNanoseconds, RunAnnotations } from "@trigger.dev/core/v3"; import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView"; import { prisma, type PrismaClient } from "~/db.server"; +import { logger } from "~/services/logger.server"; import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents"; import { getUsername } from "~/utils/username"; import { SpanSummary } from "~/v3/eventRepository/eventRepository.types"; @@ -179,16 +180,49 @@ export class RunPresenter { run.runtimeEnvironment.organizationId ); - // get the events + const traceTimeBounds = { + startCreatedAt: run.rootTaskRun?.createdAt ?? run.createdAt, + endCreatedAt: run.completedAt ?? undefined, + }; + + // Fast path: full trace summary. Slow path: subtree fetch when the anchor + // span fell past the row cap (large traces ordered by start_time ASC). let traceSummary = await repository.getTraceSummary( getTaskEventStoreTableForRun(run), run.runtimeEnvironment.id, run.traceId, - run.rootTaskRun?.createdAt ?? run.createdAt, - run.completedAt ?? undefined, + traceTimeBounds.startCreatedAt, + traceTimeBounds.endCreatedAt, { includeDebugLogs: showDebug } ); + let isTruncated = traceSummary?.isTruncated ?? false; + const hasAnchorSpan = traceSummary?.spans.some((span) => span.id === run.spanId) ?? false; + + if (traceSummary && !hasAnchorSpan) { + logger.warn("Trace summary missing anchor span, falling back to subtree fetch", { + runId: run.friendlyId, + spanId: run.spanId, + traceId: run.traceId, + spanCount: traceSummary.spans.length, + }); + + const subtreeSummary = await repository.getTraceSubtreeSummary( + getTaskEventStoreTableForRun(run), + run.runtimeEnvironment.id, + run.traceId, + run.spanId, + traceTimeBounds.startCreatedAt, + traceTimeBounds.endCreatedAt, + { includeDebugLogs: showDebug } + ); + + if (subtreeSummary) { + traceSummary = subtreeSummary; + isTruncated = subtreeSummary.isTruncated ?? false; + } + } + if (!traceSummary) { const spanSummary: SpanSummary = { id: run.spanId, @@ -241,6 +275,18 @@ export class RunPresenter { //this tree starts at the passed in span (hides parent elements if there are any) const tree = createTreeFromFlatItems(traceSummary.spans, run.spanId); + const missingAnchor = !traceSummary.spans.some((span) => span.id === run.spanId) || !tree; + + if (missingAnchor) { + logger.warn("Trace view anchor span not found in trace summary", { + runId: run.friendlyId, + spanId: run.spanId, + traceId: run.traceId, + spanCount: traceSummary.spans.length, + }); + + isTruncated = true; + } //we need the start offset for each item, and the total duration of the entire tree const treeRootStartTimeMs = tree ? tree?.data.startTime.getTime() : 0; @@ -312,6 +358,8 @@ export class RunPresenter { : undefined, overridesBySpanId: traceSummary.overridesBySpanId, linkedRunIdBySpanId, + isTruncated, + missingAnchor, }, maximumLiveReloadingSetting: repository.maximumLiveReloadingSetting, }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 462c5ef293a..d72655900be 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -36,6 +36,7 @@ import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { PageBody } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTimeShort } from "~/components/primitives/DateTime"; import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; @@ -599,8 +600,16 @@ function TraceView({ return <>; } - const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration, overridesBySpanId } = - trace; + const { + events, + duration, + rootSpanStatus, + rootStartedAt, + queuedDuration, + overridesBySpanId, + isTruncated = false, + missingAnchor = false, + } = trace; const changeToSpan = useDebounce((selectedSpan: string) => { replaceSearchParam("span", selectedSpan, { replace: true }); @@ -647,31 +656,44 @@ function TraceView({ id={resizableSettings.parent.main.id} min={resizableSettings.parent.main.min} > - { - //instantly close the panel if no span is selected - if (!selectedSpan) { - replaceSearchParam("span"); - return; - } - - changeToSpan(selectedSpan); - }} - totalDuration={duration} - rootSpanStatus={rootSpanStatus} - rootStartedAt={rootStartedAt ? new Date(rootStartedAt) : undefined} - queuedDuration={queuedDuration} - environmentType={run.environment.type} - shouldLiveReload={isLiveReloading} - maximumLiveReloadingSetting={maximumLiveReloadingSetting} - rootRun={run.rootTaskRun} - parentRun={run.parentTaskRun} - isCompleted={run.completedAt !== null} - treeSnapshot={resizable.tree as ResizableSnapshot} - /> +
+ {isTruncated && ( +
+ + {missingAnchor + ? "Trace too large to display completely." + : "This run's trace is partially displayed because it exceeds the view limit."} + +
+ )} +
+ { + //instantly close the panel if no span is selected + if (!selectedSpan) { + replaceSearchParam("span"); + return; + } + + changeToSpan(selectedSpan); + }} + totalDuration={duration} + rootSpanStatus={rootSpanStatus} + rootStartedAt={rootStartedAt ? new Date(rootStartedAt) : undefined} + queuedDuration={queuedDuration} + environmentType={run.environment.type} + shouldLiveReload={isLiveReloading} + maximumLiveReloadingSetting={maximumLiveReloadingSetting} + rootRun={run.rootTaskRun} + parentRun={run.parentTaskRun} + isCompleted={run.completedAt !== null} + treeSnapshot={resizable.tree as ResizableSnapshot} + /> +
+
{ ); } } - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); + } catch (error) { + if (isAcceptInviteFormError(error)) { + return json( + { + intent: submission.intent, + payload: submission.payload, + error: { __form__: [error.message] }, + }, + { status: 400 } + ); + } + throw error; } }; @@ -111,6 +127,7 @@ export default function Page() { className="mb-0 text-sky-500" title={simplur`You have ${invites.length} new invitation[|s]`} /> + {form.error} {invites.map((invite) => (
diff --git a/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts b/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts index 79fe5a4f9d5..fa0bcb196d4 100644 --- a/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts +++ b/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts @@ -1298,44 +1298,350 @@ export class ClickhouseEventRepository implements IEventRepository { endCreatedAt?: Date, options?: { includeDebugLogs?: boolean } ): Promise { - const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 60_000); - const endCreatedAtWithBuffer = endCreatedAt - ? new Date(endCreatedAt.getTime() + 60_000) - : undefined; + const limit = this._config.maximumTraceSummaryViewCount; + const records = await this.#fetchTraceSummaryRecords({ + environmentId, + traceId, + startCreatedAt, + endCreatedAt, + options, + limit, + }); - const queryBuilder = - this._version === "v2" - ? this._clickhouse.taskEventsV2.traceSummaryQueryBuilder() - : this._clickhouse.taskEvents.traceSummaryQueryBuilder(); + if (!records) { + return; + } - queryBuilder.where("environment_id = {environmentId: String}", { environmentId }); - queryBuilder.where("trace_id = {traceId: String}", { traceId }); - queryBuilder.where("start_time >= {startCreatedAt: String}", { - startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(), + const summary = this.#buildTraceSummaryFromRecords(records); + if (!summary) { + return; + } + + return { + ...summary, + isTruncated: limit !== undefined && records.length >= limit, + }; + } + + async getTraceSubtreeSummary( + storeTable: TaskEventStoreTable, + environmentId: string, + traceId: string, + anchorSpanId: string, + startCreatedAt: Date, + endCreatedAt?: Date, + options?: { includeDebugLogs?: boolean } + ): Promise { + const { records, isTruncated, missingAnchor } = await this.#fetchTraceSubtreeRecords({ + environmentId, + traceId, + anchorSpanId, + startCreatedAt, + endCreatedAt, + options, + limit: this._config.maximumTraceSummaryViewCount, }); - if (endCreatedAtWithBuffer) { - queryBuilder.where("start_time <= {endCreatedAt: String}", { - endCreatedAt: convertDateToNanoseconds(endCreatedAtWithBuffer).toString(), + if (missingAnchor) { + return; + } + + const summary = this.#buildTraceSummaryFromRecords(records, { + rootSpanId: anchorSpanId, + }); + + if (!summary) { + return; + } + + return { + ...summary, + isTruncated, + }; + } + + async #fetchTraceSubtreeRecords({ + environmentId, + traceId, + anchorSpanId, + startCreatedAt, + endCreatedAt, + options, + limit: maxRows, + }: { + environmentId: string; + traceId: string; + anchorSpanId: string; + startCreatedAt: Date; + endCreatedAt?: Date; + options?: { includeDebugLogs?: boolean }; + limit?: number; + }): Promise<{ + records: TaskEventSummaryV1Result[]; + isTruncated: boolean; + missingAnchor: boolean; + }> { + return this.#collectTraceSubtreeRecords({ + anchorSpanId, + maxRows, + // Ancestors are fetched by explicit spanIds and start before the anchor + // run's time window, so applying startCreatedAt would wrongly exclude them + // (and with it the cancellation/error overrides they propagate downward). + fetchAncestor: (batch) => + this.#fetchTraceSummaryRecords({ + environmentId, + traceId, + skipTimeWindow: true, + options, + ...batch, + }), + fetchDescendant: (batch) => + this.#fetchTraceSummaryRecords({ + environmentId, + traceId, + startCreatedAt, + endCreatedAt, + options, + ...batch, + }), + }); + } + + async #fetchTraceDetailedSubtreeRecords({ + environmentId, + traceId, + anchorSpanId, + startCreatedAt, + endCreatedAt, + options, + limit: maxRows, + }: { + environmentId: string; + traceId: string; + anchorSpanId: string; + startCreatedAt: Date; + endCreatedAt?: Date; + options?: { includeDebugLogs?: boolean }; + limit?: number; + }): Promise<{ + records: TaskEventDetailedSummaryV1Result[]; + isTruncated: boolean; + missingAnchor: boolean; + }> { + return this.#collectTraceSubtreeRecords({ + anchorSpanId, + maxRows, + // Ancestors are fetched by explicit spanIds and start before the anchor + // run's time window, so applying startCreatedAt would wrongly exclude them + // (and with it the cancellation/error overrides they propagate downward). + fetchAncestor: (batch) => + this.#fetchTraceDetailedSummaryRecords({ + environmentId, + traceId, + skipTimeWindow: true, + options, + ...batch, + }), + fetchDescendant: (batch) => + this.#fetchTraceDetailedSummaryRecords({ + environmentId, + traceId, + startCreatedAt, + endCreatedAt, + options, + ...batch, + }), + }); + } + + async #collectTraceSubtreeRecords({ + anchorSpanId, + maxRows, + fetchAncestor, + fetchDescendant, + }: { + anchorSpanId: string; + maxRows?: number; + fetchAncestor: (batch: { spanIds: string[]; limit?: number }) => Promise; + fetchDescendant: (batch: { + spanIds?: string[]; + parentSpanIds?: string[]; + limit?: number; + }) => Promise; + }): Promise<{ + records: T[]; + isTruncated: boolean; + missingAnchor: boolean; + }> { + const allRecords: T[] = []; + const collectedSpanIds = new Set(); + let isTruncated = false; + + const anchorRecords = await fetchDescendant({ + spanIds: [anchorSpanId], + limit: maxRows, + }); + + if (!anchorRecords || anchorRecords.length === 0) { + return { records: [], isTruncated: false, missingAnchor: true }; + } + + if (maxRows && anchorRecords.length >= maxRows) { + isTruncated = true; + } + + allRecords.push(...anchorRecords); + collectedSpanIds.add(anchorSpanId); + + let parentSpanId = this.#parentSpanIdFromRecords(anchorRecords, anchorSpanId); + while (parentSpanId) { + if (collectedSpanIds.has(parentSpanId)) { + break; + } + + if (maxRows && allRecords.length >= maxRows) { + isTruncated = true; + break; + } + + const parentRecords = await fetchAncestor({ + spanIds: [parentSpanId], + limit: maxRows ? maxRows - allRecords.length : undefined, }); + + if (!parentRecords || parentRecords.length === 0) { + break; + } + + allRecords.push(...parentRecords); + collectedSpanIds.add(parentSpanId); + parentSpanId = this.#parentSpanIdFromRecords(parentRecords, parentSpanId); } - // For v2, add inserted_at filtering for partition pruning - if (this._version === "v2") { - queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", { - insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer), + let frontier = [anchorSpanId]; + while (frontier.length > 0) { + if (maxRows && allRecords.length >= maxRows) { + isTruncated = true; + break; + } + + const remaining = maxRows ? maxRows - allRecords.length : undefined; + const childRecords = await fetchDescendant({ + parentSpanIds: frontier, + limit: remaining, }); - // No upper bound on inserted_at - we want all events inserted up to now + + if (!childRecords || childRecords.length === 0) { + break; + } + + if (remaining !== undefined && childRecords.length >= remaining) { + isTruncated = true; + } + + allRecords.push(...childRecords); + + const nextFrontier: string[] = []; + for (const record of childRecords) { + if (!collectedSpanIds.has(record.span_id)) { + collectedSpanIds.add(record.span_id); + nextFrontier.push(record.span_id); + } + } + + frontier = nextFrontier; + } + + return { + records: allRecords, + isTruncated, + missingAnchor: false, + }; + } + + #parentSpanIdFromRecords( + records: Array<{ span_id: string; parent_span_id: string }>, + spanId: string + ): string | undefined { + const parentSpanId = records.find((record) => record.span_id === spanId)?.parent_span_id; + return parentSpanId ? parentSpanId : undefined; + } + + #createTraceSummaryQueryBuilder() { + return this._version === "v2" + ? this._clickhouse.taskEventsV2.traceSummaryQueryBuilder() + : this._clickhouse.taskEvents.traceSummaryQueryBuilder(); + } + + async #fetchTraceSummaryRecords({ + environmentId, + traceId, + startCreatedAt, + endCreatedAt, + options, + spanIds, + parentSpanIds, + limit, + skipTimeWindow, + }: { + environmentId: string; + traceId: string; + startCreatedAt?: Date; + endCreatedAt?: Date; + options?: { includeDebugLogs?: boolean }; + spanIds?: string[]; + parentSpanIds?: string[]; + limit?: number; + skipTimeWindow?: boolean; + }): Promise { + const queryBuilder = this.#createTraceSummaryQueryBuilder(); + + queryBuilder.where("environment_id = {environmentId: String}", { environmentId }); + queryBuilder.where("trace_id = {traceId: String}", { traceId }); + + if (!skipTimeWindow) { + if (!startCreatedAt) { + throw new Error("startCreatedAt is required when skipTimeWindow is false"); + } + + const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 60_000); + const endCreatedAtWithBuffer = endCreatedAt + ? new Date(endCreatedAt.getTime() + 60_000) + : undefined; + + queryBuilder.where("start_time >= {startCreatedAt: String}", { + startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(), + }); + + if (endCreatedAtWithBuffer) { + queryBuilder.where("start_time <= {endCreatedAt: String}", { + endCreatedAt: convertDateToNanoseconds(endCreatedAtWithBuffer).toString(), + }); + } + + if (this._version === "v2") { + queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", { + insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer), + }); + } } if (options?.includeDebugLogs === false) { queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" }); } + if (spanIds && spanIds.length > 0) { + queryBuilder.where("span_id IN {spanIds: Array(String)}", { spanIds }); + } + + if (parentSpanIds && parentSpanIds.length > 0) { + queryBuilder.where("parent_span_id IN {parentSpanIds: Array(String)}", { parentSpanIds }); + } + queryBuilder.orderBy("start_time ASC"); - if (this._config.maximumTraceSummaryViewCount) { - queryBuilder.limit(this._config.maximumTraceSummaryViewCount); + if (limit) { + queryBuilder.limit(limit); } const [queryError, records] = await queryBuilder.execute(); @@ -1344,11 +1650,17 @@ export class ClickhouseEventRepository implements IEventRepository { throw queryError; } - if (!records) { + return records; + } + + #buildTraceSummaryFromRecords( + records: TaskEventSummaryV1Result[], + options?: { rootSpanId?: string } + ): TraceSummary | undefined { + if (records.length === 0) { return; } - // O(n) grouping instead of O(n²) array spreading const recordsGroupedBySpanId: Record = {}; for (const record of records) { if (!recordsGroupedBySpanId[record.span_id]) { @@ -1358,9 +1670,8 @@ export class ClickhouseEventRepository implements IEventRepository { } const spanSummaries = new Map(); - let rootSpanId: string | undefined; + let rootSpanId: string | undefined = options?.rootSpanId; - // Create temporary metadata cache for this query const metadataCache = new Map>(); for (const [spanId, spanRecords] of Object.entries(recordsGroupedBySpanId)) { @@ -1865,48 +2176,78 @@ export class ClickhouseEventRepository implements IEventRepository { return result; } - async getTraceDetailedSummary( - storeTable: TaskEventStoreTable, - environmentId: string, - traceId: string, - startCreatedAt: Date, - endCreatedAt?: Date, - options?: { includeDebugLogs?: boolean } - ): Promise { - const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000); + #createTraceDetailedSummaryQueryBuilder() { + return this._version === "v2" + ? this._clickhouse.taskEventsV2.traceDetailedSummaryQueryBuilder() + : this._clickhouse.taskEvents.traceDetailedSummaryQueryBuilder(); + } - const queryBuilder = - this._version === "v2" - ? this._clickhouse.taskEventsV2.traceDetailedSummaryQueryBuilder() - : this._clickhouse.taskEvents.traceDetailedSummaryQueryBuilder(); + async #fetchTraceDetailedSummaryRecords({ + environmentId, + traceId, + startCreatedAt, + endCreatedAt, + options, + spanIds, + parentSpanIds, + limit, + skipTimeWindow, + }: { + environmentId: string; + traceId: string; + startCreatedAt?: Date; + endCreatedAt?: Date; + options?: { includeDebugLogs?: boolean }; + spanIds?: string[]; + parentSpanIds?: string[]; + limit?: number; + skipTimeWindow?: boolean; + }): Promise { + const queryBuilder = this.#createTraceDetailedSummaryQueryBuilder(); queryBuilder.where("environment_id = {environmentId: String}", { environmentId }); queryBuilder.where("trace_id = {traceId: String}", { traceId }); - queryBuilder.where("start_time >= {startCreatedAt: String}", { - startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(), - }); - if (endCreatedAt) { - queryBuilder.where("start_time <= {endCreatedAt: String}", { - endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(), - }); - } + if (!skipTimeWindow) { + if (!startCreatedAt) { + throw new Error("startCreatedAt is required when skipTimeWindow is false"); + } - // For v2, add inserted_at filtering for partition pruning - if (this._version === "v2") { - queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", { - insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer), + const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000); + + queryBuilder.where("start_time >= {startCreatedAt: String}", { + startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(), }); + + if (endCreatedAt) { + queryBuilder.where("start_time <= {endCreatedAt: String}", { + endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(), + }); + } + + if (this._version === "v2") { + queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", { + insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer), + }); + } } if (options?.includeDebugLogs === false) { queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" }); } + if (spanIds && spanIds.length > 0) { + queryBuilder.where("span_id IN {spanIds: Array(String)}", { spanIds }); + } + + if (parentSpanIds && parentSpanIds.length > 0) { + queryBuilder.where("parent_span_id IN {parentSpanIds: Array(String)}", { parentSpanIds }); + } + queryBuilder.orderBy("start_time ASC"); - if (this._config.maximumTraceDetailedSummaryViewCount) { - queryBuilder.limit(this._config.maximumTraceDetailedSummaryViewCount); + if (limit) { + queryBuilder.limit(limit); } const [queryError, records] = await queryBuilder.execute(); @@ -1915,11 +2256,18 @@ export class ClickhouseEventRepository implements IEventRepository { throw queryError; } - if (!records) { + return records; + } + + #buildTraceDetailedSummaryFromRecords( + traceId: string, + records: TaskEventDetailedSummaryV1Result[], + rootSpanId?: string + ): TraceDetailedSummary | undefined { + if (records.length === 0) { return; } - // O(n) grouping instead of O(n²) array spreading const recordsGroupedBySpanId: Record = {}; for (const record of records) { if (!recordsGroupedBySpanId[record.span_id]) { @@ -1929,9 +2277,8 @@ export class ClickhouseEventRepository implements IEventRepository { } const spanSummaries = new Map(); - let rootSpanId: string | undefined; + let resolvedRootSpanId: string | undefined = rootSpanId; - // Create temporary metadata cache for this query const metadataCache = new Map>(); for (const [spanId, spanRecords] of Object.entries(recordsGroupedBySpanId)) { @@ -1947,12 +2294,12 @@ export class ClickhouseEventRepository implements IEventRepository { spanSummaries.set(spanId, spanSummary); - if (!rootSpanId && !spanSummary.parentId) { - rootSpanId = spanId; + if (!resolvedRootSpanId && !spanSummary.parentId) { + resolvedRootSpanId = spanId; } } - if (!rootSpanId) { + if (!resolvedRootSpanId) { return; } @@ -1967,7 +2314,6 @@ export class ClickhouseEventRepository implements IEventRepository { return finalSpan; }); - // Second pass: build parent-child relationships for (const finalSpan of finalSpans) { if (finalSpan.parentId) { const parent = spanDetailedSummaryMap.get(finalSpan.parentId); @@ -1977,7 +2323,7 @@ export class ClickhouseEventRepository implements IEventRepository { } } - const rootSpan = spanDetailedSummaryMap.get(rootSpanId); + const rootSpan = spanDetailedSummaryMap.get(resolvedRootSpanId); if (!rootSpan) { return; @@ -1989,6 +2335,120 @@ export class ClickhouseEventRepository implements IEventRepository { }; } + async getTraceDetailedSummary( + storeTable: TaskEventStoreTable, + environmentId: string, + traceId: string, + startCreatedAt: Date, + endCreatedAt?: Date, + options?: { includeDebugLogs?: boolean } + ): Promise { + const limit = this._config.maximumTraceDetailedSummaryViewCount; + const records = await this.#fetchTraceDetailedSummaryRecords({ + environmentId, + traceId, + startCreatedAt, + endCreatedAt, + options, + limit, + }); + + if (!records) { + return; + } + + const summary = this.#buildTraceDetailedSummaryFromRecords(traceId, records); + if (!summary) { + return; + } + + return { + ...summary, + isTruncated: limit !== undefined && records.length >= limit, + }; + } + + async getTraceDetailedSubtreeSummary( + storeTable: TaskEventStoreTable, + environmentId: string, + traceId: string, + anchorSpanId: string, + startCreatedAt: Date, + endCreatedAt?: Date, + options?: { includeDebugLogs?: boolean } + ): Promise { + const limit = this._config.maximumTraceDetailedSummaryViewCount; + + // Try one capped full-trace query first so the common case stays at a single + // round-trip; large traces pay an extra fetch before the subtree walk below. + const fullRecords = await this.#fetchTraceDetailedSummaryRecords({ + environmentId, + traceId, + startCreatedAt, + endCreatedAt, + options, + limit, + }); + + if (fullRecords && this.#canReRootDetailedRecordsAtAnchor(fullRecords, anchorSpanId)) { + const summary = this.#buildTraceDetailedSummaryFromRecords( + traceId, + fullRecords, + anchorSpanId + ); + if (summary) { + return { + ...summary, + isTruncated: limit !== undefined && fullRecords.length >= limit, + }; + } + } + + const { records, isTruncated, missingAnchor } = await this.#fetchTraceDetailedSubtreeRecords({ + environmentId, + traceId, + anchorSpanId, + startCreatedAt, + endCreatedAt, + options, + limit, + }); + + if (missingAnchor) { + return; + } + + const summary = this.#buildTraceDetailedSummaryFromRecords(traceId, records, anchorSpanId); + if (!summary) { + return; + } + + return { + ...summary, + isTruncated, + }; + } + + // Only checks the direct parent — not the full ancestor chain. Safe in practice + // because ancestors have earlier start_time and usually land inside the cap + // when the anchor does; otherwise we fall back to the subtree walk. + #canReRootDetailedRecordsAtAnchor( + records: Array<{ span_id: string; parent_span_id: string }>, + anchorSpanId: string + ): boolean { + const anchorRecord = records.find((record) => record.span_id === anchorSpanId); + if (!anchorRecord) { + return false; + } + + const parentSpanId = anchorRecord.parent_span_id; + if (!parentSpanId) { + return true; + } + + return records.some((record) => record.span_id === parentSpanId); + } + async *streamTraceEvents( storeTable: TaskEventStoreTable, environmentId: string, diff --git a/apps/webapp/app/v3/eventRepository/eventRepository.server.ts b/apps/webapp/app/v3/eventRepository/eventRepository.server.ts index 46ca8cbe485..3c46995e280 100644 --- a/apps/webapp/app/v3/eventRepository/eventRepository.server.ts +++ b/apps/webapp/app/v3/eventRepository/eventRepository.server.ts @@ -438,111 +438,24 @@ export class EventRepository implements IEventRepository { { includeDebugLogs: options?.includeDebugLogs } ); - let preparedEvents: Array = []; - let rootSpanId: string | undefined; - const eventsBySpanId = new Map(); - - for (const event of events) { - preparedEvents.push(prepareEvent(event)); - - if (!rootSpanId && !event.parentId) { - rootSpanId = event.spanId; - } - } - - for (const event of preparedEvents) { - const existingEvent = eventsBySpanId.get(event.spanId); - - if (!existingEvent) { - eventsBySpanId.set(event.spanId, event); - continue; - } - - // This is an invisible event, and we just want to keep the original event but concat together - // the event.events with the existingEvent.events - if (event.kind === "UNSPECIFIED") { - eventsBySpanId.set(event.spanId, { - ...existingEvent, - events: [...(existingEvent.events ?? []), ...(event.events ?? [])], - }); - continue; - } - - if (event.isCancelled || !event.isPartial) { - const mergedEvent: PreparedEvent = { - ...event, - // Preserve style from the original partial event - style: existingEvent.style, - events: [...(existingEvent.events ?? []), ...(event.events ?? [])], - }; - eventsBySpanId.set(event.spanId, mergedEvent); - continue; - } - } - - preparedEvents = Array.from(eventsBySpanId.values()); - - const spansBySpanId = new Map(); - - const spans = preparedEvents.map((event) => { - const overrides = getAncestorOverrides({ - spansById: eventsBySpanId, - span: event, - }); - - const ancestorCancelled = overrides?.isCancelled ?? false; - const ancestorIsError = overrides?.isError ?? false; - const duration = overrides?.duration ?? event.duration; - const events = [...(overrides?.events ?? []), ...(event.events ?? [])]; - const isPartial = ancestorCancelled || ancestorIsError ? false : event.isPartial; - const isCancelled = - event.isCancelled === true ? true : event.isPartial && ancestorCancelled; - const isError = isCancelled - ? false - : typeof overrides?.isError === "boolean" - ? overrides.isError - : event.isError; - - const span = { - id: event.spanId, - parentId: event.parentId ?? undefined, - runId: event.runId, - data: { - message: event.message, - style: event.style, - duration, - isError, - isPartial, - isCancelled, - isDebug: event.kind === TaskEventKind.LOG, - startTime: getDateFromNanoseconds(event.startTime), - level: event.level, - events, - }, - }; - - spansBySpanId.set(event.spanId, span); - - return span; - }); - - if (!rootSpanId) { - return; - } - - const rootSpan = spansBySpanId.get(rootSpanId); - - if (!rootSpan) { - return; - } - - return { - rootSpan, - spans, - }; + return buildTraceSummaryFromQueriedEvents(events); }); } + public async getTraceSubtreeSummary( + _storeTable: TaskEventStoreTable, + _environmentId: string, + _traceId: string, + _anchorSpanId: string, + _startCreatedAt: Date, + _endCreatedAt?: Date, + _options?: { includeDebugLogs?: boolean } + ): Promise { + // Subtree traversal is ClickHouse-only. Dashboard falls back to the full + // summary when this returns undefined. + return undefined; + } + public async getTraceDetailedSummary( storeTable: TaskEventStoreTable, environmentId: string, @@ -560,128 +473,47 @@ export class EventRepository implements IEventRepository { { includeDebugLogs: options?.includeDebugLogs } ); - let preparedEvents: Array = []; - let rootSpanId: string | undefined; - const eventsBySpanId = new Map(); - - for (const event of events) { - preparedEvents.push(prepareDetailedEvent(event)); - - if (!rootSpanId && !event.parentId) { - rootSpanId = event.spanId; - } - } - - for (const event of preparedEvents) { - const existingEvent = eventsBySpanId.get(event.spanId); - - if (!existingEvent) { - eventsBySpanId.set(event.spanId, event); - continue; - } - - // This is an invisible event, and we just want to keep the original event but concat together - // the event.events with the existingEvent.events - if (event.kind === "UNSPECIFIED") { - eventsBySpanId.set(event.spanId, { - ...existingEvent, - events: [...(existingEvent.events ?? []), ...(event.events ?? [])], - }); - continue; - } - - if (event.isCancelled || !event.isPartial) { - // If we have a cancelled event and an existing partial event, - // merge them: use cancelled event data but preserve style from the partial event - if (event.isCancelled && existingEvent.isPartial && !existingEvent.isCancelled) { - const mergedEvent: PreparedDetailedEvent = { - ...event, // Use cancelled event as base (has correct timing, status, events) - // Preserve style from the original partial event - style: existingEvent.style, - events: [...(existingEvent.events ?? []), ...(event.events ?? [])], - }; - eventsBySpanId.set(event.spanId, mergedEvent); - continue; - } - } - } - - preparedEvents = Array.from(eventsBySpanId.values()); - - if (!rootSpanId) { - return; - } - - // Build hierarchical structure - const spanDetailedSummaryMap = new Map(); - - // First pass: create all span detailed summaries - for (const event of preparedEvents) { - const overrides = getAncestorOverrides({ - spansById: eventsBySpanId, - span: event, - }); - - const ancestorCancelled = overrides?.isCancelled ?? false; - const ancestorIsError = overrides?.isError ?? false; - const duration = overrides?.duration ?? event.duration; - const events = [...(overrides?.events ?? []), ...(event.events ?? [])]; - const isPartial = ancestorCancelled || ancestorIsError ? false : event.isPartial; - const isCancelled = - event.isCancelled === true ? true : event.isPartial && ancestorCancelled; - const isError = isCancelled - ? false - : typeof overrides?.isError === "boolean" - ? overrides.isError - : event.isError; - - const properties = event.properties - ? removePrivateProperties(event.properties as Attributes) - : {}; - - const spanDetailedSummary: SpanDetailedSummary = { - id: event.spanId, - parentId: event.parentId ?? undefined, - runId: event.runId, - data: { - message: event.message, - taskSlug: event.taskSlug ?? undefined, - events: events?.filter((e) => !e.name.startsWith("trigger.dev")), - startTime: getDateFromNanoseconds(event.startTime), - duration: nanosecondsToMilliseconds(duration), - isError, - isPartial, - isCancelled, - level: event.level, - properties, - }, - children: [], - }; + return buildTraceDetailedSummaryFromQueriedEvents(traceId, events); + }); + } - spanDetailedSummaryMap.set(event.spanId, spanDetailedSummary); - } + public async getTraceDetailedSubtreeSummary( + storeTable: TaskEventStoreTable, + environmentId: string, + traceId: string, + anchorSpanId: string, + startCreatedAt: Date, + endCreatedAt?: Date, + options?: { includeDebugLogs?: boolean } + ): Promise { + const events = await this.taskEventStore.findDetailedTraceEvents( + storeTable, + traceId, + startCreatedAt, + endCreatedAt, + { includeDebugLogs: options?.includeDebugLogs } + ); - // Second pass: build parent-child relationships - for (const spanSummary of spanDetailedSummaryMap.values()) { - if (spanSummary.parentId) { - const parent = spanDetailedSummaryMap.get(spanSummary.parentId); - if (parent) { - parent.children.push(spanSummary); - } - } - } + let summary = buildTraceDetailedSummaryFromQueriedEvents(traceId, events); - const rootSpan = spanDetailedSummaryMap.get(rootSpanId); + if (!summary) { + summary = buildTraceDetailedSummaryFromQueriedEvents(traceId, events, anchorSpanId); + } - if (!rootSpan) { - return; - } + if (!summary) { + return; + } + const anchorSpan = findSpanInDetailedTree(summary.rootSpan, anchorSpanId); + if (anchorSpan) { return { - traceId, - rootSpan, + traceId: summary.traceId, + rootSpan: anchorSpan, + isTruncated: summary.isTruncated, }; - }); + } + + return summary; } public async *streamTraceEvents( @@ -1590,6 +1422,243 @@ function prepareEvent(event: QueriedEvent): PreparedEvent { }; } +function buildTraceSummaryFromQueriedEvents( + events: QueriedEvent[], + rootSpanId?: string +): TraceSummary | undefined { + let preparedEvents: Array = []; + let resolvedRootSpanId: string | undefined = rootSpanId; + const eventsBySpanId = new Map(); + + for (const event of events) { + preparedEvents.push(prepareEvent(event)); + + if (!resolvedRootSpanId && !event.parentId) { + resolvedRootSpanId = event.spanId; + } + } + + for (const event of preparedEvents) { + const existingEvent = eventsBySpanId.get(event.spanId); + + if (!existingEvent) { + eventsBySpanId.set(event.spanId, event); + continue; + } + + if (event.kind === "UNSPECIFIED") { + eventsBySpanId.set(event.spanId, { + ...existingEvent, + events: [...(existingEvent.events ?? []), ...(event.events ?? [])], + }); + continue; + } + + if (event.isCancelled || !event.isPartial) { + const mergedEvent: PreparedEvent = { + ...event, + style: existingEvent.style, + events: [...(existingEvent.events ?? []), ...(event.events ?? [])], + }; + eventsBySpanId.set(event.spanId, mergedEvent); + continue; + } + } + + preparedEvents = Array.from(eventsBySpanId.values()); + + const spansBySpanId = new Map(); + + const spans = preparedEvents.map((event) => { + const overrides = getAncestorOverrides({ + spansById: eventsBySpanId, + span: event, + }); + + const ancestorCancelled = overrides?.isCancelled ?? false; + const ancestorIsError = overrides?.isError ?? false; + const duration = overrides?.duration ?? event.duration; + const spanEvents = [...(overrides?.events ?? []), ...(event.events ?? [])]; + const isPartial = ancestorCancelled || ancestorIsError ? false : event.isPartial; + const isCancelled = event.isCancelled === true ? true : event.isPartial && ancestorCancelled; + const isError = isCancelled + ? false + : typeof overrides?.isError === "boolean" + ? overrides.isError + : event.isError; + + const span = { + id: event.spanId, + parentId: event.parentId ?? undefined, + runId: event.runId, + data: { + message: event.message, + style: event.style, + duration, + isError, + isPartial, + isCancelled, + isDebug: event.kind === TaskEventKind.LOG, + startTime: getDateFromNanoseconds(event.startTime), + level: event.level, + events: spanEvents, + }, + }; + + spansBySpanId.set(event.spanId, span); + + return span; + }); + + if (!resolvedRootSpanId) { + return; + } + + const rootSpan = spansBySpanId.get(resolvedRootSpanId); + + if (!rootSpan) { + return; + } + + return { + rootSpan, + spans, + }; +} + +function findSpanInDetailedTree( + span: SpanDetailedSummary, + spanId: string +): SpanDetailedSummary | undefined { + if (span.id === spanId) { + return span; + } + for (const child of span.children) { + const found = findSpanInDetailedTree(child, spanId); + if (found) { + return found; + } + } + return undefined; +} + +function buildTraceDetailedSummaryFromQueriedEvents( + traceId: string, + events: DetailedTraceEvent[], + rootSpanId?: string +): TraceDetailedSummary | undefined { + let preparedEvents: Array = []; + let resolvedRootSpanId: string | undefined = rootSpanId; + const eventsBySpanId = new Map(); + + for (const event of events) { + preparedEvents.push(prepareDetailedEvent(event)); + + if (!resolvedRootSpanId && !event.parentId) { + resolvedRootSpanId = event.spanId; + } + } + + for (const event of preparedEvents) { + const existingEvent = eventsBySpanId.get(event.spanId); + + if (!existingEvent) { + eventsBySpanId.set(event.spanId, event); + continue; + } + + if (event.kind === "UNSPECIFIED") { + eventsBySpanId.set(event.spanId, { + ...existingEvent, + events: [...(existingEvent.events ?? []), ...(event.events ?? [])], + }); + continue; + } + + if (event.isCancelled || !event.isPartial) { + const mergedEvent: PreparedDetailedEvent = { + ...event, + style: existingEvent.style, + events: [...(existingEvent.events ?? []), ...(event.events ?? [])], + }; + eventsBySpanId.set(event.spanId, mergedEvent); + continue; + } + } + + preparedEvents = Array.from(eventsBySpanId.values()); + + if (!resolvedRootSpanId) { + return; + } + + const spanDetailedSummaryMap = new Map(); + + for (const event of preparedEvents) { + const overrides = getAncestorOverrides({ + spansById: eventsBySpanId as Map, + span: event as PreparedEvent, + }); + + const ancestorCancelled = overrides?.isCancelled ?? false; + const ancestorIsError = overrides?.isError ?? false; + const duration = overrides?.duration ?? event.duration; + const spanEvents = [...(overrides?.events ?? []), ...(event.events ?? [])]; + const isPartial = ancestorCancelled || ancestorIsError ? false : event.isPartial; + const isCancelled = event.isCancelled === true ? true : event.isPartial && ancestorCancelled; + const isError = isCancelled + ? false + : typeof overrides?.isError === "boolean" + ? overrides.isError + : event.isError; + + const properties = event.properties + ? removePrivateProperties(event.properties as Attributes) + : {}; + + const spanDetailedSummary: SpanDetailedSummary = { + id: event.spanId, + parentId: event.parentId ?? undefined, + runId: event.runId, + data: { + message: event.message, + taskSlug: event.taskSlug ?? undefined, + events: spanEvents?.filter((e) => !e.name.startsWith("trigger.dev")), + startTime: getDateFromNanoseconds(event.startTime), + duration: nanosecondsToMilliseconds(duration), + isError, + isPartial, + isCancelled, + level: event.level, + properties, + }, + children: [], + }; + + spanDetailedSummaryMap.set(event.spanId, spanDetailedSummary); + } + + for (const spanSummary of spanDetailedSummaryMap.values()) { + if (spanSummary.parentId) { + const parent = spanDetailedSummaryMap.get(spanSummary.parentId); + if (parent) { + parent.children.push(spanSummary); + } + } + } + + const rootSpan = spanDetailedSummaryMap.get(resolvedRootSpanId); + + if (!rootSpan) { + return; + } + + return { + traceId, + rootSpan, + }; +} + function prepareDetailedEvent(event: DetailedTraceEvent): PreparedDetailedEvent { return { ...event, diff --git a/apps/webapp/app/v3/eventRepository/eventRepository.types.ts b/apps/webapp/app/v3/eventRepository/eventRepository.types.ts index 591c7927a58..4b7db3f870a 100644 --- a/apps/webapp/app/v3/eventRepository/eventRepository.types.ts +++ b/apps/webapp/app/v3/eventRepository/eventRepository.types.ts @@ -308,6 +308,8 @@ export type TraceSummary = { rootSpan: SpanSummary; spans: Array; overridesBySpanId?: Record; + /** Set when a subtree fetch hit the row cap before collecting all descendants. */ + isTruncated?: boolean; }; export type SpanDetailedSummary = { @@ -351,6 +353,8 @@ export type StreamedTraceEvent = { export type TraceDetailedSummary = { traceId: string; rootSpan: SpanDetailedSummary; + /** Set when a fetch hit the row cap before collecting all spans. */ + isTruncated?: boolean; }; // ============================================================================ @@ -416,6 +420,17 @@ export interface IEventRepository { options?: { includeDebugLogs?: boolean } ): Promise; + /** Fetch the anchor span, its ancestors (for override propagation), and all descendants. */ + getTraceSubtreeSummary( + storeTable: TaskEventStoreTable, + environmentId: string, + traceId: string, + anchorSpanId: string, + startCreatedAt: Date, + endCreatedAt?: Date, + options?: { includeDebugLogs?: boolean } + ): Promise; + getTraceDetailedSummary( storeTable: TaskEventStoreTable, environmentId: string, @@ -425,6 +440,17 @@ export interface IEventRepository { options?: { includeDebugLogs?: boolean } ): Promise; + /** Fetch the anchor span subtree as a detailed hierarchical trace rooted at anchorSpanId. */ + getTraceDetailedSubtreeSummary( + storeTable: TaskEventStoreTable, + environmentId: string, + traceId: string, + anchorSpanId: string, + startCreatedAt: Date, + endCreatedAt?: Date, + options?: { includeDebugLogs?: boolean } + ): Promise; + // Streams a trace's events in start_time order, one at a time, without ever // materialising the full result set or a tree. Powers the streaming trace // export so arbitrarily large traces download with bounded memory. diff --git a/apps/webapp/app/v3/mollifier/syntheticTrace.server.ts b/apps/webapp/app/v3/mollifier/syntheticTrace.server.ts index e36aaa19e62..4a1c6129f7d 100644 --- a/apps/webapp/app/v3/mollifier/syntheticTrace.server.ts +++ b/apps/webapp/app/v3/mollifier/syntheticTrace.server.ts @@ -84,5 +84,7 @@ export function buildSyntheticTraceForBufferedRun(run: SyntheticRun) { queuedDuration: undefined, overridesBySpanId: undefined, linkedRunIdBySpanId: {} as Record, + isTruncated: false, + missingAnchor: false, }; } diff --git a/apps/webapp/test/getTraceDetailedSubtreeSummary.integration.test.ts b/apps/webapp/test/getTraceDetailedSubtreeSummary.integration.test.ts new file mode 100644 index 00000000000..d389003a44d --- /dev/null +++ b/apps/webapp/test/getTraceDetailedSubtreeSummary.integration.test.ts @@ -0,0 +1,428 @@ +import { ClickHouse, type TaskEventV2Input } from "@internal/clickhouse"; +import { clickhouseTest } from "@internal/testcontainers"; +import { describe, expect } from "vitest"; +import type { SpanDetailedSummary } from "~/v3/eventRepository/eventRepository.types"; +import { + ClickhouseEventRepository, + convertDateToClickhouseDateTime, +} from "~/v3/eventRepository/clickhouseEventRepository.server"; + +/** + * Proves getTraceDetailedSubtreeSummary (used by GET /api/v1/runs/:runId/trace) + * returns a tree rooted at the requested run's span — not the trace-wide root. + * + * Reproduces the large-trace failure mode: a full-trace fetch is capped by + * ORDER BY start_time ASC LIMIT N, so late spans are excluded. Subtree fetch + * looks up the anchor span directly and still returns the run-scoped tree. + */ +const INTEGRATION_TIMEOUT_MS = 60_000; +const TRACE_ROW_LIMIT = 50; +const FILLER_COUNT = 60; + +function startTimeNs(baseMs: number, offsetMs: number): string { + return ((BigInt(baseMs) + BigInt(offsetMs)) * 1_000_000n).toString(); +} + +function formatClickhouseStartTime(baseMs: number, offsetMs: number): string { + const nanoseconds = startTimeNs(baseMs, offsetMs); + if (nanoseconds.length !== 19) { + return nanoseconds; + } + + return `${nanoseconds.substring(0, 10)}.${nanoseconds.substring(10)}`; +} + +function findSpan( + span: SpanDetailedSummary | undefined, + spanId: string +): SpanDetailedSummary | undefined { + if (!span) { + return undefined; + } + if (span.id === spanId) { + return span; + } + for (const child of span.children) { + const found = findSpan(child, spanId); + if (found) { + return found; + } + } + return undefined; +} + +describe("getTraceDetailedSubtreeSummary", () => { + clickhouseTest( + "roots the API trace at the requested run span even when it is outside the full-trace row cap", + async ({ clickhouseContainer }) => { + const clickhouse = new ClickHouse({ + url: clickhouseContainer.getConnectionUrl(), + logLevel: "warn", + }); + + const repository = new ClickhouseEventRepository({ + clickhouse, + version: "v2", + maximumTraceDetailedSummaryViewCount: TRACE_ROW_LIMIT, + }); + + const environmentId = "env_trace_subtree_test"; + const organizationId = "org_trace_subtree_test"; + const projectId = "proj_trace_subtree_test"; + const traceId = "a".repeat(32); + const spanRoot = "rootspan00000001"; + const spanChild = "childspan0000001"; + const spanGrandchild = "grandchildspan01"; + const runRoot = "run_root_task_run"; + const runChild = "run_child_task_run"; + const baseMs = Date.now(); + const runCreatedAt = new Date(baseMs - 60_000); + const expiresAt = convertDateToClickhouseDateTime( + new Date(baseMs + 365 * 24 * 60 * 60 * 1000) + ); + + function makeSpanRow({ + spanId, + parentSpanId, + runId, + startOffsetMs, + message, + }: { + spanId: string; + parentSpanId: string; + runId: string; + startOffsetMs: number; + message: string; + }): TaskEventV2Input { + return { + environment_id: environmentId, + organization_id: organizationId, + project_id: projectId, + task_identifier: "subtree-test-task", + run_id: runId, + start_time: formatClickhouseStartTime(baseMs, startOffsetMs), + duration: "1000000", + trace_id: traceId, + span_id: spanId, + parent_span_id: parentSpanId, + message, + kind: "SPAN", + status: "OK", + attributes: {}, + metadata: "{}", + expires_at: expiresAt, + }; + } + + const rows: TaskEventV2Input[] = [ + makeSpanRow({ + spanId: spanRoot, + parentSpanId: "", + runId: runRoot, + startOffsetMs: 0, + message: "root task", + }), + ...Array.from({ length: FILLER_COUNT }, (_, index) => + makeSpanRow({ + spanId: `filler${String(index).padStart(10, "0")}`, + parentSpanId: spanRoot, + runId: runRoot, + startOffsetMs: index + 1, + message: `filler span ${index}`, + }) + ), + makeSpanRow({ + spanId: spanChild, + parentSpanId: spanRoot, + runId: runChild, + startOffsetMs: 100_000, + message: "child task", + }), + makeSpanRow({ + spanId: spanGrandchild, + parentSpanId: spanChild, + runId: runChild, + startOffsetMs: 100_001, + message: "grandchild span", + }), + ]; + + const [insertError] = await clickhouse.taskEventsV2.insert(rows, { + clickhouse_settings: { async_insert: 0 }, + }); + expect(insertError).toBeNull(); + + const fullTrace = await repository.getTraceDetailedSummary( + "taskEvent", + environmentId, + traceId, + runCreatedAt + ); + + expect(fullTrace?.rootSpan.id).toBe(spanRoot); + expect(fullTrace?.isTruncated).toBe(true); + expect(findSpan(fullTrace?.rootSpan, spanChild)).toBeUndefined(); + + const subtree = await repository.getTraceDetailedSubtreeSummary( + "taskEvent", + environmentId, + traceId, + spanChild, + runCreatedAt + ); + + expect(subtree).toBeDefined(); + expect(subtree!.isTruncated).toBe(false); + expect(subtree!.traceId).toBe(traceId); + expect(subtree!.rootSpan.id).toBe(spanChild); + expect(subtree!.rootSpan.runId).toBe(runChild); + expect(subtree!.rootSpan.parentId).toBe(spanRoot); + expect(subtree!.rootSpan.children).toHaveLength(1); + expect(subtree!.rootSpan.children[0]?.id).toBe(spanGrandchild); + expect(subtree!.rootSpan.children[0]?.runId).toBe(runChild); + }, + INTEGRATION_TIMEOUT_MS + ); + + clickhouseTest( + "loads ancestors outside the anchor run time window for override propagation", + async ({ clickhouseContainer }) => { + const clickhouse = new ClickHouse({ + url: clickhouseContainer.getConnectionUrl(), + logLevel: "warn", + }); + + const repository = new ClickhouseEventRepository({ + clickhouse, + version: "v2", + maximumTraceDetailedSummaryViewCount: TRACE_ROW_LIMIT, + }); + + const environmentId = "env_trace_subtree_ancestor"; + const organizationId = "org_trace_subtree_ancestor"; + const projectId = "proj_trace_subtree_ancestor"; + const traceId = "b".repeat(32); + const spanRoot = "rootspan00000002"; + const spanChild = "childspan0000002"; + const runRoot = "run_root_cancelled"; + const runChild = "run_child_partial"; + const baseMs = Date.now(); + const childRunCreatedAt = new Date(baseMs + 100_000); + const expiresAt = convertDateToClickhouseDateTime( + new Date(baseMs + 365 * 24 * 60 * 60 * 1000) + ); + + function makeSpanRow({ + spanId, + parentSpanId, + runId, + startOffsetMs, + insertedOffsetMs, + message, + status, + }: { + spanId: string; + parentSpanId: string; + runId: string; + startOffsetMs: number; + insertedOffsetMs: number; + message: string; + status: string; + }): TaskEventV2Input { + return { + environment_id: environmentId, + organization_id: organizationId, + project_id: projectId, + task_identifier: "subtree-ancestor-test-task", + run_id: runId, + start_time: formatClickhouseStartTime(baseMs, startOffsetMs), + inserted_at: convertDateToClickhouseDateTime(new Date(baseMs + insertedOffsetMs)), + duration: "5000000000", + trace_id: traceId, + span_id: spanId, + parent_span_id: parentSpanId, + message, + kind: "SPAN", + status, + attributes: {}, + metadata: "{}", + expires_at: expiresAt, + }; + } + + const rows: TaskEventV2Input[] = [ + makeSpanRow({ + spanId: spanRoot, + parentSpanId: "", + runId: runRoot, + startOffsetMs: 0, + insertedOffsetMs: 0, + message: "root task", + status: "PARTIAL", + }), + makeSpanRow({ + spanId: spanRoot, + parentSpanId: "", + runId: runRoot, + startOffsetMs: 5_000, + insertedOffsetMs: 5_000, + message: "root task", + status: "CANCELLED", + }), + makeSpanRow({ + spanId: spanChild, + parentSpanId: spanRoot, + runId: runChild, + startOffsetMs: 100_000, + insertedOffsetMs: 100_000, + message: "child task", + status: "PARTIAL", + }), + ]; + + const [insertError] = await clickhouse.taskEventsV2.insert(rows, { + clickhouse_settings: { async_insert: 0 }, + }); + expect(insertError).toBeNull(); + + const subtree = await repository.getTraceDetailedSubtreeSummary( + "taskEvent", + environmentId, + traceId, + spanChild, + childRunCreatedAt + ); + + expect(subtree).toBeDefined(); + expect(subtree!.rootSpan.id).toBe(spanChild); + expect(subtree!.rootSpan.parentId).toBe(spanRoot); + expect(subtree!.rootSpan.data.isPartial).toBe(false); + expect(subtree!.rootSpan.data.isCancelled).toBe(true); + }, + INTEGRATION_TIMEOUT_MS + ); + + clickhouseTest( + "re-roots from a single full-trace query when the anchor and parent are inside the row cap", + async ({ clickhouseContainer }) => { + const clickhouse = new ClickHouse({ + url: clickhouseContainer.getConnectionUrl(), + logLevel: "warn", + }); + + const repository = new ClickhouseEventRepository({ + clickhouse, + version: "v2", + maximumTraceDetailedSummaryViewCount: TRACE_ROW_LIMIT, + }); + + const environmentId = "env_trace_subtree_fast_path"; + const organizationId = "org_trace_subtree_fast_path"; + const projectId = "proj_trace_subtree_fast_path"; + const traceId = "c".repeat(32); + const spanRoot = "rootspan00000003"; + const spanChild = "childspan0000003"; + const spanGrandchild = "grandchildspan03"; + const runRoot = "run_root_fast_path"; + const runChild = "run_child_fast_path"; + const baseMs = Date.now(); + const runCreatedAt = new Date(baseMs - 60_000); + const expiresAt = convertDateToClickhouseDateTime( + new Date(baseMs + 365 * 24 * 60 * 60 * 1000) + ); + + function makeSpanRow({ + spanId, + parentSpanId, + runId, + startOffsetMs, + message, + status = "OK", + }: { + spanId: string; + parentSpanId: string; + runId: string; + startOffsetMs: number; + message: string; + status?: string; + }): TaskEventV2Input { + return { + environment_id: environmentId, + organization_id: organizationId, + project_id: projectId, + task_identifier: "subtree-fast-path-task", + run_id: runId, + start_time: formatClickhouseStartTime(baseMs, startOffsetMs), + inserted_at: convertDateToClickhouseDateTime(new Date(baseMs + startOffsetMs)), + duration: "1000000", + trace_id: traceId, + span_id: spanId, + parent_span_id: parentSpanId, + message, + kind: "SPAN", + status, + attributes: {}, + metadata: "{}", + expires_at: expiresAt, + }; + } + + const rows: TaskEventV2Input[] = [ + makeSpanRow({ + spanId: spanRoot, + parentSpanId: "", + runId: runRoot, + startOffsetMs: 0, + message: "root task", + status: "PARTIAL", + }), + makeSpanRow({ + spanId: spanRoot, + parentSpanId: "", + runId: runRoot, + startOffsetMs: 5_000, + message: "root task", + status: "CANCELLED", + }), + makeSpanRow({ + spanId: spanChild, + parentSpanId: spanRoot, + runId: runChild, + startOffsetMs: 10_000, + message: "child task", + status: "PARTIAL", + }), + makeSpanRow({ + spanId: spanGrandchild, + parentSpanId: spanChild, + runId: runChild, + startOffsetMs: 10_001, + message: "grandchild span", + }), + ]; + + const [insertError] = await clickhouse.taskEventsV2.insert(rows, { + clickhouse_settings: { async_insert: 0 }, + }); + expect(insertError).toBeNull(); + + const subtree = await repository.getTraceDetailedSubtreeSummary( + "taskEvent", + environmentId, + traceId, + spanChild, + runCreatedAt + ); + + expect(subtree).toBeDefined(); + expect(subtree!.isTruncated).toBe(false); + expect(subtree!.rootSpan.id).toBe(spanChild); + expect(subtree!.rootSpan.parentId).toBe(spanRoot); + expect(subtree!.rootSpan.data.isPartial).toBe(false); + expect(subtree!.rootSpan.data.isCancelled).toBe(true); + expect(subtree!.rootSpan.children).toHaveLength(1); + expect(subtree!.rootSpan.children[0]?.id).toBe(spanGrandchild); + }, + INTEGRATION_TIMEOUT_MS + ); +}); diff --git a/apps/webapp/test/member.server.test.ts b/apps/webapp/test/member.server.test.ts new file mode 100644 index 00000000000..5ed0126332d --- /dev/null +++ b/apps/webapp/test/member.server.test.ts @@ -0,0 +1,318 @@ +import { randomBytes } from "node:crypto"; +import { describe, expect, vi } from "vitest"; +import type { PrismaClient } from "@trigger.dev/database"; + +const prismaHolder = vi.hoisted(() => ({ + client: null as PrismaClient | null, +})); + +vi.mock("~/services/rbac.server", () => ({ + rbac: { + setUserRole: async () => ({ ok: true as const }), + }, +})); + +vi.mock("~/db.server", () => ({ + get prisma() { + if (!prismaHolder.client) { + throw new Error("test prisma not set"); + } + return prismaHolder.client; + }, + get $replica() { + if (!prismaHolder.client) { + throw new Error("test prisma not set"); + } + return prismaHolder.client; + }, +})); + +import { postgresTest } from "@internal/testcontainers"; + +vi.setConfig({ testTimeout: 60_000 }); + +function randomHex(len = 12): string { + return randomBytes(Math.ceil(len / 2)) + .toString("hex") + .slice(0, len); +} + +async function seedInviteFixture( + prisma: PrismaClient, + opts: { activeProjectCount: number; deletedProjectCount?: number } +) { + const suffix = randomHex(8); + const inviter = await prisma.user.create({ + data: { + email: `inviter-${suffix}@test.local`, + authenticationMethod: "MAGIC_LINK", + }, + }); + const invitee = await prisma.user.create({ + data: { + email: `invitee-${suffix}@test.local`, + authenticationMethod: "MAGIC_LINK", + }, + }); + + const organization = await prisma.organization.create({ + data: { + title: `invite-org-${suffix}`, + slug: `invite-org-${suffix}`, + v3Enabled: true, + members: { create: { userId: inviter.id, role: "ADMIN" } }, + }, + }); + + const activeProjects = []; + for (let i = 0; i < opts.activeProjectCount; i++) { + activeProjects.push( + await prisma.project.create({ + data: { + name: `active-project-${i}-${suffix}`, + slug: `active-proj-${i}-${suffix}`, + externalRef: `proj_active_${i}_${suffix}`, + organizationId: organization.id, + engine: "V2", + }, + }) + ); + } + + const deletedProjectCount = opts.deletedProjectCount ?? 0; + for (let i = 0; i < deletedProjectCount; i++) { + await prisma.project.create({ + data: { + name: `deleted-project-${i}-${suffix}`, + slug: `deleted-proj-${i}-${suffix}`, + externalRef: `proj_deleted_${i}_${suffix}`, + organizationId: organization.id, + engine: "V2", + deletedAt: new Date(), + }, + }); + } + + const invite = await prisma.orgMemberInvite.create({ + data: { + email: invitee.email, + organizationId: organization.id, + inviterId: inviter.id, + role: "MEMBER", + }, + }); + + return { inviter, invitee, organization, activeProjects, invite }; +} + +function devEnvKeys(apiKey: string, pkApiKey: string) { + return { apiKey, pkApiKey, shortcode: randomHex(4) }; +} + +describe("acceptInvite", () => { + postgresTest( + "creates member and dev environments for active projects only (many projects)", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { acceptInvite } = await import("../app/models/member.server"); + + const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 25, + deletedProjectCount: 3, + }); + + const beforeEnvCount = await prisma.runtimeEnvironment.count(); + + const { organization: joinedOrg } = await acceptInvite({ + inviteId: invite.id, + user: { id: invitee.id, email: invitee.email }, + }); + + expect(joinedOrg.id).toBe(organization.id); + + const member = await prisma.orgMember.findFirst({ + where: { userId: invitee.id, organizationId: organization.id }, + }); + expect(member).not.toBeNull(); + + const devEnvs = await prisma.runtimeEnvironment.findMany({ + where: { + organizationId: organization.id, + orgMemberId: member!.id, + type: "DEVELOPMENT", + }, + }); + expect(devEnvs).toHaveLength(activeProjects.length); + + const envProjectIds = new Set(devEnvs.map((e) => e.projectId)); + for (const project of activeProjects) { + expect(envProjectIds.has(project.id)).toBe(true); + } + + const newEnvCount = await prisma.runtimeEnvironment.count(); + expect(newEnvCount - beforeEnvCount).toBe(activeProjects.length); + } + ); + + postgresTest( + "rejects wrong email without creating member or environments", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server"); + + const { invitee, organization, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 2, + }); + + const beforeMemberCount = await prisma.orgMember.count({ + where: { organizationId: organization.id, userId: invitee.id }, + }); + const beforeEnvCount = await prisma.runtimeEnvironment.count(); + + await expect( + acceptInvite({ + inviteId: invite.id, + user: { id: invitee.id, email: "wrong@example.com" }, + }) + ).rejects.toThrow(INVITE_NOT_FOUND); + + const afterMemberCount = await prisma.orgMember.count({ + where: { organizationId: organization.id, userId: invitee.id }, + }); + expect(afterMemberCount).toBe(beforeMemberCount); + + const afterEnvCount = await prisma.runtimeEnvironment.count(); + expect(afterEnvCount).toBe(beforeEnvCount); + } + ); + + postgresTest( + "rejects already consumed invite with normalized error", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server"); + + const { invitee, organization, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 1, + }); + + await prisma.orgMemberInvite.delete({ where: { id: invite.id } }); + + await expect( + acceptInvite({ + inviteId: invite.id, + user: { id: invitee.id, email: invitee.email }, + }) + ).rejects.toThrow(INVITE_NOT_FOUND); + + const member = await prisma.orgMember.findFirst({ + where: { userId: invitee.id, organizationId: organization.id }, + }); + expect(member).toBeNull(); + } + ); +}); + +describe("provisionMemberDevelopmentEnvironments", () => { + postgresTest( + "throws partial-success error when env creation fails mid-loop", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { provisionMemberDevelopmentEnvironments, ENV_SETUP_INCOMPLETE } = + await import("../app/models/member.server"); + + const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 2, + }); + + const member = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: invitee.id, + role: "MEMBER", + }, + }); + + await expect( + provisionMemberDevelopmentEnvironments({ + inviteId: invite.id, + user: { id: invitee.id, email: invitee.email }, + member, + organization, + projects: [activeProjects[0], { id: "missing-project-id" }, activeProjects[1]], + }) + ).rejects.toThrow(ENV_SETUP_INCOMPLETE); + + const devEnvs = await prisma.runtimeEnvironment.findMany({ + where: { + organizationId: organization.id, + orgMemberId: member.id, + type: "DEVELOPMENT", + }, + }); + + expect(devEnvs).toHaveLength(1); + expect(devEnvs[0]?.projectId).toBe(activeProjects[0].id); + } + ); + + postgresTest( + "acceptInvite resumes provisioning when member exists and invite is still pending", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { acceptInvite } = await import("../app/models/member.server"); + + const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 3, + }); + + const member = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: invitee.id, + role: "MEMBER", + }, + }); + + for (const project of activeProjects.slice(0, 2)) { + const keys = devEnvKeys(`tr_dev_${randomHex(24)}`, `pk_dev_${randomHex(24)}`); + await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "DEVELOPMENT", + ...keys, + projectId: project.id, + organizationId: organization.id, + orgMemberId: member.id, + }, + }); + } + + const { organization: joinedOrg } = await acceptInvite({ + inviteId: invite.id, + user: { id: invitee.id, email: invitee.email }, + }); + + expect(joinedOrg.id).toBe(organization.id); + + const devEnvs = await prisma.runtimeEnvironment.findMany({ + where: { + organizationId: organization.id, + orgMemberId: member.id, + type: "DEVELOPMENT", + }, + }); + expect(devEnvs).toHaveLength(activeProjects.length); + + const consumedInvite = await prisma.orgMemberInvite.findFirst({ + where: { id: invite.id }, + }); + expect(consumedInvite).toBeNull(); + } + ); +}); diff --git a/docs/management/runs/retrieve-trace.mdx b/docs/management/runs/retrieve-trace.mdx index 668718cf76a..9aa237108d1 100644 --- a/docs/management/runs/retrieve-trace.mdx +++ b/docs/management/runs/retrieve-trace.mdx @@ -2,3 +2,7 @@ title: "Retrieve run trace" openapi: "v3-openapi GET /api/v1/runs/{runId}/trace" --- + +Returns the OpenTelemetry trace subtree for the run you request. The response `trace.rootSpan` is that run's span — not necessarily the trace-wide root — with its descendant spans nested under `children`. + +For a child or nested run inside a large trace, this endpoint scopes the tree to that run so you still get a useful subtree even when the full trace has more spans than the platform can return in one response. diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index 63700c90dc0..7c6fd78c1dc 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -448,7 +448,7 @@ paths: get: operationId: get_run_trace_v1 summary: Retrieve run trace - description: Returns the full OTel trace tree for a run, including all spans and their children. + description: Returns the OTel trace subtree for the requested run — the run's span as `rootSpan`, its ancestor chain, and its descendant spans. For child or nested runs in a large trace, this is scoped to that run rather than the trace-wide root. responses: "200": description: Successful request @@ -464,7 +464,9 @@ paths: type: string description: The OTel trace ID. rootSpan: - $ref: "#/components/schemas/SpanDetailedSummary" + allOf: + - $ref: "#/components/schemas/SpanDetailedSummary" + description: The requested run's span, with nested descendant spans as `children`. Not necessarily the trace-wide root span. "401": description: Unauthorized request content: