From 432c3601bcc8547096cf3f8b0e4e1e91bde6c133 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Jan 2026 11:32:26 +0000 Subject: [PATCH] feat: add reverse replay tracking with "Replayed as" section in Details tab - Add replayed_from_friendly_id column to ClickHouse task_runs_v2 table - Create migration 012_add_task_runs_v2_replayed_from.sql - Update replication service to include the new field - Add getRunReplays query to ClickHouse client (optimized with org/project/env filter) - Update SpanPresenter to query replays from ClickHouse - Add "Replayed as" section in run Details tab showing linked replay runs with status The ClickHouse query filters by organization_id, project_id, and environment_id in the correct order to match the primary key for optimal query performance. This enables users to see which runs have been replayed from the original run, addressing the feedback about tracking replay status of failed runs. Slack thread: https://triggerdotdev.slack.com/archives/C045W9WM3E1/p1767609682537389 --- .../app/presenters/v3/SpanPresenter.server.ts | 51 +++++++++++++++++++ .../route.tsx | 34 +++++++++++++ .../services/runsReplicationService.server.ts | 1 + .../012_add_task_runs_v2_replayed_from.sql | 10 ++++ internal-packages/clickhouse/src/index.ts | 2 + internal-packages/clickhouse/src/taskRuns.ts | 39 ++++++++++++++ 6 files changed, 137 insertions(+) create mode 100644 internal-packages/clickhouse/schema/012_add_task_runs_v2_replayed_from.sql diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 4c0e3405cf..a4c4f82e76 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -9,6 +9,7 @@ import { } from "@trigger.dev/core/v3"; import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic"; import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; import { logger } from "~/services/logger.server"; import { rehydrateAttribute } from "~/v3/eventRepository/eventRepository.server"; import { machinePresetFromRun } from "~/v3/machinePresets.server"; @@ -210,6 +211,14 @@ export class SpanPresenter extends BasePresenter { region = workerGroup ?? null; } + // Query for runs that are replays of this run (from ClickHouse) + const replays = await this.#getRunReplays( + run.project.organization.id, + run.project.id, + run.runtimeEnvironment.id, + run.friendlyId + ); + return { id: run.id, friendlyId: run.friendlyId, @@ -276,9 +285,51 @@ export class SpanPresenter extends BasePresenter { machinePreset: machine?.name, taskEventStore: run.taskEventStore, externalTraceId, + replays, }; } + async #getRunReplays( + organizationId: string, + projectId: string, + environmentId: string, + friendlyId: string + ): Promise> { + try { + const [error, result] = await clickhouseClient.taskRuns.getRunReplays({ + organizationId, + projectId, + environmentId, + replayedFromFriendlyId: friendlyId, + }); + + if (error) { + logger.error("Error fetching run replays from ClickHouse", { + error, + organizationId, + projectId, + environmentId, + friendlyId, + }); + return []; + } + + return result.map((row) => ({ + friendlyId: row.friendly_id, + status: row.status, + })); + } catch (error) { + logger.error("Error fetching run replays from ClickHouse", { + error, + organizationId, + projectId, + environmentId, + friendlyId, + }); + return []; + } + } + async resolveSchedule(scheduleId?: string) { if (!scheduleId) { return; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index e8e472bfc7..39c5ee834c 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -707,6 +707,40 @@ function RunBody({ )} + {run.replays && run.replays.length > 0 && ( + + Replayed as + +
+ {run.replays.map((replay) => ( + + + + + } + content={`Jump to replay run`} + disableHoverableContent + /> + ))} +
+
+
+ )} {environment && ( Environment diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index c150eb2a00..ec8b365bdb 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -831,6 +831,7 @@ export class RunsReplicationService { concurrency_key: run.concurrencyKey ?? "", bulk_action_group_ids: run.bulkActionGroupIds ?? [], worker_queue: run.masterQueue, + replayed_from_friendly_id: run.replayedFromTaskRunFriendlyId ?? "", _version: _version.toString(), _is_deleted: event === "delete" ? 1 : 0, }; diff --git a/internal-packages/clickhouse/schema/012_add_task_runs_v2_replayed_from.sql b/internal-packages/clickhouse/schema/012_add_task_runs_v2_replayed_from.sql new file mode 100644 index 0000000000..ed35c137e2 --- /dev/null +++ b/internal-packages/clickhouse/schema/012_add_task_runs_v2_replayed_from.sql @@ -0,0 +1,10 @@ +-- +goose Up +/* +Add replayed_from_friendly_id column to track which run this was replayed from. + */ +ALTER TABLE trigger_dev.task_runs_v2 +ADD COLUMN replayed_from_friendly_id String DEFAULT ''; + +-- +goose Down +ALTER TABLE trigger_dev.task_runs_v2 +DROP COLUMN replayed_from_friendly_id; diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index 4aceeb92d8..14596d1882 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -12,6 +12,7 @@ import { getTaskUsageByOrganization, getTaskRunsCountQueryBuilder, getTaskRunTagsQueryBuilder, + getRunReplays, } from "./taskRuns.js"; import { getSpanDetailsQueryBuilder, @@ -164,6 +165,7 @@ export class ClickHouse { getCurrentRunningStats: getCurrentRunningStats(this.reader), getAverageDurations: getAverageDurations(this.reader), getTaskUsageByOrganization: getTaskUsageByOrganization(this.reader), + getRunReplays: getRunReplays(this.reader), }; } diff --git a/internal-packages/clickhouse/src/taskRuns.ts b/internal-packages/clickhouse/src/taskRuns.ts index b2410ee4b6..d9e5e96c33 100644 --- a/internal-packages/clickhouse/src/taskRuns.ts +++ b/internal-packages/clickhouse/src/taskRuns.ts @@ -45,6 +45,7 @@ export const TaskRunV2 = z.object({ concurrency_key: z.string().default(""), bulk_action_group_ids: z.array(z.string()).default([]), worker_queue: z.string().default(""), + replayed_from_friendly_id: z.string().default(""), _version: z.string(), _is_deleted: z.number().int().default(0), }); @@ -305,3 +306,41 @@ export function getTaskUsageByOrganization(ch: ClickhouseReader, settings?: Clic settings, }); } + +export const RunReplayQueryResult = z.object({ + friendly_id: z.string(), + status: z.string(), +}); + +export type RunReplayQueryResult = z.infer; + +export const RunReplayQueryParams = z.object({ + organizationId: z.string(), + projectId: z.string(), + environmentId: z.string(), + replayedFromFriendlyId: z.string(), +}); + +export function getRunReplays(ch: ClickhouseReader, settings?: ClickHouseSettings) { + return ch.query({ + name: "getRunReplays", + query: ` + SELECT + friendly_id, + status + FROM trigger_dev.task_runs_v2 FINAL + WHERE + organization_id = {organizationId: String} + AND project_id = {projectId: String} + AND environment_id = {environmentId: String} + AND replayed_from_friendly_id = {replayedFromFriendlyId: String} + AND _is_deleted = 0 + ORDER BY + created_at DESC + LIMIT 10 + `, + schema: RunReplayQueryResult, + params: RunReplayQueryParams, + settings, + }); +}