Skip to content

Commit 486ec62

Browse files
committed
feat(webapp): deployment lifecycle telemetry events per build path
Replaces the deployment.outcome span with a wide deployment.lifecycle event emitted once per terminal transition (DEPLOYED/FAILED/TIMED_OUT/ CANCELED), backdated createdAt-to-terminal, carrying build path (depot/ native/local_bundle), per-phase durations derived from the persisted timestamp chain, error class, org/project/env, runtime, CLI version and trigger source as attributes. A zero-duration deployment.initialized event at creation provides the funnel denominator for stuck-deployment detection. Events are emitted on ROOT_CONTEXT with the forceRecording attribute: the previous span was started under the ambient request context, where the parent-based sampler drops ~95% of traffic before the force-record check runs. SEMINTATTRS_FORCE_RECORDING is now exported for this. The fail, timeout and finalize transitions now use guarded updateMany writes so exactly one caller commits a terminal status and emits the event; this also stops a late timeout from overwriting DEPLOYED. The cancel path now emits too (it previously recorded nothing). Also: cliVersion is stamped onto WorkerDeployment at initialization from the x-trigger-cli-version header (previously only available post-index via BackgroundWorker, i.e. null for pre-index failures); an optional second OTLP exporter (INTERNAL_OTEL_DEPLOYMENT_EVENT_EXPORTER_URL) mirrors deployment.* spans into a dedicated dataset; the tracer provider is flushed on SIGTERM/SIGINT so shutdowns stop dropping the last batch.
1 parent 036cf8d commit 486ec62

16 files changed

Lines changed: 680 additions & 96 deletions

apps/webapp/app/env.server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,12 @@ const EnvironmentSchema = z
935935
DISABLE_HTTP_INSTRUMENTATION: BoolEnv.default(false),
936936

937937
INTERNAL_OTEL_LOG_EXPORTER_URL: z.string().optional(),
938+
939+
// Optional second OTLP trace exporter that receives only `deployment.*`
940+
// spans (deployment lifecycle analytics), e.g. a dedicated long-retention
941+
// Axiom dataset. The spans also still flow to the main trace exporter.
942+
INTERNAL_OTEL_DEPLOYMENT_EVENT_EXPORTER_URL: z.string().optional(),
943+
INTERNAL_OTEL_DEPLOYMENT_EVENT_EXPORTER_AUTH_HEADERS: z.string().optional(),
938944
INTERNAL_OTEL_METRIC_EXPORTER_URL: z.string().optional(),
939945
INTERNAL_OTEL_METRIC_EXPORTER_AUTH_HEADERS: z.string().optional(),
940946
INTERNAL_OTEL_METRIC_EXPORTER_ENABLED: z.string().default("0"),

apps/webapp/app/routes/api.v1.deployments.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
4242
const service = new InitializeDeploymentService();
4343

4444
try {
45-
const result = await service.call(authenticatedEnv, body.data);
45+
const result = await service.call(authenticatedEnv, body.data, {
46+
cliVersion: request.headers.get("x-trigger-cli-version") ?? undefined,
47+
});
4648
const { deployment, imageRef } = result;
4749

4850
const responseBody: InitializeDeploymentResponseBody = {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Deployment telemetry attributes
2+
3+
`deploymentTelemetry.ts` is the single owner of these names. `deployment.lifecycle`
4+
(one wide event per terminal transition, span backdated createdAt → terminal) and
5+
`deployment.initialized` (zero-duration funnel event at creation) are emitted by
6+
`services/recordDeploymentLifecycle.server.ts`. Axiom queries, dashboards, and
7+
monitors reference these names — treat renames as breaking changes.
8+
9+
| Attribute | Events | Values / notes |
10+
| -------------------------------- | ---------------- | -------------------------------------------------------------------------- |
11+
| `$trigger.org.id` | both | Organization id |
12+
| `$trigger.project.id` | both | Project id |
13+
| `$trigger.project.ref` | both | Project external ref (`proj_…`) |
14+
| `$trigger.env.id` | both | Environment id |
15+
| `$trigger.env.type` | both | `PRODUCTION` / `STAGING` / `PREVIEW` / `DEVELOPMENT` |
16+
| `deployment.id` | both | Deployment friendly id — dedup key (`arg_max(_time, *) by deployment.id`) |
17+
| `deployment.version` | both | Deployment version, e.g. `20260825.3` |
18+
| `deployment.status` | both | lifecycle: terminal status; initialized: initial status (`PENDING`/`BUILDING`) |
19+
| `deployment.success` | lifecycle | `status === "DEPLOYED"`. CANCELED is excluded from failure rates |
20+
| `deployment.build_path` | both | `depot` / `native` / `local_bundle` (rare `--local-build` lands in `depot`) |
21+
| `deployment.worker_type` | both | `V1` / `MANAGED` (run engine) |
22+
| `deployment.runtime` | both | `node` / `node-22` / `bun` / … |
23+
| `deployment.runtime_version` | lifecycle | Set at indexing; null for pre-index failures |
24+
| `deployment.cli_version` | both | From `x-trigger-cli-version` at init; null for pre-column history |
25+
| `deployment.triggered_via` | both | e.g. `cli`, GitHub/Vercel integrations |
26+
| `deployment.commit_sha` | lifecycle | From git meta when present |
27+
| `deployment.error.name` | lifecycle | Error class from `errorData` (`TimeoutError`, build errors, …) |
28+
| `deployment.error.message` | lifecycle | Human-readable failure reason |
29+
| `deployment.canceled_reason` | lifecycle | Only on CANCELED |
30+
| `deployment.duration.total_ms` | lifecycle | createdAt → terminal (also the span's own duration) |
31+
| `deployment.duration.queue_ms` | lifecycle | createdAt → startedAt; ≈0 when created directly in BUILDING (depot) |
32+
| `deployment.duration.install_ms` | lifecycle | startedAt → installedAt; build-server paths only (depot never sets it) |
33+
| `deployment.duration.building_ms`| lifecycle | (installedAt ?? startedAt) → builtAt |
34+
| `deployment.duration.deploying_ms`| lifecycle | builtAt → terminal; for depot dominated by the server-side registry push |
35+
36+
The span's `_time` is the deployment's **createdAt**, so a TIMED_OUT event lands
37+
backdated by up to the full deploy timeout (~23 min at current defaults) — monitors
38+
must use windows longer than the max timeout or they will systematically miss the
39+
stuck deployments they exist to catch.
40+
41+
Phase durations are omitted (not zero) when a boundary timestamp is missing —
42+
timestamp chains are path-shaped. Compare only shared phases across build paths;
43+
`total_ms` excludes local-bundle's pre-init client work (esbuild + upload) until
44+
the CLI passes client timings.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { BuildServerMetadata } from "@trigger.dev/core/v3";
2+
3+
// Attribute names for the deployment telemetry events (see
4+
// DEPLOYMENT_TELEMETRY_ATTRIBUTES.md next to this file). This module is the single owner of these names — Axiom
5+
// queries, dashboards, and monitors reference them, so treat renames as
6+
// breaking changes.
7+
export const DeploymentTelemetryAttributes = {
8+
ORG_ID: "$trigger.org.id",
9+
PROJECT_ID: "$trigger.project.id",
10+
PROJECT_REF: "$trigger.project.ref",
11+
ENV_ID: "$trigger.env.id",
12+
ENV_TYPE: "$trigger.env.type",
13+
DEPLOYMENT_ID: "deployment.id",
14+
VERSION: "deployment.version",
15+
STATUS: "deployment.status",
16+
SUCCESS: "deployment.success",
17+
BUILD_PATH: "deployment.build_path",
18+
WORKER_TYPE: "deployment.worker_type",
19+
RUNTIME: "deployment.runtime",
20+
RUNTIME_VERSION: "deployment.runtime_version",
21+
CLI_VERSION: "deployment.cli_version",
22+
TRIGGERED_VIA: "deployment.triggered_via",
23+
COMMIT_SHA: "deployment.commit_sha",
24+
ERROR_NAME: "deployment.error.name",
25+
ERROR_MESSAGE: "deployment.error.message",
26+
CANCELED_REASON: "deployment.canceled_reason",
27+
DURATION_TOTAL_MS: "deployment.duration.total_ms",
28+
DURATION_QUEUE_MS: "deployment.duration.queue_ms",
29+
DURATION_INSTALL_MS: "deployment.duration.install_ms",
30+
DURATION_BUILDING_MS: "deployment.duration.building_ms",
31+
DURATION_DEPLOYING_MS: "deployment.duration.deploying_ms",
32+
} as const;
33+
34+
export type DeploymentBuildPath = "local_bundle" | "native" | "depot";
35+
36+
/**
37+
* Classifies which build path produced a deployment, from its persisted
38+
* metadata. Everything that is not a native-build-server deployment falls into
39+
* the depot bucket — including rare `--local-build` deploys, whose flag is not
40+
* persisted. `externalBuildData` is NOT usable as a depot signal: init writes a
41+
* placeholder (`"-"` fields) for every path.
42+
*/
43+
export function deriveBuildPath(buildServerMetadata: unknown): DeploymentBuildPath {
44+
const metadata = BuildServerMetadata.safeParse(buildServerMetadata);
45+
46+
if (metadata.success && metadata.data.isNativeBuild) {
47+
return metadata.data.fromBundle ? "local_bundle" : "native";
48+
}
49+
50+
return "depot";
51+
}
52+
53+
export type DeploymentTimestamps = {
54+
createdAt: Date;
55+
startedAt?: Date | null;
56+
installedAt?: Date | null;
57+
builtAt?: Date | null;
58+
};
59+
60+
export type DeploymentDurations = {
61+
totalMs: number;
62+
queueMs?: number;
63+
installMs?: number;
64+
buildingMs?: number;
65+
deployingMs?: number;
66+
};
67+
68+
/**
69+
* Derives per-phase durations from the persisted timestamp chain
70+
* (createdAt → startedAt → installedAt → builtAt → terminal). Chains are
71+
* path-shaped: depot never sets installedAt (the /progress route is
72+
* build-server-only) and PENDING-skipping deploys have queue ≈ 0 — each phase
73+
* is emitted only when both of its boundary timestamps exist and are ordered.
74+
*/
75+
export function deriveDeploymentDurations(
76+
timestamps: DeploymentTimestamps,
77+
terminalAt: Date
78+
): DeploymentDurations {
79+
const { createdAt, startedAt, installedAt, builtAt } = timestamps;
80+
const buildingFrom = installedAt ?? startedAt;
81+
82+
return {
83+
totalMs: Math.max(terminalAt.getTime() - createdAt.getTime(), 0),
84+
queueMs: msBetween(createdAt, startedAt),
85+
installMs: msBetween(startedAt, installedAt),
86+
buildingMs: msBetween(buildingFrom, builtAt),
87+
deployingMs: msBetween(builtAt, terminalAt),
88+
};
89+
}
90+
91+
function msBetween(from?: Date | null, to?: Date | null): number | undefined {
92+
if (!from || !to) return undefined;
93+
const ms = to.getTime() - from.getTime();
94+
return ms >= 0 ? ms : undefined;
95+
}

apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
} from "./createBackgroundWorker.server";
1919
import { findOrCreateBackgroundWorker } from "./createDeploymentBackgroundWorkerV4/findOrCreateBackgroundWorker.server";
2020
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
21-
import { recordDeploymentOutcome } from "./recordDeploymentOutcome.server";
21+
import { recordDeploymentLifecycle } from "./recordDeploymentLifecycle.server";
2222
import { env } from "~/env.server";
2323
import { webhookPrisma } from "~/db.server";
2424

@@ -298,6 +298,12 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
298298
error: Error,
299299
environment: AuthenticatedEnvironment
300300
) {
301+
const failedAt = new Date();
302+
const errorData = {
303+
name: error.name,
304+
message: error.message,
305+
};
306+
301307
// Guarded BUILDING → FAILED transition, symmetric with the BUILDING → DEPLOYING
302308
// transition in `call()`. With idempotent retries, two attempts can run side-by-side;
303309
// without the predicate, one attempt's failure could downgrade the deployment after
@@ -309,11 +315,8 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
309315
},
310316
data: {
311317
status: "FAILED",
312-
failedAt: new Date(),
313-
errorData: {
314-
name: error.name,
315-
message: error.message,
316-
},
318+
failedAt,
319+
errorData,
317320
buildEnvVars: Prisma.DbNull,
318321
},
319322
});
@@ -332,13 +335,16 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
332335
// BUILDING → DEPLOYING transition.
333336
await TimeoutDeploymentService.dequeue(deployment.id, this._prisma);
334337

335-
recordDeploymentOutcome({
338+
recordDeploymentLifecycle({
336339
status: "FAILED",
337-
deploymentFriendlyId: deployment.friendlyId,
338-
organizationId: environment.organizationId,
339-
projectId: environment.projectId,
340-
environmentId: environment.id,
341-
environmentType: environment.type,
340+
deployment: { ...deployment, status: "FAILED", failedAt, errorData },
341+
environment: {
342+
organizationId: environment.organizationId,
343+
projectId: environment.projectId,
344+
projectRef: environment.project.externalRef,
345+
environmentId: environment.id,
346+
environmentType: environment.type,
347+
},
342348
reason: error.message,
343349
});
344350
}

apps/webapp/app/v3/services/deployment.server.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type DeploymentEvent,
1010
} from "@trigger.dev/core/v3";
1111
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
12+
import { recordDeploymentLifecycle } from "./recordDeploymentLifecycle.server";
1213
import { env } from "~/env.server";
1314
import { createRemoteImageBuild } from "../remoteImageBuilder.server";
1415
import { FINAL_DEPLOYMENT_STATUSES } from "./failDeployment.server";
@@ -238,6 +239,8 @@ export class DeploymentService extends BaseService {
238239
if (result.count === 0) {
239240
return errAsync({ type: "deployment_cannot_be_cancelled" as const });
240241
}
242+
// Fire-and-forget: telemetry must never affect the cancel result.
243+
void this.#recordCanceledLifecycle(deployment.id);
241244
return okAsync({ deployment });
242245
});
243246

@@ -474,6 +477,41 @@ export class DeploymentService extends BaseService {
474477
);
475478
}
476479

480+
// The cancel path only carries a narrow row selection, so re-fetch the full
481+
// row (post-update, status already CANCELED) for the lifecycle event.
482+
async #recordCanceledLifecycle(deploymentId: string) {
483+
try {
484+
const canceled = await this._prisma.workerDeployment.findFirst({
485+
where: { id: deploymentId },
486+
include: {
487+
environment: {
488+
include: {
489+
project: {
490+
select: { id: true, organizationId: true, externalRef: true },
491+
},
492+
},
493+
},
494+
},
495+
});
496+
497+
if (!canceled || canceled.status !== "CANCELED") return;
498+
499+
recordDeploymentLifecycle({
500+
status: "CANCELED",
501+
deployment: canceled,
502+
environment: {
503+
organizationId: canceled.environment.project.organizationId,
504+
projectId: canceled.environment.project.id,
505+
projectRef: canceled.environment.project.externalRef,
506+
environmentId: canceled.environmentId,
507+
environmentType: canceled.environment.type,
508+
},
509+
});
510+
} catch (error) {
511+
logger.error("Failed to record canceled deployment lifecycle", { deploymentId, error });
512+
}
513+
}
514+
477515
private getDeployment(environmentId: string, friendlyId: string) {
478516
return fromPromise(
479517
this._prisma.workerDeployment.findFirst({

apps/webapp/app/v3/services/failDeployment.server.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Prisma, type WorkerDeploymentStatus } from "@trigger.dev/database";
55
import { type FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
66
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
77
import { DeploymentService } from "./deployment.server";
8-
import { recordDeploymentOutcome } from "./recordDeploymentOutcome.server";
8+
import { recordDeploymentLifecycle } from "./recordDeploymentLifecycle.server";
99

1010
export const FINAL_DEPLOYMENT_STATUSES: WorkerDeploymentStatus[] = [
1111
"CANCELED",
@@ -41,25 +41,50 @@ export class FailDeploymentService extends BaseService {
4141
return;
4242
}
4343

44-
const failedDeployment = await this._prisma.workerDeployment.update({
44+
const failedAt = new Date();
45+
46+
// Guarded transition: a concurrent finalize/timeout/cancel can win between
47+
// the check above and this write; the predicate makes exactly one caller
48+
// commit the terminal status (and emit the lifecycle event).
49+
const { count: updatedCount } = await this._prisma.workerDeployment.updateMany({
4550
where: {
4651
id: deployment.id,
52+
status: { notIn: FINAL_DEPLOYMENT_STATUSES },
4753
},
4854
data: {
4955
status: "FAILED",
50-
failedAt: new Date(),
56+
failedAt,
5157
errorData: params.error,
5258
buildEnvVars: Prisma.DbNull,
5359
},
5460
});
5561

56-
recordDeploymentOutcome({
62+
if (updatedCount === 0) {
63+
logger.warn("Worker deployment reached a final state concurrently, skipping fail", {
64+
id: deployment.id,
65+
friendlyId,
66+
});
67+
return;
68+
}
69+
70+
const failedDeployment = {
71+
...deployment,
72+
status: "FAILED" as const,
73+
failedAt,
74+
errorData: params.error,
75+
buildEnvVars: null,
76+
};
77+
78+
recordDeploymentLifecycle({
5779
status: "FAILED",
58-
deploymentFriendlyId: friendlyId,
59-
organizationId: authenticatedEnv.organizationId,
60-
projectId: authenticatedEnv.projectId,
61-
environmentId: authenticatedEnv.id,
62-
environmentType: authenticatedEnv.type,
80+
deployment: failedDeployment,
81+
environment: {
82+
organizationId: authenticatedEnv.organizationId,
83+
projectId: authenticatedEnv.projectId,
84+
projectRef: authenticatedEnv.project.externalRef,
85+
environmentId: authenticatedEnv.id,
86+
environmentType: authenticatedEnv.type,
87+
},
6388
reason: params.error.message,
6489
});
6590

0 commit comments

Comments
 (0)