From dcaaea32ce45da88512427ccc6def6cc00fabe54 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 4 Sep 2026 16:35:14 +0530 Subject: [PATCH 1/2] feat: classify child process telemetry failures Signed-off-by: Aman Varshney --- src/telemetry/create.ts | 40 ++++++++++++++++++++++++++++++++++++++++ tests/telemetry.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/telemetry/create.ts b/src/telemetry/create.ts index 53f2900..dd8935f 100644 --- a/src/telemetry/create.ts +++ b/src/telemetry/create.ts @@ -85,6 +85,45 @@ function getErrorCode(error: unknown): number | string | null { return typeof code === "number" || typeof code === "string" ? code : null; } +type ChildProcessFailure = + | "cancelled" + | "command_not_found" + | "interrupted" + | "max_buffer" + | "non_zero_exit" + | "permission_denied" + | "spawn_failed" + | "terminated" + | "timed_out"; + +const WINDOWS_CONTROL_C_EXIT_CODE = 0xc000013a; + +function getChildProcessFailure(error: unknown): ChildProcessFailure | null { + if (typeof error !== "object" || error === null) { + return null; + } + if (Reflect.get(error, "name") !== "ExecaError" || Reflect.get(error, "failed") !== true) { + return null; + } + + if (Reflect.get(error, "timedOut") === true) return "timed_out"; + if (Reflect.get(error, "isCanceled") === true) return "cancelled"; + if (Reflect.get(error, "isMaxBuffer") === true) return "max_buffer"; + + const exitCode = Reflect.get(error, "exitCode"); + const signal = Reflect.get(error, "signal"); + if (signal === "SIGINT" || exitCode === 130 || exitCode === WINDOWS_CONTROL_C_EXIT_CODE) { + return "interrupted"; + } + if (Reflect.get(error, "isTerminated") === true) return "terminated"; + + const code = Reflect.get(error, "code"); + if (code === "ENOENT") return "command_not_found"; + if (code === "EACCES" || code === "EPERM") return "permission_denied"; + if (typeof exitCode === "number") return "non_zero_exit"; + return "spawn_failed"; +} + function getPrismaCliFailureProperty( error: unknown, property: "prismaCliCommand" | "prismaCliErrorCode", @@ -124,6 +163,7 @@ export async function trackCreateFailed(params: { "failure-reason": params.reason, "error-name": getErrorName(params.error), "error-code": getErrorCode(params.error), + "child-process-failure": getChildProcessFailure(params.error), "prisma-cli-command": getPrismaCliFailureProperty(params.error, "prismaCliCommand"), "prisma-cli-error-code": getPrismaCliFailureProperty(params.error, "prismaCliErrorCode"), }); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index c840d6f..93d3964 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -122,6 +122,43 @@ describe("create telemetry", () => { expect(JSON.stringify(properties)).not.toContain("secret"); }); + test("classifies child-process failures without capturing command output", async () => { + const cases = [ + [{ timedOut: true }, "timed_out"], + [{ isCanceled: true }, "cancelled"], + [{ isMaxBuffer: true }, "max_buffer"], + [{ signal: "SIGINT", isTerminated: true }, "interrupted"], + [{ exitCode: 0xc000013a }, "interrupted"], + [{ signal: "SIGTERM", isTerminated: true }, "terminated"], + [{ code: "ENOENT" }, "command_not_found"], + [{ code: "EACCES" }, "permission_denied"], + [{ exitCode: 1 }, "non_zero_exit"], + [{ code: "UNKNOWN" }, "spawn_failed"], + ] as const; + + for (const [details, expectedFailure] of cases) { + await trackCreateFailed({ + input: createInput, + context: createContext, + durationMs: 10, + error: Object.assign(new Error("token=secret"), { + name: "ExecaError", + failed: true, + ...details, + }), + stage: "install_dependencies", + reason: "dependency_install_failed", + }); + + const [, properties] = trackCliTelemetry.mock.calls.at(-1) as [ + string, + Record, + ]; + expect(properties["child-process-failure"]).toBe(expectedFailure); + expect(JSON.stringify(properties)).not.toContain("secret"); + } + }); + test("tracks prompt cancellation as a separate outcome", async () => { await trackCreateCancelled({ input: createInput, From 8fdf26d0b7ea936158a0363fa0346947b03b7001 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 14 Sep 2026 21:41:46 +0530 Subject: [PATCH 2/2] fix: preserve process failure categories through Effect commands Signed-off-by: Aman Varshney --- src/create-outcome.ts | 3 ++ src/services/command-runner.ts | 11 +++++ src/tasks/prisma-cli.ts | 2 + src/telemetry/create.ts | 53 +++++--------------- src/utils/child-process-failure.ts | 40 +++++++++++++++ tests/e2e/create-prisma.e2e.test.ts | 10 ++-- tests/telemetry.test.ts | 77 +++++++++++++++++++++++++++-- 7 files changed, 145 insertions(+), 51 deletions(-) create mode 100644 src/utils/child-process-failure.ts diff --git a/src/create-outcome.ts b/src/create-outcome.ts index fb6e65b..05b67ba 100644 --- a/src/create-outcome.ts +++ b/src/create-outcome.ts @@ -1,5 +1,7 @@ import { Schema } from "effect"; +import { ChildProcessFailureSchema } from "./utils/child-process-failure"; + export const CreateFailureStageSchema = Schema.Literals([ "validate_input", "collect_context", @@ -91,6 +93,7 @@ export class PrismaCliCommandError extends Schema.TaggedError()( @@ -26,6 +33,7 @@ export class CommandExecutionError extends Schema.TaggedError @@ -90,6 +99,7 @@ export class CommandRunner extends Context.Service< stdout: "", stderr: "", cause, + childProcessFailure: getChildProcessFailure(cause), }), }), ); @@ -104,6 +114,7 @@ export class CommandRunner extends Context.Service< exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, + childProcessFailure: result.childProcessFailure, }), ), ), diff --git a/src/tasks/prisma-cli.ts b/src/tasks/prisma-cli.ts index 4f9afb3..1c69801 100644 --- a/src/tasks/prisma-cli.ts +++ b/src/tasks/prisma-cli.ts @@ -73,6 +73,7 @@ export const runPrismaJsonCommandEffect = Effect.fn("PrismaCli.runJson")(functio redactSecrets(result.stderr.trim() || result.stdout.trim()) || getErrorMessage(cause), stderr: redactSecrets(result.stderr), exitCode: result.exitCode, + childProcessFailure: result.childProcessFailure, }); } @@ -90,6 +91,7 @@ export const runPrismaJsonCommandEffect = Effect.fn("PrismaCli.runJson")(functio ...(envelope.error?.code ? { code: envelope.error.code } : {}), stderr: redactSecrets(result.stderr), exitCode: result.exitCode, + childProcessFailure: result.childProcessFailure, }); } return envelope.result; diff --git a/src/telemetry/create.ts b/src/telemetry/create.ts index 396a3ac..8237702 100644 --- a/src/telemetry/create.ts +++ b/src/telemetry/create.ts @@ -1,13 +1,15 @@ import { Effect } from "effect"; import type { CreatePromptContext } from "../commands/create"; -import type { - CreateCancellationStage, - CreateFailureReason, - CreateFailureStage, +import { + PrismaCliCommandError, + type CreateCancellationStage, + type CreateFailureReason, + type CreateFailureStage, } from "../create-outcome"; import type { CreateCommandInput } from "../types"; import { applicationRuntime } from "../runtime"; +import { CommandExecutionError } from "../services/command-runner"; import { TELEMETRY_TIMEOUT_MS, trackCliTelemetryEffect } from "./client"; @@ -91,43 +93,10 @@ function getErrorCode(error: unknown): number | string | null { return typeof code === "number" || typeof code === "string" ? code : null; } -type ChildProcessFailure = - | "cancelled" - | "command_not_found" - | "interrupted" - | "max_buffer" - | "non_zero_exit" - | "permission_denied" - | "spawn_failed" - | "terminated" - | "timed_out"; - -const WINDOWS_CONTROL_C_EXIT_CODE = 0xc000013a; - -function getChildProcessFailure(error: unknown): ChildProcessFailure | null { - if (typeof error !== "object" || error === null) { - return null; - } - if (Reflect.get(error, "name") !== "ExecaError" || Reflect.get(error, "failed") !== true) { - return null; - } - - if (Reflect.get(error, "timedOut") === true) return "timed_out"; - if (Reflect.get(error, "isCanceled") === true) return "cancelled"; - if (Reflect.get(error, "isMaxBuffer") === true) return "max_buffer"; - - const exitCode = Reflect.get(error, "exitCode"); - const signal = Reflect.get(error, "signal"); - if (signal === "SIGINT" || exitCode === 130 || exitCode === WINDOWS_CONTROL_C_EXIT_CODE) { - return "interrupted"; - } - if (Reflect.get(error, "isTerminated") === true) return "terminated"; - - const code = Reflect.get(error, "code"); - if (code === "ENOENT") return "command_not_found"; - if (code === "EACCES" || code === "EPERM") return "permission_denied"; - if (typeof exitCode === "number") return "non_zero_exit"; - return "spawn_failed"; +function getChildProcessFailureProperty(error: unknown): string | null { + return error instanceof CommandExecutionError || error instanceof PrismaCliCommandError + ? (error.childProcessFailure ?? null) + : null; } function getPrismaCliFailureProperty( @@ -175,7 +144,7 @@ export const trackCreateFailedEffect = Effect.fn("Telemetry.createFailed")(funct "failure-reason": params.reason, "error-name": getErrorName(params.error), "error-code": getErrorCode(params.error), - "child-process-failure": getChildProcessFailure(params.error), + "child-process-failure": getChildProcessFailureProperty(params.error), "prisma-cli-command": getPrismaCliFailureProperty(params.error, "prismaCliCommand"), "prisma-cli-error-code": getPrismaCliFailureProperty(params.error, "prismaCliErrorCode"), }).pipe( diff --git a/src/utils/child-process-failure.ts b/src/utils/child-process-failure.ts new file mode 100644 index 0000000..cd4ddd0 --- /dev/null +++ b/src/utils/child-process-failure.ts @@ -0,0 +1,40 @@ +import { Schema } from "effect"; + +export const ChildProcessFailureSchema = Schema.Literals([ + "cancelled", + "command_not_found", + "interrupted", + "max_buffer", + "non_zero_exit", + "permission_denied", + "spawn_failed", + "terminated", + "timed_out", +]); +export type ChildProcessFailure = typeof ChildProcessFailureSchema.Type; + +const WINDOWS_CONTROL_C_EXIT_CODE = 0xc000013a; + +export function getChildProcessFailure(error: unknown): ChildProcessFailure | undefined { + if (typeof error !== "object" || error === null) return undefined; + if (Reflect.get(error, "name") !== "ExecaError" || Reflect.get(error, "failed") !== true) { + return undefined; + } + + if (Reflect.get(error, "timedOut") === true) return "timed_out"; + if (Reflect.get(error, "isCanceled") === true) return "cancelled"; + if (Reflect.get(error, "isMaxBuffer") === true) return "max_buffer"; + + const exitCode = Reflect.get(error, "exitCode"); + const signal = Reflect.get(error, "signal"); + if (signal === "SIGINT" || exitCode === 130 || exitCode === WINDOWS_CONTROL_C_EXIT_CODE) { + return "interrupted"; + } + if (Reflect.get(error, "isTerminated") === true) return "terminated"; + + const code = Reflect.get(error, "code"); + if (code === "ENOENT") return "command_not_found"; + if (code === "EACCES" || code === "EPERM") return "permission_denied"; + if (typeof exitCode === "number") return "non_zero_exit"; + return "spawn_failed"; +} diff --git a/tests/e2e/create-prisma.e2e.test.ts b/tests/e2e/create-prisma.e2e.test.ts index 2237017..02c40e1 100644 --- a/tests/e2e/create-prisma.e2e.test.ts +++ b/tests/e2e/create-prisma.e2e.test.ts @@ -286,9 +286,9 @@ describe("create-prisma e2e", () => { ok: false, error: { stage: "collect_context", message: expect.stringContaining("migration history") }, }); - expect(await readdir(path.join(projectDir, "migrations"), { recursive: true })).toEqual( - migrationPaths, - ); + expect( + (await readdir(path.join(projectDir, "migrations"), { recursive: true })).sort(), + ).toEqual(migrationPaths.toSorted()); expect( await Promise.all( preservedPaths.map((filePath) => readFile(path.join(projectDir, filePath), "utf8")), @@ -298,7 +298,7 @@ describe("create-prisma e2e", () => { TEST_TIMEOUT, ); - test("returns a non-zero exit code when project setup fails", async () => { + test("returns a non-zero exit code before scaffolding when npm is unavailable", async () => { const rootDir = await mkdtemp(path.join(tmpdir(), "create-prisma-exit-code-e2e-")); tempRoots.push(rootDir); const emptyBinDir = path.join(rootDir, "empty-bin"); @@ -346,7 +346,7 @@ describe("create-prisma e2e", () => { error: { stage: "install_dependencies" }, }); expect(stderr).toBe(""); - expect(await pathExists(path.join(rootDir, "failed-app", "package.json"))).toBe(true); + expect(await pathExists(path.join(rootDir, "failed-app"))).toBe(false); }); test("rejects unsupported Deno combinations with a non-zero exit code", async () => { diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index a2b9bcc..6cba873 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -3,10 +3,14 @@ import { Effect } from "effect"; import type { CreatePromptContext } from "../src/commands/create"; import type { CreateCommandInput } from "../src/types"; +import { CommandExecutionError, CommandRunner } from "../src/services/command-runner"; +import { runPrismaJsonCommandEffect } from "../src/tasks/prisma-cli"; +import { getChildProcessFailure } from "../src/utils/child-process-failure"; const trackCliTelemetry = mock(async () => {}); mock.module("../src/telemetry/client", () => ({ + TELEMETRY_TIMEOUT_MS: 2_000, trackCliTelemetryEffect: (event: string, properties: Record) => Effect.promise(() => trackCliTelemetry(event, properties)), })); @@ -153,10 +157,16 @@ describe("create telemetry", () => { input: createInput, context: createContext, durationMs: 10, - error: Object.assign(new Error("token=secret"), { - name: "ExecaError", - failed: true, - ...details, + error: new CommandExecutionError({ + command: "secret-command", + args: ["secret-argument"], + stdout: "token=secret", + stderr: "token=secret", + childProcessFailure: getChildProcessFailure({ + name: "ExecaError", + failed: true, + ...details, + }), }), stage: "install_dependencies", reason: "dependency_install_failed", @@ -171,6 +181,65 @@ describe("create telemetry", () => { } }); + test("preserves real process failures through checked and Prisma JSON commands", async () => { + const cases = [ + { command: "create-prisma-nonexistent-test-command", args: [], failure: "command_not_found" }, + { command: process.execPath, args: ["-e", "process.exit(130)"], failure: "interrupted" }, + { + command: process.execPath, + args: ["-e", "console.error('token=secret'); process.exit(2)"], + failure: "non_zero_exit", + }, + { + command: process.execPath, + args: [ + "-e", + `console.log(JSON.stringify({ok: false, error: {code: "AUTH.LOGIN_DENIED", summary: "Sign-in was not authorized."}})); process.exit(1)`, + ], + failure: "non_zero_exit", + }, + ]; + + for (const spec of cases) { + for (const json of [false, true]) { + const error = await Effect.runPromise( + Effect.gen(function* () { + const runner = yield* CommandRunner; + const command = { command: spec.command, args: spec.args, cwd: process.cwd() }; + return yield* json + ? runPrismaJsonCommandEffect({ + packageManager: "npm", + projectDir: process.cwd(), + args: ["init"], + }).pipe( + Effect.provideService(CommandRunner, { + ...runner, + run: () => runner.run(command), + }), + ) + : runner.runChecked(command); + }).pipe(Effect.provide(CommandRunner.layer), Effect.flip), + ); + await trackCreateFailed({ + input: createInput, + durationMs: 10, + error, + stage: json ? "initialize_prisma" : "install_dependencies", + reason: json ? "prisma_init_failed" : "dependency_install_failed", + }); + const [, properties] = trackCliTelemetry.mock.calls.at(-1) as [ + string, + Record, + ]; + expect(properties["child-process-failure"]).toBe(spec.failure); + expect(properties["error-name"]).toBe( + json ? "PrismaCliCommandError" : "CommandExecutionError", + ); + expect(JSON.stringify(properties)).not.toContain("secret"); + } + } + }); + test("tracks prompt cancellation as a separate outcome", async () => { await trackCreateCancelled({ input: createInput,