Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/violet-buses-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---

Send the CLI version header on all API requests so deployments are attributable to a CLI version
4 changes: 4 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,10 @@ const EnvironmentSchema = z
DISABLE_HTTP_INSTRUMENTATION: BoolEnv.default(false),

INTERNAL_OTEL_LOG_EXPORTER_URL: z.string().optional(),

// Second trace exporter receiving only `deployment.*` spans; they still flow to the main one
INTERNAL_OTEL_DEPLOYMENT_EVENT_EXPORTER_URL: z.string().optional(),
INTERNAL_OTEL_DEPLOYMENT_EVENT_EXPORTER_AUTH_HEADERS: z.string().optional(),
INTERNAL_OTEL_METRIC_EXPORTER_URL: z.string().optional(),
INTERNAL_OTEL_METRIC_EXPORTER_AUTH_HEADERS: z.string().optional(),
INTERNAL_OTEL_METRIC_EXPORTER_ENABLED: z.string().default("0"),
Expand Down
12 changes: 11 additions & 1 deletion apps/webapp/app/routes/api.v1.deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
const service = new InitializeDeploymentService();

try {
const result = await service.call(authenticatedEnv, body.data);
const result = await service.call(authenticatedEnv, body.data, {
cliVersion: parseCliVersionHeader(request),
});
Comment thread
myftija marked this conversation as resolved.
const { deployment, imageRef } = result;

const responseBody: InitializeDeploymentResponseBody = {
Expand Down Expand Up @@ -75,6 +77,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
}

// Client-controlled and persisted, so cap what we accept
const CLI_VERSION_MAX_LENGTH = 128;

function parseCliVersionHeader(request: Request): string | undefined {
const value = request.headers.get("x-trigger-cli-version");
return value && value.length <= CLI_VERSION_MAX_LENGTH ? value : undefined;
}

export const loader = createLoaderApiRoute(
{
searchParams: ApiDeploymentListSearchParams,
Expand Down
116 changes: 116 additions & 0 deletions apps/webapp/app/v3/deploymentTelemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { BuildServerMetadata } from "@trigger.dev/core/v3";

/**
* Attribute names for the `deployment.finished` and `deployment.initialized`
* telemetry events (emitted by services/recordDeploymentFinished.server.ts).
* This module is the single owner of these names — external queries,
* dashboards, and monitors reference them, so treat renames as breaking.
*
* Query gotchas: dedup with `arg_max(_time, *) by deployment.id` (job retries
* can double-emit); the span's `_time` is the deployment's createdAt, so a
* TIMED_OUT event lands backdated by up to the full deploy timeout — monitor
* windows must exceed it; phase durations are omitted (not zero) when a
* boundary timestamp is missing, and `total_ms` excludes local-bundle's
* pre-init client work (esbuild + upload) until the CLI reports timings.
*/
export const DeploymentTelemetryAttributes = {
ORG_ID: "$trigger.org.id",
PROJECT_ID: "$trigger.project.id",
// Project external ref ("proj_…")
PROJECT_REF: "$trigger.project.ref",
ENV_ID: "$trigger.env.id",
// PRODUCTION / STAGING / PREVIEW / DEVELOPMENT
ENV_TYPE: "$trigger.env.type",
// Deployment friendly id — the dedup key
DEPLOYMENT_ID: "deployment.id",
VERSION: "deployment.version",
// finished: terminal status; initialized: initial status (PENDING/BUILDING)
STATUS: "deployment.status",
// status === DEPLOYED; CANCELED is excluded from failure rates
SUCCESS: "deployment.success",
// depot / native / native_local_bundle (see deriveBuildPath)
BUILD_PATH: "deployment.build_path",
// V1 / MANAGED (run engine)
WORKER_TYPE: "deployment.worker_type",
RUNTIME: "deployment.runtime",
// Set at indexing; null for pre-index failures
RUNTIME_VERSION: "deployment.runtime_version",
// From x-trigger-cli-version at init; null for pre-column history
CLI_VERSION: "deployment.cli_version",
TRIGGERED_VIA: "deployment.triggered_via",
COMMIT_SHA: "deployment.commit_sha",
// error.* only on FAILED/TIMED_OUT; CANCELED uses canceled_reason
ERROR_NAME: "deployment.error.name",
ERROR_MESSAGE: "deployment.error.message",
CANCELED_REASON: "deployment.canceled_reason",
// createdAt → terminal (also the span's own duration)
DURATION_TOTAL_MS: "deployment.duration.total_ms",
// createdAt → startedAt; ≈0 when created directly in BUILDING (depot)
DURATION_QUEUE_MS: "deployment.duration.queue_ms",
// startedAt → installedAt; build-server paths only (depot never sets it)
DURATION_INSTALL_MS: "deployment.duration.install_ms",
// (installedAt ?? startedAt) → builtAt
DURATION_BUILDING_MS: "deployment.duration.building_ms",
// builtAt → terminal; for depot dominated by the server-side registry push
DURATION_DEPLOYING_MS: "deployment.duration.deploying_ms",
} as const;

export type DeploymentBuildPath = "native_local_bundle" | "native" | "depot";

/**
* Everything that is not a native-build-server deployment falls into the depot
* bucket, including rare `--local-build` deploys (their flag is not persisted).
* `externalBuildData` is NOT a usable depot signal: init writes a placeholder
* for every path.
*/
export function deriveBuildPath(buildServerMetadata: unknown): DeploymentBuildPath {
const metadata = BuildServerMetadata.safeParse(buildServerMetadata);

if (metadata.success && metadata.data.isNativeBuild) {
return metadata.data.fromBundle ? "native_local_bundle" : "native";
}

return "depot";
}

export type DeploymentTimestamps = {
createdAt: Date;
startedAt?: Date | null;
installedAt?: Date | null;
builtAt?: Date | null;
};

export type DeploymentDurations = {
totalMs: number;
queueMs?: number;
installMs?: number;
buildingMs?: number;
deployingMs?: number;
};

/**
* Timestamp chains are path-shaped (e.g. depot never sets installedAt), so
* each phase is derived only when both of its boundary timestamps exist and
* are ordered.
*/
export function deriveDeploymentDurations(
timestamps: DeploymentTimestamps,
terminalAt: Date
): DeploymentDurations {
const { createdAt, startedAt, installedAt, builtAt } = timestamps;
const buildingFrom = installedAt ?? startedAt;

return {
totalMs: Math.max(terminalAt.getTime() - createdAt.getTime(), 0),
queueMs: msBetween(createdAt, startedAt),
installMs: msBetween(startedAt, installedAt),
buildingMs: msBetween(buildingFrom, builtAt),
deployingMs: msBetween(builtAt, terminalAt),
};
}

function msBetween(from?: Date | null, to?: Date | null): number | undefined {
if (!from || !to) return undefined;
const ms = to.getTime() - from.getTime();
return ms >= 0 ? ms : undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from "./createBackgroundWorker.server";
import { findOrCreateBackgroundWorker } from "./createDeploymentBackgroundWorkerV4/findOrCreateBackgroundWorker.server";
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
import { recordDeploymentOutcome } from "./recordDeploymentOutcome.server";
import { recordDeploymentFinished } from "./recordDeploymentFinished.server";
import { env } from "~/env.server";
import { webhookPrisma } from "~/db.server";

Expand Down Expand Up @@ -298,6 +298,12 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
error: Error,
environment: AuthenticatedEnvironment
) {
const failedAt = new Date();
const errorData = {
name: error.name,
message: error.message,
};

// Guarded BUILDING → FAILED transition, symmetric with the BUILDING → DEPLOYING
// transition in `call()`. With idempotent retries, two attempts can run side-by-side;
// without the predicate, one attempt's failure could downgrade the deployment after
Expand All @@ -309,11 +315,8 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
},
data: {
status: "FAILED",
failedAt: new Date(),
errorData: {
name: error.name,
message: error.message,
},
failedAt,
errorData,
buildEnvVars: Prisma.DbNull,
},
});
Expand All @@ -332,13 +335,16 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
// BUILDING → DEPLOYING transition.
await TimeoutDeploymentService.dequeue(deployment.id, this._prisma);

recordDeploymentOutcome({
recordDeploymentFinished({
status: "FAILED",
deploymentFriendlyId: deployment.friendlyId,
organizationId: environment.organizationId,
projectId: environment.projectId,
environmentId: environment.id,
environmentType: environment.type,
deployment: { ...deployment, status: "FAILED", failedAt, errorData },
environment: {
organizationId: environment.organizationId,
projectId: environment.projectId,
projectRef: environment.project.externalRef,
environmentId: environment.id,
environmentType: environment.type,
},
reason: error.message,
});
}
Expand Down
63 changes: 46 additions & 17 deletions apps/webapp/app/v3/services/deployment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type DeploymentEvent,
} from "@trigger.dev/core/v3";
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
import { recordDeploymentFinished } from "./recordDeploymentFinished.server";
import { env } from "~/env.server";
import { createRemoteImageBuild } from "../remoteImageBuilder.server";
import { FINAL_DEPLOYMENT_STATUSES } from "./failDeployment.server";
Expand Down Expand Up @@ -195,10 +196,8 @@ export class DeploymentService extends BaseService {
friendlyId: string,
data?: Partial<Pick<WorkerDeployment, "canceledReason">>
) {
const validateDeployment = (
deployment: Pick<WorkerDeployment, "id" | "status" | "shortCode"> & {
environment: { project: { externalRef: string } };
}
const validateDeployment = <T extends Pick<WorkerDeployment, "id" | "status">>(
deployment: T
) => {
if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) {
logger.warn("Attempted cancelling deployment in a final state", {
Expand All @@ -210,11 +209,7 @@ export class DeploymentService extends BaseService {
return okAsync(deployment);
};

const cancelDeployment = (
deployment: Pick<WorkerDeployment, "id" | "shortCode"> & {
environment: { project: { externalRef: string } };
}
) =>
const cancelDeployment = <T extends Pick<WorkerDeployment, "id">>(deployment: T) =>
fromPromise(
this._prisma.workerDeployment.updateMany({
where: {
Expand Down Expand Up @@ -250,7 +245,25 @@ export class DeploymentService extends BaseService {
return this.getDeployment(authenticatedEnv.id, friendlyId)
.andThen(validateDeployment)
.andThen(cancelDeployment)
.andThen(({ deployment }) =>
.andTee(({ deployment }) =>
recordDeploymentFinished({
status: "CANCELED",
deployment: {
...deployment,
status: "CANCELED",
canceledAt: new Date(),
canceledReason: data?.canceledReason ?? null,
},
environment: {
organizationId: deployment.environment.project.organizationId,
projectId: deployment.environment.project.id,
projectRef: deployment.environment.project.externalRef,
environmentId: deployment.environment.id,
environmentType: deployment.environment.type,
},
})
)
.andTee(({ deployment }) =>
this.appendToEventLog(deployment.environment.project, deployment, [
{
type: "finalized",
Expand All @@ -259,14 +272,11 @@ export class DeploymentService extends BaseService {
message: data?.canceledReason ?? undefined,
},
},
])
.orElse((error) => {
logger.error("Failed to append event to deployment event log", { error });
return okAsync(deployment);
})
.map(() => deployment)
]).orTee((error) => {
logger.error("Failed to append event to deployment event log", { error });
})
)
.andThen(deleteTimeout)
.andThen(({ deployment }) => deleteTimeout(deployment))
.map(() => undefined);
}

Expand Down Expand Up @@ -484,13 +494,32 @@ export class DeploymentService extends BaseService {
select: {
status: true,
id: true,
friendlyId: true,
version: true,
type: true,
createdAt: true,
startedAt: true,
installedAt: true,
builtAt: true,
deployedAt: true,
failedAt: true,
canceledAt: true,
canceledReason: true,
errorData: true,
runtime: true,
runtimeVersion: true,
cliVersion: true,
triggeredVia: true,
commitSHA: true,
buildServerMetadata: true,
imageReference: true,
shortCode: true,
environment: {
include: {
project: {
select: {
id: true,
organizationId: true,
externalRef: true,
},
},
Expand Down
Loading