Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
37 changes: 37 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,45 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
windows-smoke:
name: Windows creation smoke
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
(github.event.action != 'labeled' || github.event.label.name == 'release:next')
runs-on: windows-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22.18.0"

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Scaffold and build a Prisma app
run: >-
bun test --timeout 300000
--test-name-pattern "builds a Next.js app with a TypeScript-authored contract"
./tests/e2e/create-prisma.e2e.test.ts

preview:
name: Publish PR preview
needs: windows-smoke
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
27 changes: 23 additions & 4 deletions src/tasks/deploy-with-composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,24 @@ import { runSetupCommand } from "../utils/run-command";

type PrismaCliEnvelope<Result = unknown> = {
ok: boolean;
command?: string;
commandId?: string;
result?: Result;
error?: { summary?: string; message?: string; why?: string };
error?: { code?: string; summary?: string; message?: string; why?: string };
};

export class PrismaCliCommandError extends Error {
readonly prismaCliCommand?: string;
readonly prismaCliErrorCode?: string;

constructor(options: { message: string; command?: string; code?: string }) {
super(options.message);
this.name = "PrismaCliCommandError";
this.prismaCliCommand = options.command;
this.prismaCliErrorCode = options.code;
}
}

type PrismaWorkspace = {
id: string;
name: string | null;
Expand Down Expand Up @@ -175,11 +189,16 @@ async function runPrismaJsonCommand<Result>(options: {

if (result.exitCode !== 0 || !envelope.ok || envelope.result === undefined) {
const summary = envelope.error?.summary ?? envelope.error?.message;
throw new Error(
[summary, envelope.error?.why].filter(Boolean).join(": ") ||
throw new PrismaCliCommandError({
message:
[summary, envelope.error?.why].filter(Boolean).join(": ") ||
result.stderr.trim() ||
"Prisma CLI command failed.",
);
...(envelope.commandId || envelope.command
? { command: envelope.commandId ?? envelope.command }
: {}),
...(envelope.error?.code ? { code: envelope.error.code } : {}),
});
}
return envelope.result;
}
Expand Down
32 changes: 32 additions & 0 deletions src/telemetry/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ export const CREATE_PRISMA_NEXT_CANCELLED_EVENT = "cli:create_prisma_next_comman

export type CreateTelemetryFailureStage = CreateFailureStage;

const expectedRejectionReasons = new Set<CreateFailureReason>([
"invalid_input",
"unsupported_node_version",
"invalid_project_name",
"target_path_not_directory",
"target_directory_not_empty",
"unsupported_configuration",
"not_authenticated",
"workspace_missing",
"workspace_mismatch",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"project_name_collision",
]);

function getFailureClass(reason: CreateFailureReason): "expected_rejection" | "technical_failure" {
return expectedRejectionReasons.has(reason) ? "expected_rejection" : "technical_failure";
}

function getTargetDirectoryState(context: CreatePromptContext): string {
if (!context.targetPathState.exists) {
return "new";
Expand Down Expand Up @@ -68,6 +85,18 @@ function getErrorCode(error: unknown): number | string | null {
return typeof code === "number" || typeof code === "string" ? code : null;
}

function getPrismaCliFailureProperty(
error: unknown,
property: "prismaCliCommand" | "prismaCliErrorCode",
): string | null {
if (typeof error !== "object" || error === null) {
return null;
}

const value = Reflect.get(error, property);
return typeof value === "string" && value.length > 0 ? value : null;
}

export async function trackCreateCompleted(params: {
input: CreateCommandInput;
context: CreatePromptContext;
Expand All @@ -90,10 +119,13 @@ export async function trackCreateFailed(params: {
await trackCliTelemetry(CREATE_PRISMA_NEXT_FAILED_EVENT, {
...getBaseCreateProperties(params.input, params.context),
"duration-ms": params.durationMs,
"failure-class": getFailureClass(params.reason),
"failure-stage": params.stage,
"failure-reason": params.reason,
"error-name": getErrorName(params.error),
"error-code": getErrorCode(params.error),
"prisma-cli-command": getPrismaCliFailureProperty(params.error, "prismaCliCommand"),
"prisma-cli-error-code": getPrismaCliFailureProperty(params.error, "prismaCliErrorCode"),
});
}

Expand Down
41 changes: 41 additions & 0 deletions tests/deploy-with-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getConsoleProjectUrl,
parseComposerDeployResult,
parsePrismaCliEnvelope,
PrismaCliCommandError,
} from "../src/tasks/deploy-with-composer";
import { getErrorMessage, redactSecrets } from "../src/utils/errors";

Expand Down Expand Up @@ -91,6 +92,46 @@ describe("parsePrismaCliEnvelope", () => {
),
).toEqual({ ok: true, result: { summary: null } });
});

test("preserves stable command and error codes from a failure envelope", () => {
const envelope = parsePrismaCliEnvelope(
JSON.stringify({
kind: "result",
envelope: {
ok: false,
commandId: "app.deploy",
error: {
code: "APP.DEPLOY_FAILED",
summary: "Deployment failed",
why: "The compute service was not created",
},
},
}),
);

expect(envelope).toMatchObject({
ok: false,
commandId: "app.deploy",
error: { code: "APP.DEPLOY_FAILED" },
});
});
});

describe("PrismaCliCommandError", () => {
test("exposes only stable structured fields for telemetry", () => {
const error = new PrismaCliCommandError({
message: "Deployment failed",
command: "app.deploy",
code: "APP.DEPLOY_FAILED",
});

expect(error).toMatchObject({
name: "PrismaCliCommandError",
message: "Deployment failed",
prismaCliCommand: "app.deploy",
prismaCliErrorCode: "APP.DEPLOY_FAILED",
});
});
});

describe("parseComposerDeployResult", () => {
Expand Down
40 changes: 40 additions & 0 deletions tests/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe("create telemetry", () => {
expect(properties).toEqual(
expect.objectContaining({
"duration-ms": 456,
"failure-class": "technical_failure",
"error-code": "ERR_TEST",
"failure-stage": "plan_migration",
"failure-reason": "migration_plan_failed",
Expand All @@ -78,6 +79,45 @@ describe("create telemetry", () => {
expect(JSON.stringify(properties)).not.toContain("secret");
});

test("separates expected input and environment rejections from technical failures", async () => {
for (const reason of ["target_directory_not_empty", "workspace_missing"] as const) {
await trackCreateFailed({
input: createInput,
context: createContext,
durationMs: 10,
stage: reason === "workspace_missing" ? "select_workspace" : "collect_context",
reason,
});
}
for (const [, properties] of trackCliTelemetry.mock.calls as Array<
[string, Record<string, unknown>]
>) {
expect(properties["failure-class"]).toBe("expected_rejection");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

test("tracks stable Prisma CLI failure fields without raw output", async () => {
await trackCreateFailed({
input: createInput,
context: createContext,
durationMs: 456,
error: Object.assign(new Error("token=secret"), {
prismaCliCommand: "app.deploy",
prismaCliErrorCode: "APP.DEPLOY_FAILED",
}),
stage: "composer_deploy",
reason: "composer_deploy_failed",
});
const [, properties] = trackCliTelemetry.mock.calls[0] as [string, Record<string, unknown>];
expect(properties).toEqual(
expect.objectContaining({
"prisma-cli-command": "app.deploy",
"prisma-cli-error-code": "APP.DEPLOY_FAILED",
}),
);
expect(JSON.stringify(properties)).not.toContain("secret");
});

test("tracks prompt cancellation as a separate outcome", async () => {
await trackCreateCancelled({
input: createInput,
Expand Down
Loading