Compare commits

...

1 Commits

Author SHA1 Message Date
Claude 432c3601bc 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
2026-01-05 11:38:52 +00:00
6 changed files with 137 additions and 0 deletions
@@ -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<Array<{ friendlyId: string; status: string }>> {
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;
@@ -707,6 +707,40 @@ function RunBody({
</Property.Value>
</Property.Item>
)}
{run.replays && run.replays.length > 0 && (
<Property.Item>
<Property.Label>Replayed as</Property.Label>
<Property.Value>
<div className="flex flex-col gap-1">
{run.replays.map((replay) => (
<SimpleTooltip
key={replay.friendlyId}
button={
<TextLink
to={v3RunRedirectPath(organization, project, {
friendlyId: replay.friendlyId,
})}
className="flex items-center gap-1"
>
<CopyableText
value={replay.friendlyId}
copyValue={replay.friendlyId}
asChild
/>
<TaskRunStatusCombo
status={replay.status as any}
className="text-xs"
/>
</TextLink>
}
content={`Jump to replay run`}
disableHoverableContent
/>
))}
</div>
</Property.Value>
</Property.Item>
)}
{environment && (
<Property.Item>
<Property.Label>Environment</Property.Label>
@@ -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,
};
@@ -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;
@@ -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),
};
}
@@ -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<typeof RunReplayQueryResult>;
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,
});
}