diff --git a/apps/cli-go/internal/telemetry/events.go b/apps/cli-go/internal/telemetry/events.go index 2ee362dc17..3775ded6aa 100644 --- a/apps/cli-go/internal/telemetry/events.go +++ b/apps/cli-go/internal/telemetry/events.go @@ -114,6 +114,28 @@ const ( // example "pretty", "json", "yaml"), after agent auto-detection and any // command-local override are applied. PropOutputFormat = "output_format" + // PropErrorKind classifies failed commands into the CLI error actionability + // taxonomy. Values are user_actionable, internal_bug, external_service, or + // unknown. + PropErrorKind = "error_kind" + // PropErrorCategory is the sanitized CLI error category used for KPI + // reporting. It must never contain raw error text or user-specific data. + PropErrorCategory = "error_category" + // PropErrorFingerprint is a stable sanitized identifier for grouping repeated + // failures. It must never contain raw error text or user-specific data. + PropErrorFingerprint = "error_fingerprint" + // PropHasSuggestion indicates whether the failed command had a known + // remediation suggestion. + PropHasSuggestion = "has_suggestion" + // PropSuggestionType is the sanitized kind of remediation suggestion shown or + // known for the failure. + PropSuggestionType = "suggestion_type" + // PropSuggestedCommand is a safe command-only remediation hint when one is + // known, such as "supabase login". It must not include user-provided values. + PropSuggestedCommand = "suggested_command" + // PropWorkflow is an optional safe workflow label for future mapped recovery + // analysis. + PropWorkflow = "workflow" ) // Group identifiers associate events with higher-level entities in PostHog. diff --git a/apps/cli/src/legacy/commands/branches/create/create.handler.ts b/apps/cli/src/legacy/commands/branches/create/create.handler.ts index 9cf9352b1f..5e94c9764a 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -16,7 +16,10 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyMarkUpgradeSuggested, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyBranchesCreateCancelledError, LegacyBranchesCreateNetworkError, @@ -106,12 +109,13 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function HttpClientError.isHttpClientError(cause) && cause.response !== undefined ? cause.response.status : 0; - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "branching_limit", statusCode: status, }); - return yield* mapCreateErrorRaw(cause); + const mapped = yield* Effect.flip(mapCreateErrorRaw(cause)); + return yield* Effect.fail(legacyMarkUpgradeSuggested(mapped, upgradeSuggested)); }), ), ); diff --git a/apps/cli/src/legacy/commands/branches/update/update.handler.ts b/apps/cli/src/legacy/commands/branches/update/update.handler.ts index 5dc3085c74..eda6d000b1 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.handler.ts @@ -16,7 +16,10 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyMarkUpgradeSuggested, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyBranchesUpdateNetworkError, LegacyBranchesUpdateUnexpectedStatusError, @@ -78,12 +81,13 @@ export const legacyBranchesUpdate = Effect.fn("legacy.branches.update")(function : 0; // Mirrors Go's `update.go:26` — pass the resolved branch's project // ref so the entitlements check is scoped to the branch's org. - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: branchRef, featureKey: "branching_persistent", statusCode: status, }); - return yield* mapUpdateError(cause); + const mapped = yield* Effect.flip(mapUpdateError(cause)); + return yield* Effect.fail(legacyMarkUpgradeSuggested(mapped, upgradeSuggested)); }), ), ); diff --git a/apps/cli/src/legacy/commands/sso/add/add.handler.ts b/apps/cli/src/legacy/commands/sso/add/add.handler.ts index e1951e7b6c..67f3be552f 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.handler.ts @@ -16,7 +16,10 @@ import { sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; import { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyMarkUpgradeSuggested, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacySsoAddAttributeMappingFileError, LegacySsoAddMetadataFileError, @@ -137,7 +140,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy // mapper uses (`mapLegacyHttpError`) so error output stays bounded and // shell-safe — the raw-HTTP path must not skip these defences. const bodyText = sanitizeLegacyErrorBody(rawBody); - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, @@ -145,15 +148,21 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy yield* creating?.fail() ?? Effect.void; if (response.status === 404) { return yield* Effect.fail( - new LegacySsoAddSamlDisabledError({ message: SAML_DISABLED_MESSAGE }), + legacyMarkUpgradeSuggested( + new LegacySsoAddSamlDisabledError({ message: SAML_DISABLED_MESSAGE }), + upgradeSuggested, + ), ); } return yield* Effect.fail( - new LegacySsoAddUnexpectedStatusError({ - status: response.status, - body: bodyText, - message: `Unexpected error adding identity provider: ${bodyText}`, - }), + legacyMarkUpgradeSuggested( + new LegacySsoAddUnexpectedStatusError({ + status: response.status, + body: bodyText, + message: `Unexpected error adding identity provider: ${bodyText}`, + }), + upgradeSuggested, + ), ); } diff --git a/apps/cli/src/legacy/commands/sso/list/list.handler.ts b/apps/cli/src/legacy/commands/sso/list/list.handler.ts index 408e43d23b..67a59fb36c 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.handler.ts @@ -14,7 +14,10 @@ import { import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyMarkUpgradeSuggested, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacySsoListNetworkError, LegacySsoListSamlDisabledError, @@ -37,16 +40,20 @@ const handleListError = (ref: string, cause: SupabaseApiError) => Effect.gen(function* () { const mapped = yield* Effect.flip(mapStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoListUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, }); if (mapped.status === 404) { return yield* Effect.fail( - new LegacySsoListSamlDisabledError({ message: SAML_DISABLED_MESSAGE }), + legacyMarkUpgradeSuggested( + new LegacySsoListSamlDisabledError({ message: SAML_DISABLED_MESSAGE }), + upgradeSuggested, + ), ); } + return yield* Effect.fail(legacyMarkUpgradeSuggested(mapped, upgradeSuggested)); } return yield* Effect.fail(mapped); }); diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts index c3a35f0ef3..1176fc2ac3 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts @@ -9,7 +9,10 @@ import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go- import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyMarkUpgradeSuggested, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacySsoRemoveNetworkError, LegacySsoRemoveNotFoundError, @@ -29,18 +32,22 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr Effect.gen(function* () { const mapped = yield* Effect.flip(mapStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoRemoveUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, }); if (mapped.status === 404) { return yield* Effect.fail( - new LegacySsoRemoveNotFoundError({ - message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, - }), + legacyMarkUpgradeSuggested( + new LegacySsoRemoveNotFoundError({ + message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, + }), + upgradeSuggested, + ), ); } + return yield* Effect.fail(legacyMarkUpgradeSuggested(mapped, upgradeSuggested)); } return yield* Effect.fail(mapped); }); diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index ca86765455..bce1f13830 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -18,7 +18,10 @@ import { mapLegacyHttpError, sanitizeLegacyErrorBody } from "../../../shared/leg import { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyMarkUpgradeSuggested, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacySsoMutexFlagError, LegacySsoUpdateAttributeMappingFileError, @@ -52,18 +55,22 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError Effect.gen(function* () { const mapped = yield* Effect.flip(mapGetStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoUpdateUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, }); if (mapped.status === 404) { return yield* Effect.fail( - new LegacySsoUpdateNotFoundError({ - message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, - }), + legacyMarkUpgradeSuggested( + new LegacySsoUpdateNotFoundError({ + message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, + }), + upgradeSuggested, + ), ); } + return yield* Effect.fail(legacyMarkUpgradeSuggested(mapped, upgradeSuggested)); } return yield* Effect.fail(mapped); }); @@ -209,7 +216,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( // Cap + sanitise to match `mapLegacyHttpError`'s defences — see add handler // for the rationale; the raw-HTTP path must not bypass these. const bodyText = sanitizeLegacyErrorBody(rawBody); - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, @@ -217,11 +224,14 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( yield* fetching?.fail() ?? Effect.void; return yield* Effect.fail( // Go reuses the GET error message even for PUT (see `update.go:133`). - new LegacySsoUpdateUnexpectedStatusError({ - status: response.status, - body: bodyText, - message: `unexpected error fetching identity provider: ${bodyText}`, - }), + legacyMarkUpgradeSuggested( + new LegacySsoUpdateUnexpectedStatusError({ + status: response.status, + body: bodyText, + message: `unexpected error fetching identity provider: ${bodyText}`, + }), + upgradeSuggested, + ), ); } diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index 27877ce2c5..51391fe9e8 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -2,7 +2,10 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyMarkUpgradeSuggested, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -57,11 +60,12 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain // tagged error before deciding whether to suggest an upgrade, then re-fail. const mapped = yield* Effect.flip(mapActivateError(cause)); if (mapped._tag === "LegacyVanitySubdomainsActivateUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, }); + return yield* Effect.fail(legacyMarkUpgradeSuggested(mapped, upgradeSuggested)); } return yield* Effect.fail(mapped); }), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index d17836e693..3c72578460 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -2,7 +2,10 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyMarkUpgradeSuggested, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -60,12 +63,13 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( if (mapped._tag === "LegacyVanitySubdomainsCheckUnexpectedStatusError") { // Go's check command calls SuggestUpgradeOnError without a following // TrackUpgradeSuggested, so suppress the analytics event for parity. - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, trackAnalytics: false, }); + return yield* Effect.fail(legacyMarkUpgradeSuggested(mapped, upgradeSuggested)); } return yield* Effect.fail(mapped); }), diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index 0466e726ec..a8d95da12a 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -58,7 +58,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly trackAnalytics?: boolean; }) { if (opts.statusCode < 400 || opts.statusCode >= 500) { - return; + return false; } const output = yield* Output; @@ -78,15 +78,15 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); const projectResp = yield* httpClient.execute(projectReq).pipe(Effect.option); if (projectResp._tag === "None" || projectResp.value.status !== 200) { - return; + return false; } const projectBody = yield* projectResp.value.json.pipe(Effect.option); if (projectBody._tag === "None") { - return; + return false; } const orgSlug = readString(projectBody.value, "organization_slug"); if (orgSlug.length === 0) { - return; + return false; } const entReq = HttpClientRequest.get( @@ -94,15 +94,15 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); const entResp = yield* httpClient.execute(entReq).pipe(Effect.option); if (entResp._tag === "None" || entResp.value.status !== 200) { - return; + return false; } const entBody = yield* entResp.value.json.pipe(Effect.option); if (entBody._tag === "None") { - return; + return false; } const entitlements = (entBody.value as { entitlements?: unknown }).entitlements; if (!Array.isArray(entitlements)) { - return; + return false; } const gated = entitlements.some((entry: unknown) => { @@ -114,7 +114,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { return key === opts.featureKey && hasAccess === false; }); if (!gated) { - return; + return false; } const url = legacyBillingUrl(cliConfig.profile, orgSlug); @@ -130,4 +130,19 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { [PropOrgSlug]: orgSlug, }); } + + return true; }); + +export function legacyMarkUpgradeSuggested( + error: T, + upgradeSuggested: boolean, +): T { + if (upgradeSuggested) { + Object.defineProperty(error, "upgradeSuggested", { + value: true, + enumerable: true, + }); + } + return error; +} diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts index 04deca03cd..5d8624ab81 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts @@ -15,6 +15,10 @@ import { PropExitCode, PropOutputFormat, } from "../../shared/telemetry/event-catalog.ts"; +import { + classifyCliCauseActionability, + classifyProcessControlledFailureActionability, +} from "../../shared/telemetry/error-actionability.ts"; import { LEGACY_RESOURCE_OUTPUT_FORMATS, LegacyInvalidOutputFormatError, @@ -292,6 +296,11 @@ function withLegacyCommandAnalyticsImplementation { ); }); - it.live("captures failed commands with exit_code=1", () => { + it.live("captures failed commands with actionability metadata", () => { const analytics = mockContextualAnalytics(); - return withLegacyCommandInstrumentation()(Effect.fail(new Error("boom"))).pipe( + return withLegacyCommandInstrumentation()( + Effect.fail({ _tag: "LegacyProjectNotLinkedError", message: "Project not linked." }), + ).pipe( Effect.provide(analytics.layer), Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), @@ -472,6 +474,14 @@ describe("withLegacyCommandInstrumentation", () => { Effect.sync(() => { expect(analytics.captured).toHaveLength(1); expect(analytics.captured[0]?.properties.exit_code).toBe(1); + expect(analytics.captured[0]?.properties).toMatchObject({ + error_kind: "user_actionable", + error_category: "project_not_linked", + error_fingerprint: "tag:LegacyProjectNotLinkedError", + has_suggestion: true, + suggestion_type: "link_project", + suggested_command: "supabase link", + }); }), ), Effect.asVoid, @@ -500,7 +510,14 @@ describe("withLegacyCommandInstrumentation", () => { Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); - expect(analytics.captured[0]?.properties.exit_code).toBe(1); + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "process_control:db_lint_fail_on", + has_suggestion: false, + suggestion_type: "none", + }); }), ), ); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 1b29aba459..54d7060c0f 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -1,10 +1,12 @@ import { BunServices } from "@effect/platform-bun"; import { ProjectConfigStore } from "@supabase/config"; import { unixHttpClientLayer } from "@supabase/stack"; -import { Cause, Effect, Exit, Fiber, Layer, Stdio } from "effect"; +import type { FileSystem, Path } from "effect"; +import { Cause, Clock, Effect, Exit, Fiber, Layer, Stdio } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { CLI_VERSION } from "./version.ts"; import { Credentials } from "../../next/auth/credentials.service.ts"; +import type { CliConfig } from "../../next/config/cli-config.service.ts"; import { jsonCliOutputFormatter } from "../output/json-formatter.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; import { outputLayerFor } from "../output/output.layer.ts"; @@ -17,13 +19,23 @@ import { ProjectLocalServiceVersions } from "../../next/config/project-local-ser import { projectContextLayer } from "../../next/config/project-context.layer.ts"; import { projectLinkStateLayer } from "../../next/config/project-link-state.layer.ts"; import { processControlLayer } from "../runtime/process-control.layer.ts"; +import type { RuntimeInfo } from "../runtime/runtime-info.service.ts"; import { runtimeInfoLayer } from "../runtime/runtime-info.layer.ts"; +import type { Tty } from "../runtime/tty.service.ts"; import { ttyLayer } from "../runtime/tty.layer.ts"; import { CommandRuntime } from "../runtime/command-runtime.service.ts"; import { ProcessControl } from "../runtime/process-control.service.ts"; -import type { Analytics } from "../telemetry/analytics.service.ts"; +import { Analytics } from "../telemetry/analytics.service.ts"; +import { withAnalyticsContext } from "../telemetry/analytics-context.ts"; import { aiToolLayer } from "../telemetry/ai-tool.layer.ts"; import { AiTool } from "../telemetry/ai-tool.service.ts"; +import { + EventCommandExecuted, + PropDurationMs, + PropExitCode, + PropOutputFormat, +} from "../telemetry/event-catalog.ts"; +import { classifyCliCauseActionability } from "../telemetry/error-actionability.ts"; import { telemetryRuntimeLayer } from "../telemetry/runtime.layer.ts"; import { tracingLayer } from "../telemetry/tracing.layer.ts"; import { CliArgs } from "./cli-args.service.ts"; @@ -53,6 +65,186 @@ const selfManagedSignalCommands: ReadonlyArray> = [ ["functions", "serve"], ]; +const preHandlerTelemetryTags = new Set([ + "LegacyInvalidOutputFormatError", + "MissingOption", + "UnknownSubcommand", + "UnrecognizedOption", +]); + +const rootTelemetryCommandPaths: ReadonlyArray> = [ + ["db", "schema", "declarative", "generate"], + ["db", "schema", "declarative", "sync"], + ["inspect", "db", "bloat"], + ["inspect", "db", "blocking"], + ["inspect", "db", "cache-hit"], + ["inspect", "db", "calls"], + ["inspect", "db", "db-stats"], + ["inspect", "db", "index-sizes"], + ["inspect", "db", "index-stats"], + ["inspect", "db", "index-usage"], + ["inspect", "db", "locks"], + ["inspect", "db", "long-running-queries"], + ["inspect", "db", "outliers"], + ["inspect", "db", "replication-slots"], + ["inspect", "db", "role-configs"], + ["inspect", "db", "role-connections"], + ["inspect", "db", "role-stats"], + ["inspect", "db", "seq-scans"], + ["inspect", "db", "table-index-sizes"], + ["inspect", "db", "table-record-counts"], + ["inspect", "db", "table-sizes"], + ["inspect", "db", "table-stats"], + ["inspect", "db", "total-index-size"], + ["inspect", "db", "total-table-sizes"], + ["inspect", "db", "traffic-profile"], + ["inspect", "db", "unused-indexes"], + ["inspect", "db", "vacuum-stats"], + ["backups", "list"], + ["backups", "restore"], + ["branches", "create"], + ["branches", "delete"], + ["branches", "disable"], + ["branches", "get"], + ["branches", "list"], + ["branches", "pause"], + ["branches", "switch"], + ["branches", "unpause"], + ["branches", "update"], + ["completion", "bash"], + ["completion", "fish"], + ["completion", "powershell"], + ["completion", "zsh"], + ["config", "push"], + ["db", "advisors"], + ["db", "branch"], + ["db", "diff"], + ["db", "dump"], + ["db", "lint"], + ["db", "pull"], + ["db", "push"], + ["db", "query"], + ["db", "remote"], + ["db", "reset"], + ["db", "schema"], + ["db", "start"], + ["db", "test"], + ["domains", "activate"], + ["domains", "create"], + ["domains", "delete"], + ["domains", "get"], + ["domains", "reverify"], + ["encryption", "get-root-key"], + ["encryption", "update-root-key"], + ["functions", "delete"], + ["functions", "deploy"], + ["functions", "dev"], + ["functions", "download"], + ["functions", "list"], + ["functions", "new"], + ["functions", "serve"], + ["gen", "bearer-jwt"], + ["gen", "keys"], + ["gen", "signing-key"], + ["gen", "types"], + ["inspect", "db"], + ["inspect", "report"], + ["migration", "down"], + ["migration", "fetch"], + ["migration", "list"], + ["migration", "new"], + ["migration", "repair"], + ["migration", "squash"], + ["migration", "up"], + ["network-bans", "get"], + ["network-bans", "remove"], + ["network-restrictions", "get"], + ["network-restrictions", "update"], + ["orgs", "create"], + ["orgs", "list"], + ["postgres-config", "delete"], + ["postgres-config", "get"], + ["postgres-config", "update"], + ["projects", "api-keys"], + ["projects", "create"], + ["projects", "delete"], + ["projects", "list"], + ["secrets", "list"], + ["secrets", "set"], + ["secrets", "unset"], + ["seed", "buckets"], + ["snippets", "download"], + ["snippets", "list"], + ["ssl-enforcement", "get"], + ["ssl-enforcement", "update"], + ["sso", "add"], + ["sso", "info"], + ["sso", "list"], + ["sso", "remove"], + ["sso", "show"], + ["sso", "update"], + ["storage", "cp"], + ["storage", "ls"], + ["storage", "mv"], + ["storage", "rm"], + ["telemetry", "disable"], + ["telemetry", "enable"], + ["telemetry", "status"], + ["test", "db"], + ["test", "new"], + ["vanity-subdomains", "activate"], + ["vanity-subdomains", "check-availability"], + ["vanity-subdomains", "delete"], + ["vanity-subdomains", "get"], + ["backups"], + ["bootstrap"], + ["branches"], + ["completion"], + ["config"], + ["db"], + ["domains"], + ["encryption"], + ["functions"], + ["gen"], + ["init"], + ["inspect"], + ["issue"], + ["link"], + ["list"], + ["login"], + ["logout"], + ["logs"], + ["migration"], + ["network-bans"], + ["network-restrictions"], + ["orgs"], + ["platform"], + ["postgres-config"], + ["projects"], + ["secrets"], + ["seed"], + ["services"], + ["snippets"], + ["ssl-enforcement"], + ["sso"], + ["start"], + ["status"], + ["stop"], + ["storage"], + ["telemetry"], + ["test"], + ["unlink"], + ["update"], + ["vanity-subdomains"], +]; + +const legacyRootTelemetryMachineOutputFormats = new Set(["csv", "env", "json", "toml", "yaml"]); +const legacyRootTelemetryOutputFormats = new Set([ + ...legacyRootTelemetryMachineOutputFormats, + "pretty", + "table", +]); + /** Positional command-path tokens from argv, skipping global flags and their values. */ export function extractCommandPath(args: ReadonlyArray): ReadonlyArray { const commandArgs: Array = []; @@ -102,6 +294,104 @@ function isExplicitHelpCause(cause: Cause.Cause): boolean { return !Array.isArray(errors) || errors.length === 0; } +function readCauseErrorTag(cause: Cause.Cause): string | undefined { + const error = Cause.findErrorOption(cause); + if (error._tag !== "Some" || !isErrorRecord(error.value)) return undefined; + const tag = error.value["_tag"]; + return typeof tag === "string" ? tag : undefined; +} + +export function shouldCapturePreHandlerFailureTelemetry(cause: Cause.Cause): boolean { + const error = Cause.findErrorOption(cause); + if (error._tag !== "Some" || !isErrorRecord(error.value)) return false; + + const tag = readCauseErrorTag(cause); + if (tag === "ShowHelp") { + const errors = error.value["errors"]; + return Array.isArray(errors) && errors.length > 0; + } + + return tag !== undefined && preHandlerTelemetryTags.has(tag); +} + +export function commandNameForRootTelemetry(args: ReadonlyArray): string { + const commandPath = extractCommandPath(args); + const matchedPath = rootTelemetryCommandPaths.find((candidate) => + candidate.every((segment, index) => commandPath[index] === segment), + ); + return matchedPath === undefined ? "root" : matchedPath.join(" "); +} + +function extractLegacyRootTelemetryOutputFormat(args: ReadonlyArray): string | undefined { + let format: string | undefined; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) continue; + + if (arg === "--output" || arg === "-o") { + const value = args[index + 1]; + if (value !== undefined && legacyRootTelemetryOutputFormats.has(value)) { + format = value; + } + index += 1; + continue; + } + + if (arg.startsWith("--output=") || arg.startsWith("-o=")) { + const value = arg.slice(arg.indexOf("=") + 1); + if (legacyRootTelemetryOutputFormats.has(value)) { + format = value; + } + } + } + + return format; +} + +export function outputFormatForRootTelemetry( + args: ReadonlyArray, + outputFormat: OutputFormat, +): string { + const legacyOutputFormat = extractLegacyRootTelemetryOutputFormat(args); + if (legacyOutputFormat === "table" && commandNameForRootTelemetry(args) === "db query") { + return "table"; + } + if ( + legacyOutputFormat !== undefined && + legacyRootTelemetryMachineOutputFormats.has(legacyOutputFormat) + ) { + return legacyOutputFormat; + } + return outputFormat; +} + +function capturePreHandlerFailureTelemetry( + args: ReadonlyArray, + outputFormat: string, + durationMs: number, + cause: Cause.Cause, +) { + return Effect.gen(function* () { + if (!shouldCapturePreHandlerFailureTelemetry(cause)) return; + + const analytics = yield* Analytics; + yield* analytics + .capture(EventCommandExecuted, { + [PropExitCode]: 1, + [PropDurationMs]: durationMs, + [PropOutputFormat]: outputFormat, + ...classifyCliCauseActionability(cause), + }) + .pipe( + withAnalyticsContext({ + command_run_id: crypto.randomUUID(), + command: commandNameForRootTelemetry(args), + }), + ); + }); +} + function projectContextLayerFor(runtimeLayer: Layer.Layer) { return projectContextLayer.pipe(Layer.provide(runtimeLayer), Layer.provide(BunServices.layer)); } @@ -122,7 +412,11 @@ function projectHomeLayerFor(runtimeLayer: Layer.Layer) { ); } -type AnyAnalyticsLayer = Layer.Layer; +type AnyAnalyticsLayer = Layer.Layer< + Analytics, + never, + CliConfig | FileSystem.FileSystem | Path.Path | RuntimeInfo | Tty +>; export interface RunCliOptions { readonly analyticsLayer: AnyAnalyticsLayer; @@ -226,10 +520,18 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp Effect.gen(function* () { const processControl = yield* ProcessControl; const output = yield* Output; + const startedAt = yield* Clock.currentTimeMillis; const exit = yield* program.pipe(Effect.exit); + const finishedAt = yield* Clock.currentTimeMillis; if (Exit.isFailure(exit)) { const interrupted = Cause.hasInterruptsOnly(exit.cause); if (!interrupted && !isExplicitHelpCause(exit.cause)) { + yield* capturePreHandlerFailureTelemetry( + args, + outputFormatForRootTelemetry(args, outputFormat), + finishedAt - startedAt, + exit.cause, + ); yield* output.fail(normalizeCause(exit.cause)); } return yield* processControl.exit(interrupted ? 130 : 1); @@ -238,6 +540,8 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp return yield* processControl.exit(exitCode ?? 0); }).pipe( Effect.provide(outputLayerFor(outputFormat)), + Effect.provide(options.analyticsLayer), + Effect.provide(tracingLayer), Effect.provide(telemetryRuntimeLayer), Effect.provide(projectHomeLayerFor(handledRuntimeLayer)), Effect.provide(cliConfigLayerFor(handledRuntimeLayer)), diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index fdb66377a3..887aeb2dc4 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -1,6 +1,13 @@ +import { Cause } from "effect"; import { describe, expect, it } from "vitest"; -import { extractCommandPath, shouldUseGlobalSignalInterrupt } from "./run.ts"; +import { + commandNameForRootTelemetry, + extractCommandPath, + outputFormatForRootTelemetry, + shouldCapturePreHandlerFailureTelemetry, + shouldUseGlobalSignalInterrupt, +} from "./run.ts"; describe("extractCommandPath", () => { it("returns positional command-path tokens", () => { @@ -42,3 +49,80 @@ describe("shouldUseGlobalSignalInterrupt", () => { expect(shouldUseGlobalSignalInterrupt([])).toBe(true); }); }); + +describe("commandNameForRootTelemetry", () => { + it("uses static command prefixes without argv values", () => { + expect(commandNameForRootTelemetry(["branches", "get", "customer-branch", "--bad"])).toBe( + "branches get", + ); + expect( + commandNameForRootTelemetry([ + "functions", + "deploy", + "--project-ref", + "abcdefghijklmnopqrst", + "my-function", + "--bad", + ]), + ).toBe("functions deploy"); + expect(commandNameForRootTelemetry(["db", "schema", "declarative", "sync", "schema"])).toBe( + "db schema declarative sync", + ); + }); + + it("falls back to root for unknown user-supplied command roots", () => { + expect(commandNameForRootTelemetry(["customer-branch", "--bad"])).toBe("root"); + }); +}); + +describe("outputFormatForRootTelemetry", () => { + it("preserves legacy machine output values from -o and --output", () => { + expect(outputFormatForRootTelemetry(["functions", "list", "-o", "env"], "text")).toBe("env"); + expect(outputFormatForRootTelemetry(["projects", "list", "--output=json"], "text")).toBe( + "json", + ); + expect(outputFormatForRootTelemetry(["db", "query", "-o=csv"], "text")).toBe("csv"); + }); + + it("preserves db query table output for root telemetry", () => { + expect(outputFormatForRootTelemetry(["db", "query", "-o", "table", "--bad"], "text")).toBe( + "table", + ); + }); + + it("keeps the resolved TS output format for human or invalid legacy values", () => { + expect(outputFormatForRootTelemetry(["functions", "list", "-o", "pretty"], "text")).toBe( + "text", + ); + expect(outputFormatForRootTelemetry(["functions", "list", "-o", "table"], "text")).toBe("text"); + expect(outputFormatForRootTelemetry(["functions", "list", "-o", "xml"], "json")).toBe("json"); + }); +}); + +describe("shouldCapturePreHandlerFailureTelemetry", () => { + it("captures parser and wrapper failures that happen before command handlers run", () => { + expect(shouldCapturePreHandlerFailureTelemetry(Cause.fail({ _tag: "UnknownSubcommand" }))).toBe( + true, + ); + expect( + shouldCapturePreHandlerFailureTelemetry(Cause.fail({ _tag: "UnrecognizedOption" })), + ).toBe(true); + expect( + shouldCapturePreHandlerFailureTelemetry( + Cause.fail({ _tag: "LegacyInvalidOutputFormatError" }), + ), + ).toBe(true); + expect( + shouldCapturePreHandlerFailureTelemetry( + Cause.fail({ _tag: "ShowHelp", errors: [{ _tag: "MissingOption", option: "type" }] }), + ), + ).toBe(true); + }); + + it("leaves handler failures and explicit help to the existing paths", () => { + expect( + shouldCapturePreHandlerFailureTelemetry(Cause.fail({ _tag: "ProjectNotLinkedError" })), + ).toBe(false); + expect(shouldCapturePreHandlerFailureTelemetry(Cause.fail({ _tag: "ShowHelp" }))).toBe(false); + }); +}); diff --git a/apps/cli/src/shared/telemetry/command-instrumentation.ts b/apps/cli/src/shared/telemetry/command-instrumentation.ts index 1b77bc35a3..e7032db109 100644 --- a/apps/cli/src/shared/telemetry/command-instrumentation.ts +++ b/apps/cli/src/shared/telemetry/command-instrumentation.ts @@ -7,6 +7,7 @@ import { import { Output } from "../output/output.service.ts"; import { withAnalyticsContext } from "./analytics-context.ts"; import { Analytics } from "./analytics.service.ts"; +import { classifyCliCauseActionability } from "./error-actionability.ts"; interface CommandInstrumentationOptions = never> { readonly analytics?: boolean; @@ -123,6 +124,7 @@ function withCommandAnalyticsImplementation { ); }); - it.live("captures failed commands with a non-zero exit code", () => { + it.live("captures failed commands with actionability metadata", () => { const analytics = mockContextualAnalytics(); - const program = withCommandInstrumentation()(Effect.fail(new Error("boom"))).pipe( + const program = withCommandInstrumentation()( + Effect.fail({ _tag: "ProjectNotLinkedError", message: "Project not linked." }), + ).pipe( Effect.provide(analytics.layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( @@ -115,6 +117,14 @@ describe("withCommandInstrumentation", () => { expect(analytics.captured).toHaveLength(1); expect(analytics.captured[0]?.event).toBe("cli_command_executed"); expect(analytics.captured[0]?.properties.exit_code).toBe(1); + expect(analytics.captured[0]?.properties).toMatchObject({ + error_kind: "user_actionable", + error_category: "project_not_linked", + error_fingerprint: "tag:ProjectNotLinkedError", + has_suggestion: true, + suggestion_type: "link_project", + suggested_command: "supabase link", + }); }), ), ); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts new file mode 100644 index 0000000000..53c862b0fd --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -0,0 +1,915 @@ +import { Cause, Option } from "effect"; + +export const CliErrorKind = { + UserActionable: "user_actionable", + InternalBug: "internal_bug", + ExternalService: "external_service", + UserCancelled: "user_cancelled", + Unknown: "unknown", +} as const; + +export type CliErrorKind = (typeof CliErrorKind)[keyof typeof CliErrorKind]; + +export const CliErrorCategory = { + Auth: "auth", + MissingProjectRef: "missing_project_ref", + ProjectNotLinked: "project_not_linked", + DockerNotRunning: "docker_not_running", + InvalidConfig: "invalid_config", + DbConnection: "db_connection", + MigrationDrift: "migration_drift", + Permission: "permission", + PlanLimit: "plan_limit", + InvalidInput: "invalid_input", + Network: "network", + ApiStatus: "api_status", + Cancelled: "cancelled", + Panic: "panic", + ImpossibleState: "impossible_state", + Unknown: "unknown", +} as const; + +export type CliErrorCategory = (typeof CliErrorCategory)[keyof typeof CliErrorCategory]; + +export const CliSuggestionType = { + Login: "login", + LinkProject: "link_project", + StartDocker: "start_docker", + ProvideFlags: "provide_flags", + SetEnvVar: "set_env_var", + RepairMigration: "repair_migration", + UpdateConfig: "update_config", + UpgradePlan: "upgrade_plan", + RerunDebug: "rerun_debug", + None: "none", +} as const; + +export type CliSuggestionType = (typeof CliSuggestionType)[keyof typeof CliSuggestionType]; + +export interface CliErrorActionability { + readonly error_kind: CliErrorKind; + readonly error_category: CliErrorCategory; + readonly error_fingerprint: string; + readonly has_suggestion: boolean; + readonly suggestion_type: CliSuggestionType; + readonly suggested_command?: string; + readonly workflow?: string; +} + +export const CliErrorActionabilityMetricDefinitions = { + strictRecovery: { + id: "same_command_success_same_session", + description: + "A user-actionable error is recovered when the same command later succeeds in the same PostHog session.", + }, + repeatError: { + id: "same_command_same_error_same_session_before_success", + description: + "A user-actionable error repeats when the same command fails again with the same error fingerprint in the same PostHog session before success.", + }, + internalUnknownBugRate: { + id: "failed_commands_internal_bug_or_unknown", + description: + "Internal/unknown bug failure rate is the share of failed commands classified as internal_bug or unknown.", + }, +} as const; + +type ErrorRecord = Record; + +type ActionabilityTemplate = Omit; + +interface ClassifiedTemplate { + readonly template: ActionabilityTemplate; + readonly fingerprint_suffix?: string; +} + +const authLoginTemplate = { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Auth, + has_suggestion: true, + suggestion_type: CliSuggestionType.Login, + suggested_command: "supabase login", +} satisfies ActionabilityTemplate; + +const authTokenTemplate = { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Auth, + has_suggestion: true, + suggestion_type: CliSuggestionType.SetEnvVar, +} satisfies ActionabilityTemplate; + +const provideFlagsTemplate = { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidInput, + has_suggestion: true, + suggestion_type: CliSuggestionType.ProvideFlags, +} satisfies ActionabilityTemplate; + +const externalNetworkTemplate = { + error_kind: CliErrorKind.ExternalService, + error_category: CliErrorCategory.Network, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, +} satisfies ActionabilityTemplate; + +const externalStatusTemplate = { + error_kind: CliErrorKind.ExternalService, + error_category: CliErrorCategory.ApiStatus, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, +} satisfies ActionabilityTemplate; + +const cancelledTemplate = { + error_kind: CliErrorKind.UserCancelled, + error_category: CliErrorCategory.Cancelled, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, +} satisfies ActionabilityTemplate; + +const planLimitTemplate = { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.PlanLimit, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpgradePlan, +} satisfies ActionabilityTemplate; + +const invalidInputTemplate = { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidInput, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, +} satisfies ActionabilityTemplate; + +const invalidConfigTemplate = { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, +} satisfies ActionabilityTemplate; + +const dbFindingTemplate = { + ...invalidConfigTemplate, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, +} satisfies ActionabilityTemplate; + +const startStackTemplate = { + ...invalidConfigTemplate, + suggested_command: "supabase start", +} satisfies ActionabilityTemplate; + +const stopStackTemplate = { + ...invalidConfigTemplate, + suggested_command: "supabase stop", +} satisfies ActionabilityTemplate; + +const dbConnectionTemplate = { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.DbConnection, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, +} satisfies ActionabilityTemplate; + +const migrationDriftTemplate = { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.MigrationDrift, + has_suggestion: true, + suggestion_type: CliSuggestionType.RepairMigration, +} satisfies ActionabilityTemplate; + +const permissionTemplate = { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Permission, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, +} satisfies ActionabilityTemplate; + +const accountAccessTemplate = { + ...permissionTemplate, + suggestion_type: CliSuggestionType.Login, + suggested_command: "supabase login", +} satisfies ActionabilityTemplate; + +const internalPanicTemplate = { + error_kind: CliErrorKind.InternalBug, + error_category: CliErrorCategory.Panic, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, +} satisfies ActionabilityTemplate; + +const defaultUnknownTemplate: ActionabilityTemplate = { + error_kind: CliErrorKind.Unknown, + error_category: CliErrorCategory.Unknown, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, +}; + +const actionabilityByTag = { + InvalidTokenError: authLoginTemplate, + LegacyInvalidAccessTokenError: authLoginTemplate, + LegacyLinkAuthTokenError: authLoginTemplate, + LegacyPlatformAuthRequiredError: authLoginTemplate, + PlatformAuthRequiredError: authLoginTemplate, + LegacyDbAdvisorsInvalidTokenError: authLoginTemplate, + LegacyDbAdvisorsNotLoggedInError: authLoginTemplate, + LegacyDbQueryLoginRequiredError: authLoginTemplate, + LegacyLoginFailedError: authLoginTemplate, + LoginFailedError: authLoginTemplate, + LegacyLoginSaveTokenError: authLoginTemplate, + LegacyLoginMissingTokenError: authTokenTemplate, + LegacyStorageAuthTokenError: authLoginTemplate, + LegacyDbConnectError: dbConnectionTemplate, + LegacyDbConfigConnectTempRoleError: dbConnectionTemplate, + LegacyDbConfigIpv6Error: dbConnectionTemplate, + LegacyDbConfigPoolerLoginError: dbConnectionTemplate, + LegacyDbConfigLoadError: invalidConfigTemplate, + LegacyDbConfigParseUrlError: invalidConfigTemplate, + ProjectConfigParseError: invalidConfigTemplate, + LegacyConfigPushLoadConfigError: invalidConfigTemplate, + LegacySeedConfigLoadError: invalidConfigTemplate, + LegacySecretsConfigParseError: invalidConfigTemplate, + LegacyDbPullMigrationConflictError: migrationDriftTemplate, + LegacyMigrationMissingLocalError: migrationDriftTemplate, + LegacyMigrationMissingRemoteError: migrationDriftTemplate, + LegacyMigrationTargetFlagsError: provideFlagsTemplate, + LegacyMigrationPasswordFlagsError: provideFlagsTemplate, + LegacyMigrationInvalidVersionError: provideFlagsTemplate, + LegacyMigrationFileNotFoundError: provideFlagsTemplate, + LegacyMigrationLastZeroError: provideFlagsTemplate, + LegacyMigrationLastTooLargeError: provideFlagsTemplate, + LegacyDeleteTokenError: permissionTemplate, + LegacyCredentialDeleteError: permissionTemplate, + ProjectNotLinkedError: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.ProjectNotLinked, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + LegacyProjectNotLinkedError: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.ProjectNotLinked, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + LegacyProjectRefRequiredError: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.MissingProjectRef, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + ProjectRefRequiredError: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.MissingProjectRef, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + InvalidProjectLinkStateError: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + NoAccessibleProjectsError: accountAccessTemplate, + LegacyProjectPausedError: { + ...invalidConfigTemplate, + }, + InvalidServiceVersionOverrideError: provideFlagsTemplate, + FunctionsDevEdgeRuntimeDisabledError: invalidConfigTemplate, + LegacyInvalidProjectRefError: invalidInputTemplate, + LegacyInvalidSecretPairError: invalidInputTemplate, + LegacySecretsEnvFileOpenError: invalidInputTemplate, + LegacySecretsEnvFileParseError: invalidInputTemplate, + LegacySecretsNoArgumentsError: provideFlagsTemplate, + LegacyDomainsCnameError: invalidConfigTemplate, + InitExperimentalRequiredError: provideFlagsTemplate, + InitAlreadyExistsError: provideFlagsTemplate, + InitParseSettingsError: invalidConfigTemplate, + LegacySsoInvalidUuidError: invalidInputTemplate, + LegacySsoMutexFlagError: invalidInputTemplate, + LegacySsoAddMetadataFileError: provideFlagsTemplate, + LegacySsoUpdateMetadataFileError: provideFlagsTemplate, + LegacySsoAddAttributeMappingFileError: provideFlagsTemplate, + LegacySsoUpdateAttributeMappingFileError: provideFlagsTemplate, + LegacySsoMetadataUrlInvalidError: provideFlagsTemplate, + LegacySsoMetadataUrlNonUtf8Error: provideFlagsTemplate, + LegacySsoShowNotFoundError: invalidInputTemplate, + LegacySsoUpdateNotFoundError: invalidInputTemplate, + LegacySsoRemoveNotFoundError: invalidInputTemplate, + LegacyBootstrapInvalidTemplateError: provideFlagsTemplate, + LegacyBootstrapOverwriteDeclinedError: cancelledTemplate, + LegacyBranchesBranchingDisabledError: { + ...invalidInputTemplate, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + suggested_command: "supabase branches create", + }, + LegacyBranchesBranchNameEmptyError: provideFlagsTemplate, + NoBranchNameError: provideFlagsTemplate, + BranchAlreadyExistsError: provideFlagsTemplate, + BranchNotFoundError: provideFlagsTemplate, + PlatformInputError: provideFlagsTemplate, + PlatformRouteNotFoundError: provideFlagsTemplate, + PlatformMethodSelectionError: provideFlagsTemplate, + LegacyInvalidOutputFormatError: invalidInputTemplate, + LegacyBranchesEnvNotSupportedError: invalidInputTemplate, + LegacyFunctionsEnvNotSupportedError: invalidInputTemplate, + LegacyNetworkBansEnvNotSupportedError: invalidInputTemplate, + LegacyOrgsEnvNotSupportedError: invalidInputTemplate, + LegacyProjectsEnvNotSupportedError: invalidInputTemplate, + LegacySecretsEnvNotSupportedError: invalidInputTemplate, + LegacyServicesEnvNotSupportedError: invalidInputTemplate, + LegacySnippetsEnvNotSupportedError: invalidInputTemplate, + LegacySsoShowEnvNotSupportedError: invalidInputTemplate, + LegacyExperimentalRequiredError: provideFlagsTemplate, + LegacyDbDumpRequiresDataOnlyError: provideFlagsTemplate, + LegacyDbDumpMutuallyExclusiveFlagsError: provideFlagsTemplate, + LegacyDbDumpOpenFileError: provideFlagsTemplate, + LegacyDbDiffTargetFlagsError: provideFlagsTemplate, + LegacyDbDiffEngineConflictError: provideFlagsTemplate, + LegacyDbDiffExplicitFlagsError: provideFlagsTemplate, + LegacyDbDiffUnknownTargetError: provideFlagsTemplate, + LegacyDbQueryMutuallyExclusiveFlagsError: provideFlagsTemplate, + LegacyDbQueryReadFileError: provideFlagsTemplate, + LegacyDbQueryNoStdinSqlError: provideFlagsTemplate, + LegacyDbQueryNoSqlError: provideFlagsTemplate, + LegacyDbPullTargetFlagsError: provideFlagsTemplate, + LegacyDbPullEngineConflictError: provideFlagsTemplate, + LegacyDbLintMutuallyExclusiveFlagsError: provideFlagsTemplate, + LegacyDbAdvisorsMutuallyExclusiveFlagsError: provideFlagsTemplate, + LegacyDeclarativeNotEnabledError: provideFlagsTemplate, + LegacyDeclarativeMutuallyExclusiveFlagsError: provideFlagsTemplate, + LegacyDeclarativeNonInteractiveError: provideFlagsTemplate, + LegacyDeclarativeInvalidDbUrlError: provideFlagsTemplate, + LegacyPostgresConfigInvalidConfigValueError: provideFlagsTemplate, + LegacyTestDbMutuallyExclusiveFlagsError: provideFlagsTemplate, + LegacyInspectMutuallyExclusiveFlagsError: provideFlagsTemplate, + LegacyNetworkBansInvalidIpError: invalidInputTemplate, + LegacyNetworkRestrictionsInvalidCidrError: invalidInputTemplate, + LegacyNetworkRestrictionsPrivateIpError: invalidInputTemplate, + LegacySslEnforcementMutuallyExclusiveFlagsError: provideFlagsTemplate, + LegacySslEnforcementNoEnableDisableFlagError: provideFlagsTemplate, + LegacyProjectsCreateMissingArgError: provideFlagsTemplate, + LegacyProjectsCreateNameEmptyError: provideFlagsTemplate, + LegacyProjectsDeleteRefRequiredError: provideFlagsTemplate, + LegacyProjectsDeleteNotFoundError: provideFlagsTemplate, + LegacyInvalidGenTypesDurationError: provideFlagsTemplate, + LegacyInvalidGenTypesDatabaseUrlError: provideFlagsTemplate, + LegacySnippetsInvalidIdError: invalidInputTemplate, + LegacyStorageInvalidUrlError: provideFlagsTemplate, + LegacyStorageUrlParseError: provideFlagsTemplate, + LegacyStorageMissingFlagError: provideFlagsTemplate, + LegacyStorageMutuallyExclusiveFlagsError: provideFlagsTemplate, + LegacyStorageUnsupportedOperationError: provideFlagsTemplate, + LegacyStorageCopyBetweenBucketsError: provideFlagsTemplate, + LegacyStorageUnsupportedMoveError: provideFlagsTemplate, + LegacyStorageMissingPathError: provideFlagsTemplate, + LegacyStorageMissingBucketError: provideFlagsTemplate, + InvalidFunctionDeploySlugError: provideFlagsTemplate, + InvalidFunctionSlugError: provideFlagsTemplate, + LegacyFunctionsNewInvalidSlugError: provideFlagsTemplate, + LegacyFunctionsNewFileExistsError: provideFlagsTemplate, + MissingFunctionSlugError: provideFlagsTemplate, + FunctionEntrypointExistsError: provideFlagsTemplate, + ConflictingFunctionDownloadFlagsError: provideFlagsTemplate, + ConflictingFunctionDeployFlagsError: provideFlagsTemplate, + FunctionDownloadNotFoundError: provideFlagsTemplate, + FunctionNotFoundError: provideFlagsTemplate, + NoFunctionsToDeployError: provideFlagsTemplate, + UnsupportedLogsOutputFormatError: provideFlagsTemplate, + LegacyTestNewFileExistsError: provideFlagsTemplate, + LegacyDbAdvisorsFailOnError: dbFindingTemplate, + LegacyDbLintFailOnError: dbFindingTemplate, + MissingOption: invalidInputTemplate, + NoTtyError: authTokenTemplate, + NonInteractiveError: provideFlagsTemplate, + UnknownSubcommand: invalidInputTemplate, + UnrecognizedOption: invalidInputTemplate, + InvalidStackStateError: invalidConfigTemplate, + NoRunningStackError: startStackTemplate, + StateNotFoundError: startStackTemplate, + DaemonStartError: startStackTemplate, + DaemonStillRunningError: stopStackTemplate, + StackAlreadyRunningError: stopStackTemplate, +} satisfies Record; + +const actionabilityByTagLookup = new Map( + Object.entries(actionabilityByTag), +); + +function isErrorRecord(value: unknown): value is ErrorRecord { + return typeof value === "object" && value !== null; +} + +function readString(value: ErrorRecord, key: string): string | undefined { + const field = value[key]; + return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined; +} + +function readNumber(value: ErrorRecord, key: string): number | undefined { + const field = value[key]; + return typeof field === "number" && Number.isFinite(field) ? field : undefined; +} + +function readBoolean(value: ErrorRecord, key: string): boolean | undefined { + const field = value[key]; + return typeof field === "boolean" ? field : undefined; +} + +function readRecord(value: ErrorRecord, key: string): ErrorRecord | undefined { + const field = value[key]; + return isErrorRecord(field) ? field : undefined; +} + +function safeIdentifier(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + return /^[A-Za-z][A-Za-z0-9_]*$/.test(value) ? value : undefined; +} + +function readErrorTag(error: unknown): string | undefined { + if (!isErrorRecord(error)) return undefined; + return safeIdentifier(readString(error, "_tag")); +} + +function readErrorName(error: unknown): string | undefined { + if (error instanceof Error) return safeIdentifier(error.name); + if (!isErrorRecord(error)) return undefined; + return safeIdentifier(readString(error, "name")); +} + +function fingerprint(prefix: string, identifier: string | undefined, suffix?: string): string { + const base = identifier === undefined ? `${prefix}:unknown` : `${prefix}:${identifier}`; + return suffix === undefined ? base : `${base}:${suffix}`; +} + +function classifyShowHelp(error: ErrorRecord): CliErrorActionability | undefined { + const errors = error["errors"]; + if (!Array.isArray(errors)) return undefined; + if (errors.length === 1) return classifyCliErrorActionability(errors[0]); + return { + ...actionabilityByTag.MissingOption, + error_fingerprint: "tag:ShowHelp", + }; +} + +function hasDockerDaemonDownDetail(message: string | undefined): boolean { + const detail = message?.toLocaleLowerCase(); + if (detail === undefined) return false; + return ( + detail.includes("cannot connect to the docker daemon") || + detail.includes("docker daemon is not running") || + detail.includes("docker desktop is not running") || + detail.includes("is the docker daemon running") + ); +} + +function classifyLegacyDockerRunError(error: ErrorRecord): ClassifiedTemplate { + const message = readString(error, "message"); + if (message?.startsWith("failed to run docker.")) { + return { + template: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.DockerNotRunning, + has_suggestion: true, + suggestion_type: CliSuggestionType.StartDocker, + }, + fingerprint_suffix: "docker_not_running", + }; + } + if (message?.startsWith("failed to pull docker image from all registries:")) { + if (hasDockerDaemonDownDetail(message)) { + return { + template: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.DockerNotRunning, + has_suggestion: true, + suggestion_type: CliSuggestionType.StartDocker, + }, + fingerprint_suffix: "docker_not_running", + }; + } + return { + template: externalNetworkTemplate, + fingerprint_suffix: "registry_pull", + }; + } + return { + template: defaultUnknownTemplate, + fingerprint_suffix: "unknown", + }; +} + +function classifyDockerPullError(error: ErrorRecord): ClassifiedTemplate { + if (hasDockerDaemonDownDetail(textFields(error))) { + return { + template: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.DockerNotRunning, + has_suggestion: true, + suggestion_type: CliSuggestionType.StartDocker, + }, + fingerprint_suffix: "docker_not_running", + }; + } + return { + template: externalNetworkTemplate, + fingerprint_suffix: "registry_pull", + }; +} + +function classifyStackBuildError(error: ErrorRecord): ClassifiedTemplate { + const detail = readString(error, "detail"); + if ( + detail?.startsWith('mode "native" only supports') || + detail === "imgproxy requires storage to be enabled" || + detail === "vector requires analytics to be enabled" || + detail === "studio requires pgmeta to be enabled" || + detail === "Failed to persist stack cleanup metadata" + ) { + return { + template: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + }, + fingerprint_suffix: "invalid_config", + }; + } + if (detail === "Failed to prepare stack assets") { + return { + template: externalNetworkTemplate, + fingerprint_suffix: "asset_preparation", + }; + } + return { + template: { + error_kind: CliErrorKind.InternalBug, + error_category: CliErrorCategory.ImpossibleState, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, + fingerprint_suffix: "internal_build", + }; +} + +function textFields(error: ErrorRecord): string { + return ["body", "message", "detail", "suggestion"] + .flatMap((key) => { + const value = readString(error, key); + return value === undefined ? [] : [value.toLowerCase()]; + }) + .join("\n"); +} + +function hasPlanLimitDetail(error: ErrorRecord): boolean { + if (readBoolean(error, "upgradeSuggested") === true) return true; + const text = textFields(error); + return ( + text.includes("upgrade") || + text.includes("billing") || + text.includes("entitlement") || + text.includes("plan limit") || + text.includes("quota") || + text.includes("branching limit") + ); +} + +function classifySamlDisabledError(error: ErrorRecord): ClassifiedTemplate { + return hasPlanLimitDetail(error) + ? { + template: planLimitTemplate, + fingerprint_suffix: "plan_limit", + } + : { + template: invalidConfigTemplate, + fingerprint_suffix: "saml_disabled", + }; +} + +function classifyPlanGatedNotFoundError(error: ErrorRecord): ClassifiedTemplate { + return hasPlanLimitDetail(error) + ? { + template: planLimitTemplate, + fingerprint_suffix: "plan_limit", + } + : { + template: invalidInputTemplate, + }; +} + +function classifyGatedStatusError(error: ErrorRecord): ClassifiedTemplate { + const status = readNumber(error, "status"); + if (status === 401) { + return { + template: authLoginTemplate, + fingerprint_suffix: "auth", + }; + } + if (status !== undefined && status >= 400 && status < 500 && hasPlanLimitDetail(error)) { + return { + template: planLimitTemplate, + fingerprint_suffix: "plan_limit", + }; + } + return { + template: externalStatusTemplate, + fingerprint_suffix: "api_status", + }; +} + +function classifyLegacyStatusAuthError( + tag: string | undefined, + error: ErrorRecord, +): ClassifiedTemplate | undefined { + if (tag === undefined) return undefined; + if (!tag.endsWith("UnexpectedStatusError") && !tag.endsWith("StatusError")) return undefined; + + const status = readNumber(error, "status") ?? readNumber(error, "statusCode"); + if (status !== 401) return undefined; + + return { + template: authLoginTemplate, + fingerprint_suffix: "auth", + }; +} + +function classifyApiError(error: ErrorRecord): ClassifiedTemplate { + const statusCode = readNumber(error, "statusCode"); + if (statusCode === 401) { + return { + template: authLoginTemplate, + fingerprint_suffix: "auth", + }; + } + return statusCode === undefined + ? { + template: externalNetworkTemplate, + fingerprint_suffix: "network", + } + : { + template: externalStatusTemplate, + fingerprint_suffix: "api_status", + }; +} + +function classifyHttpClientError(error: ErrorRecord): ClassifiedTemplate { + const reasonTag = readRecord(error, "reason")?._tag; + const response = readRecord(error, "response"); + const status = response === undefined ? undefined : readNumber(response, "status"); + if (status === 401) { + return { + template: authLoginTemplate, + fingerprint_suffix: "auth", + }; + } + if (reasonTag === "StatusCodeError" || response !== undefined) { + return { + template: externalStatusTemplate, + fingerprint_suffix: "api_status", + }; + } + return { + template: externalNetworkTemplate, + fingerprint_suffix: "network", + }; +} + +function hasRequestInputDecodeSignal(error: ErrorRecord): boolean { + return ( + readBoolean(error, "requestInput") === true || + readString(error, "source") === "request" || + readString(error, "phase") === "request" + ); +} + +function classifyGeneratedClientBodyOrSchemaError(error: ErrorRecord): ClassifiedTemplate { + return hasRequestInputDecodeSignal(error) + ? { + template: provideFlagsTemplate, + fingerprint_suffix: "request_input", + } + : { + template: externalStatusTemplate, + fingerprint_suffix: "api_response", + }; +} + +function classifyStackError(error: ErrorRecord): ClassifiedTemplate { + if (readString(error, "code") === "PORT_ALLOCATION") { + return { + template: invalidConfigTemplate, + fingerprint_suffix: "port_allocation", + }; + } + return { + template: defaultUnknownTemplate, + fingerprint_suffix: "unknown", + }; +} + +function isNativeJsExceptionName(name: string | undefined): boolean { + return ( + name === "TypeError" || + name === "ReferenceError" || + name === "RangeError" || + name === "SyntaxError" || + name === "EvalError" || + name === "URIError" || + name === "AggregateError" + ); +} + +function inferTemplateFromTag(tag: string | undefined): ActionabilityTemplate | undefined { + if (tag === undefined) return undefined; + if (tag.endsWith("CancelledError") || tag.endsWith("CanceledError")) { + return cancelledTemplate; + } + if (tag.endsWith("NetworkError")) { + return externalNetworkTemplate; + } + if (tag.endsWith("UnexpectedStatusError") || tag.endsWith("StatusError")) { + return externalStatusTemplate; + } + if (/Panic/.test(tag)) { + return internalPanicTemplate; + } + if (/ImpossibleState/.test(tag)) { + return { + error_kind: CliErrorKind.InternalBug, + error_category: CliErrorCategory.ImpossibleState, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }; + } + return undefined; +} + +export function classifyProcessControlledFailureActionability( + command: string, +): CliErrorActionability { + if (command === "db advisors") { + return { + ...dbFindingTemplate, + error_fingerprint: "process_control:db_advisors_fail_on", + }; + } + if (command === "db lint") { + return { + ...dbFindingTemplate, + error_fingerprint: "process_control:db_lint_fail_on", + }; + } + return { + ...defaultUnknownTemplate, + error_fingerprint: "process_control:non_zero_exit", + }; +} + +export function classifyCliErrorActionability(error: unknown): CliErrorActionability { + const tag = readErrorTag(error); + if (tag === "ShowHelp" && isErrorRecord(error)) { + const classified = classifyShowHelp(error); + if (classified !== undefined) return classified; + } + if (tag === "LegacyDockerRunError" && isErrorRecord(error)) { + const classified = classifyLegacyDockerRunError(error); + return { + ...classified.template, + error_fingerprint: fingerprint("tag", tag, classified.fingerprint_suffix), + }; + } + if (tag === "DockerPullError" && isErrorRecord(error)) { + const classified = classifyDockerPullError(error); + return { + ...classified.template, + error_fingerprint: fingerprint("tag", tag, classified.fingerprint_suffix), + }; + } + if (tag === "StackBuildError" && isErrorRecord(error)) { + const classified = classifyStackBuildError(error); + return { + ...classified.template, + error_fingerprint: fingerprint("tag", tag, classified.fingerprint_suffix), + }; + } + if ( + (tag === "LegacySsoAddSamlDisabledError" || tag === "LegacySsoListSamlDisabledError") && + isErrorRecord(error) + ) { + const classified = classifySamlDisabledError(error); + return { + ...classified.template, + error_fingerprint: fingerprint("tag", tag, classified.fingerprint_suffix), + }; + } + if ( + (tag === "LegacySsoUpdateNotFoundError" || tag === "LegacySsoRemoveNotFoundError") && + isErrorRecord(error) + ) { + const classified = classifyPlanGatedNotFoundError(error); + return { + ...classified.template, + error_fingerprint: fingerprint("tag", tag, classified.fingerprint_suffix), + }; + } + if ( + (tag === "LegacyBranchesCreateUnexpectedStatusError" || + tag === "LegacyBranchesUpdateUnexpectedStatusError" || + tag === "LegacySsoAddUnexpectedStatusError" || + tag === "LegacySsoListUnexpectedStatusError" || + tag === "LegacySsoUpdateUnexpectedStatusError" || + tag === "LegacySsoRemoveUnexpectedStatusError" || + tag === "LegacyVanitySubdomainsActivateUnexpectedStatusError" || + tag === "LegacyVanitySubdomainsCheckUnexpectedStatusError") && + isErrorRecord(error) + ) { + const classified = classifyGatedStatusError(error); + return { + ...classified.template, + error_fingerprint: fingerprint("tag", tag, classified.fingerprint_suffix), + }; + } + if (tag === "ApiError" && isErrorRecord(error)) { + const classified = classifyApiError(error); + return { + ...classified.template, + error_fingerprint: fingerprint("tag", tag, classified.fingerprint_suffix), + }; + } + if (tag === "HttpClientError" && isErrorRecord(error)) { + const classified = classifyHttpClientError(error); + return { + ...classified.template, + error_fingerprint: fingerprint("tag", tag, classified.fingerprint_suffix), + }; + } + if ((tag === "HttpBodyError" || tag === "SchemaError") && isErrorRecord(error)) { + const classified = classifyGeneratedClientBodyOrSchemaError(error); + return { + ...classified.template, + error_fingerprint: fingerprint("tag", tag, classified.fingerprint_suffix), + }; + } + if (readErrorName(error) === "StackError" && isErrorRecord(error)) { + const classified = classifyStackError(error); + return { + ...classified.template, + error_fingerprint: fingerprint("error", "StackError", classified.fingerprint_suffix), + }; + } + if (isErrorRecord(error)) { + const classified = classifyLegacyStatusAuthError(tag, error); + if (classified !== undefined) { + return { + ...classified.template, + error_fingerprint: fingerprint("tag", tag, classified.fingerprint_suffix), + }; + } + } + + const template = tag === undefined ? undefined : actionabilityByTagLookup.get(tag); + const inferred = template ?? inferTemplateFromTag(tag); + if (inferred !== undefined) { + return { + ...inferred, + error_fingerprint: fingerprint("tag", tag), + }; + } + + if (tag !== undefined) { + return { + ...defaultUnknownTemplate, + error_fingerprint: fingerprint("tag", tag), + }; + } + + if (typeof error === "string") { + return { + ...defaultUnknownTemplate, + error_fingerprint: "string:unknown", + }; + } + + const name = readErrorName(error); + if (isNativeJsExceptionName(name)) { + return { + ...internalPanicTemplate, + error_fingerprint: fingerprint("error", name), + }; + } + + return { + ...defaultUnknownTemplate, + error_fingerprint: fingerprint("error", name), + }; +} + +export function classifyCliCauseActionability(cause: Cause.Cause): CliErrorActionability { + const error = Option.getOrElse(Cause.findErrorOption(cause), () => Cause.squash(cause)); + return classifyCliErrorActionability(error); +} diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts new file mode 100644 index 0000000000..ccc1bd8a37 --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -0,0 +1,1145 @@ +import { describe, expect, test } from "vitest"; +import { + classifyCliErrorActionability, + CliErrorActionabilityMetricDefinitions, + CliErrorCategory, + CliErrorKind, + CliSuggestionType, +} from "./error-actionability.ts"; + +describe("CLI error actionability taxonomy", () => { + test("defines the KPI classification values required for the Q2 baseline", () => { + expect(Object.values(CliErrorKind)).toEqual([ + "user_actionable", + "internal_bug", + "external_service", + "user_cancelled", + "unknown", + ]); + + expect(Object.values(CliErrorCategory)).toEqual( + expect.arrayContaining([ + "auth", + "missing_project_ref", + "project_not_linked", + "docker_not_running", + "invalid_config", + "db_connection", + "migration_drift", + "permission", + "plan_limit", + "invalid_input", + "cancelled", + "panic", + "unknown", + "impossible_state", + ]), + ); + + expect(Object.values(CliSuggestionType)).toEqual( + expect.arrayContaining([ + "login", + "link_project", + "start_docker", + "provide_flags", + "set_env_var", + "repair_migration", + "update_config", + "upgrade_plan", + "rerun_debug", + "none", + ]), + ); + }); + + test("documents queryable recovery and repeat definitions", () => { + expect(CliErrorActionabilityMetricDefinitions.strictRecovery.id).toBe( + "same_command_success_same_session", + ); + expect(CliErrorActionabilityMetricDefinitions.repeatError.id).toBe( + "same_command_same_error_same_session_before_success", + ); + expect(CliErrorActionabilityMetricDefinitions.internalUnknownBugRate.id).toBe( + "failed_commands_internal_bug_or_unknown", + ); + }); + + test("classifies auth errors without using raw messages", () => { + expect( + classifyCliErrorActionability({ + _tag: "LegacyPlatformAuthRequiredError", + message: "token abc123 for project ref xyz", + }), + ).toEqual({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:LegacyPlatformAuthRequiredError", + has_suggestion: true, + suggestion_type: "login", + suggested_command: "supabase login", + }); + }); + + test("classifies command-local auth wrappers as login remediations", () => { + expect( + classifyCliErrorActionability({ _tag: "LegacyDbAdvisorsNotLoggedInError" }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:LegacyDbAdvisorsNotLoggedInError", + suggestion_type: "login", + suggested_command: "supabase login", + }); + + expect( + classifyCliErrorActionability({ _tag: "LegacyDbQueryLoginRequiredError" }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:LegacyDbQueryLoginRequiredError", + suggestion_type: "login", + suggested_command: "supabase login", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyStorageAuthTokenError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:LegacyStorageAuthTokenError", + suggestion_type: "login", + suggested_command: "supabase login", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyLoginSaveTokenError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:LegacyLoginSaveTokenError", + suggestion_type: "login", + suggested_command: "supabase login", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyLoginFailedError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:LegacyLoginFailedError", + suggestion_type: "login", + suggested_command: "supabase login", + }); + + expect(classifyCliErrorActionability({ _tag: "LoginFailedError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:LoginFailedError", + suggestion_type: "login", + suggested_command: "supabase login", + }); + }); + + test("classifies login token failures as token remediations", () => { + expect(classifyCliErrorActionability({ _tag: "LegacyLoginMissingTokenError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:LegacyLoginMissingTokenError", + has_suggestion: true, + suggestion_type: "set_env_var", + }); + + expect(classifyCliErrorActionability({ _tag: "NoTtyError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:NoTtyError", + has_suggestion: true, + suggestion_type: "set_env_var", + }); + }); + + test("splits SAML-disabled setup failures from entitlement gates", () => { + expect(classifyCliErrorActionability({ _tag: "LegacySsoAddSamlDisabledError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:LegacySsoAddSamlDisabledError:saml_disabled", + suggestion_type: "update_config", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacySsoListSamlDisabledError" })).toMatchObject( + { + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:LegacySsoListSamlDisabledError:saml_disabled", + suggestion_type: "update_config", + }, + ); + + expect( + classifyCliErrorActionability({ + _tag: "LegacySsoListSamlDisabledError", + upgradeSuggested: true, + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: "tag:LegacySsoListSamlDisabledError:plan_limit", + suggestion_type: "upgrade_plan", + }); + }); + + test("classifies plan-gated SSO failures as upgrade remediations", () => { + expect( + classifyCliErrorActionability({ + _tag: "LegacySsoAddUnexpectedStatusError", + status: 403, + body: '{"error":"forbidden"}', + upgradeSuggested: true, + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: "tag:LegacySsoAddUnexpectedStatusError:plan_limit", + suggestion_type: "upgrade_plan", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacySsoListUnexpectedStatusError", + status: 403, + body: '{"error":"forbidden"}', + upgradeSuggested: true, + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: "tag:LegacySsoListUnexpectedStatusError:plan_limit", + suggestion_type: "upgrade_plan", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacySsoListUnexpectedStatusError", + status: 403, + body: '{"error":"forbidden"}', + }), + ).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:LegacySsoListUnexpectedStatusError:api_status", + suggestion_type: "none", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacySsoAddSamlDisabledError", + upgradeSuggested: true, + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: "tag:LegacySsoAddSamlDisabledError:plan_limit", + suggestion_type: "upgrade_plan", + }); + }); + + test("classifies SSO provider not-found wrappers as invalid input", () => { + expect(classifyCliErrorActionability({ _tag: "LegacySsoShowNotFoundError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:LegacySsoShowNotFoundError", + suggestion_type: "none", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacySsoUpdateNotFoundError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:LegacySsoUpdateNotFoundError", + suggestion_type: "none", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacySsoRemoveNotFoundError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:LegacySsoRemoveNotFoundError", + suggestion_type: "none", + }); + + for (const tag of ["LegacySsoUpdateNotFoundError", "LegacySsoRemoveNotFoundError"]) { + expect(classifyCliErrorActionability({ _tag: tag, upgradeSuggested: true })).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: `tag:${tag}:plan_limit`, + suggestion_type: "upgrade_plan", + }); + } + }); + + test("classifies SSO metadata and mapping input wrappers as input remediations", () => { + for (const tag of [ + "LegacySsoAddMetadataFileError", + "LegacySsoUpdateMetadataFileError", + "LegacySsoAddAttributeMappingFileError", + "LegacySsoUpdateAttributeMappingFileError", + "LegacySsoMetadataUrlInvalidError", + "LegacySsoMetadataUrlNonUtf8Error", + ]) { + expect(classifyCliErrorActionability({ _tag: tag })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: `tag:${tag}`, + has_suggestion: true, + suggestion_type: "provide_flags", + }); + } + }); + + test("preserves branch empty-state remediation", () => { + expect( + classifyCliErrorActionability({ _tag: "LegacyBranchesBranchingDisabledError" }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:LegacyBranchesBranchingDisabledError", + has_suggestion: true, + suggestion_type: "update_config", + suggested_command: "supabase branches create", + }); + }); + + test("classifies gated status failures only when a safe plan signal is present", () => { + expect( + classifyCliErrorActionability({ + _tag: "LegacyBranchesCreateUnexpectedStatusError", + status: 402, + body: "Branching limit reached. Upgrade your plan to create more preview branches.", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: "tag:LegacyBranchesCreateUnexpectedStatusError:plan_limit", + suggestion_type: "upgrade_plan", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacyBranchesUpdateUnexpectedStatusError", + status: 403, + body: "Upgrade your plan to enable persistent preview branches.", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: "tag:LegacyBranchesUpdateUnexpectedStatusError:plan_limit", + suggestion_type: "upgrade_plan", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacyVanitySubdomainsActivateUnexpectedStatusError", + status: 402, + body: "Vanity subdomain requires a paid plan. Open billing to upgrade.", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: "tag:LegacyVanitySubdomainsActivateUnexpectedStatusError:plan_limit", + suggestion_type: "upgrade_plan", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacySsoUpdateUnexpectedStatusError", + status: 403, + body: "SAML entitlement is not enabled for this organization.", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: "tag:LegacySsoUpdateUnexpectedStatusError:plan_limit", + suggestion_type: "upgrade_plan", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacySsoAddUnexpectedStatusError", + status: 402, + body: "SAML entitlement requires an upgrade.", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: "tag:LegacySsoAddUnexpectedStatusError:plan_limit", + suggestion_type: "upgrade_plan", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacySsoListUnexpectedStatusError", + status: 403, + body: "SAML entitlement is not enabled for this organization.", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "plan_limit", + error_fingerprint: "tag:LegacySsoListUnexpectedStatusError:plan_limit", + suggestion_type: "upgrade_plan", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacyBranchesCreateUnexpectedStatusError", + status: 400, + body: "invalid branch name", + }), + ).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:LegacyBranchesCreateUnexpectedStatusError:api_status", + suggestion_type: "none", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacyBranchesUpdateUnexpectedStatusError", + status: 503, + }), + ).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:LegacyBranchesUpdateUnexpectedStatusError:api_status", + suggestion_type: "none", + }); + + expect( + classifyCliErrorActionability({ + _tag: "LegacyBackupListUnexpectedStatusError", + status: 401, + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:LegacyBackupListUnexpectedStatusError:auth", + suggestion_type: "login", + suggested_command: "supabase login", + }); + }); + + test("classifies project link and project ref errors separately", () => { + expect(classifyCliErrorActionability({ _tag: "LegacyProjectNotLinkedError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "project_not_linked", + error_fingerprint: "tag:LegacyProjectNotLinkedError", + suggestion_type: "link_project", + suggested_command: "supabase link", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyProjectRefRequiredError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "missing_project_ref", + error_fingerprint: "tag:LegacyProjectRefRequiredError", + suggestion_type: "link_project", + }); + + expect(classifyCliErrorActionability({ _tag: "ProjectRefRequiredError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "missing_project_ref", + error_fingerprint: "tag:ProjectRefRequiredError", + suggestion_type: "link_project", + suggested_command: "supabase link", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyProjectPausedError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:LegacyProjectPausedError", + suggestion_type: "update_config", + }); + + expect(classifyCliErrorActionability({ _tag: "NoAccessibleProjectsError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "permission", + error_fingerprint: "tag:NoAccessibleProjectsError", + suggestion_type: "login", + suggested_command: "supabase login", + }); + }); + + test("classifies CLI input and non-interactive prompt failures as user-actionable", () => { + expect(classifyCliErrorActionability({ _tag: "UnrecognizedOption" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + has_suggestion: false, + suggestion_type: "none", + }); + + expect(classifyCliErrorActionability({ _tag: "NonInteractiveError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + has_suggestion: true, + suggestion_type: "provide_flags", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyInvalidSecretPairError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:LegacyInvalidSecretPairError", + suggestion_type: "none", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacySsoInvalidUuidError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:LegacySsoInvalidUuidError", + suggestion_type: "none", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacySsoMutexFlagError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:LegacySsoMutexFlagError", + suggestion_type: "none", + }); + + for (const tag of [ + "LegacyBranchesEnvNotSupportedError", + "LegacyFunctionsEnvNotSupportedError", + "LegacyNetworkBansEnvNotSupportedError", + "LegacyOrgsEnvNotSupportedError", + "LegacyProjectsEnvNotSupportedError", + "LegacySecretsEnvNotSupportedError", + "LegacyServicesEnvNotSupportedError", + "LegacySnippetsEnvNotSupportedError", + "LegacySsoShowEnvNotSupportedError", + "LegacySecretsEnvFileOpenError", + "LegacySecretsEnvFileParseError", + "LegacyNetworkBansInvalidIpError", + "LegacyNetworkRestrictionsInvalidCidrError", + "LegacyNetworkRestrictionsPrivateIpError", + "LegacySnippetsInvalidIdError", + ]) { + expect(classifyCliErrorActionability({ _tag: tag })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: `tag:${tag}`, + has_suggestion: false, + suggestion_type: "none", + }); + } + }); + + test("preserves command-local input remediation tags", () => { + for (const tag of [ + "InitExperimentalRequiredError", + "InitAlreadyExistsError", + "LegacyBranchesBranchNameEmptyError", + "NoBranchNameError", + "BranchAlreadyExistsError", + "BranchNotFoundError", + "PlatformInputError", + "PlatformRouteNotFoundError", + "PlatformMethodSelectionError", + "InvalidServiceVersionOverrideError", + "UnsupportedLogsOutputFormatError", + "LegacyExperimentalRequiredError", + "LegacyDbDumpRequiresDataOnlyError", + "LegacyDbDumpMutuallyExclusiveFlagsError", + "LegacyDbDumpOpenFileError", + "LegacyDbDiffTargetFlagsError", + "LegacyDbDiffEngineConflictError", + "LegacyDbDiffExplicitFlagsError", + "LegacyDbDiffUnknownTargetError", + "LegacyMigrationLastZeroError", + "LegacyMigrationLastTooLargeError", + "LegacyDbQueryMutuallyExclusiveFlagsError", + "LegacyDbQueryReadFileError", + "LegacyDbQueryNoStdinSqlError", + "LegacyDbQueryNoSqlError", + "LegacyDbPullTargetFlagsError", + "LegacyDbPullEngineConflictError", + "LegacyDbLintMutuallyExclusiveFlagsError", + "LegacyDbAdvisorsMutuallyExclusiveFlagsError", + "LegacyDeclarativeNotEnabledError", + "LegacyDeclarativeMutuallyExclusiveFlagsError", + "LegacyDeclarativeNonInteractiveError", + "LegacyDeclarativeInvalidDbUrlError", + "LegacyPostgresConfigInvalidConfigValueError", + "LegacyTestDbMutuallyExclusiveFlagsError", + "LegacyInspectMutuallyExclusiveFlagsError", + "LegacySslEnforcementMutuallyExclusiveFlagsError", + "LegacySslEnforcementNoEnableDisableFlagError", + "LegacyProjectsCreateMissingArgError", + "LegacyProjectsCreateNameEmptyError", + "LegacyProjectsDeleteRefRequiredError", + "LegacyProjectsDeleteNotFoundError", + "LegacySecretsNoArgumentsError", + "LegacyInvalidGenTypesDurationError", + "LegacyInvalidGenTypesDatabaseUrlError", + "LegacyStorageInvalidUrlError", + "LegacyStorageUrlParseError", + "LegacyStorageMissingFlagError", + "LegacyStorageMutuallyExclusiveFlagsError", + "LegacyStorageUnsupportedOperationError", + "LegacyStorageCopyBetweenBucketsError", + "LegacyStorageUnsupportedMoveError", + "LegacyStorageMissingPathError", + "LegacyStorageMissingBucketError", + "InvalidFunctionDeploySlugError", + "InvalidFunctionSlugError", + "LegacyFunctionsNewInvalidSlugError", + "LegacyFunctionsNewFileExistsError", + "MissingFunctionSlugError", + "FunctionEntrypointExistsError", + "ConflictingFunctionDownloadFlagsError", + "ConflictingFunctionDeployFlagsError", + "FunctionDownloadNotFoundError", + "FunctionNotFoundError", + "NoFunctionsToDeployError", + "LegacyBootstrapInvalidTemplateError", + "LegacyTestNewFileExistsError", + ]) { + expect(classifyCliErrorActionability({ _tag: tag })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: `tag:${tag}`, + has_suggestion: true, + suggestion_type: "provide_flags", + }); + } + + expect(classifyCliErrorActionability({ _tag: "InitParseSettingsError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:InitParseSettingsError", + has_suggestion: true, + suggestion_type: "update_config", + }); + + expect( + classifyCliErrorActionability({ _tag: "FunctionsDevEdgeRuntimeDisabledError" }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:FunctionsDevEdgeRuntimeDisabledError", + has_suggestion: true, + suggestion_type: "update_config", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyInvalidOutputFormatError" })).toMatchObject( + { + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:LegacyInvalidOutputFormatError", + has_suggestion: false, + suggestion_type: "none", + }, + ); + + for (const tag of ["LegacyDbLintFailOnError", "LegacyDbAdvisorsFailOnError"]) { + expect(classifyCliErrorActionability({ _tag: tag })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: `tag:${tag}`, + has_suggestion: false, + suggestion_type: "none", + }); + } + }); + + test("splits broad Docker wrapper failures by safe static prefixes", () => { + expect( + classifyCliErrorActionability({ + _tag: "LegacyDockerRunError", + message: "failed to run docker. Docker Desktop is a prerequisite for local development.", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "docker_not_running", + error_fingerprint: "tag:LegacyDockerRunError:docker_not_running", + suggestion_type: "start_docker", + }); + + const pullFailure = classifyCliErrorActionability({ + _tag: "LegacyDockerRunError", + message: + "failed to pull docker image from all registries: registry.example/private/image:latest attempt 1: unauthorized", + }); + expect(pullFailure).toMatchObject({ + error_kind: "external_service", + error_category: "network", + error_fingerprint: "tag:LegacyDockerRunError:registry_pull", + suggestion_type: "rerun_debug", + }); + expect(JSON.stringify(pullFailure)).not.toContain("registry.example/private/image"); + + expect( + classifyCliErrorActionability({ + _tag: "LegacyDockerRunError", + message: + "failed to pull docker image from all registries: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "docker_not_running", + error_fingerprint: "tag:LegacyDockerRunError:docker_not_running", + suggestion_type: "start_docker", + }); + }); + + test("splits Docker pull daemon failures from registry failures", () => { + expect( + classifyCliErrorActionability({ + _tag: "DockerPullError", + detail: + "Failed to pull Docker image from all registries. postgres attempt 1: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "docker_not_running", + error_fingerprint: "tag:DockerPullError:docker_not_running", + suggestion_type: "start_docker", + }); + + const pullFailure = classifyCliErrorActionability({ + _tag: "DockerPullError", + detail: + "Failed to pull Docker image from all registries. registry.example/private/image:latest attempt 1: unauthorized", + }); + expect(pullFailure).toMatchObject({ + error_kind: "external_service", + error_category: "network", + error_fingerprint: "tag:DockerPullError:registry_pull", + suggestion_type: "rerun_debug", + }); + expect(JSON.stringify(pullFailure)).not.toContain("registry.example/private/image"); + }); + + test("classifies database connection and migration drift remediations", () => { + expect(classifyCliErrorActionability({ _tag: "LegacyDbConnectError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "db_connection", + error_fingerprint: "tag:LegacyDbConnectError", + suggestion_type: "update_config", + }); + + expect( + classifyCliErrorActionability({ _tag: "LegacyDbConfigConnectTempRoleError" }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "db_connection", + error_fingerprint: "tag:LegacyDbConfigConnectTempRoleError", + suggestion_type: "update_config", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyDbConfigPoolerLoginError" })).toMatchObject( + { + error_kind: "user_actionable", + error_category: "db_connection", + error_fingerprint: "tag:LegacyDbConfigPoolerLoginError", + suggestion_type: "update_config", + }, + ); + + expect(classifyCliErrorActionability({ _tag: "LegacyDbConfigIpv6Error" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "db_connection", + error_fingerprint: "tag:LegacyDbConfigIpv6Error", + suggestion_type: "update_config", + }); + + expect( + classifyCliErrorActionability({ _tag: "LegacyDbPullMigrationConflictError" }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "migration_drift", + error_fingerprint: "tag:LegacyDbPullMigrationConflictError", + suggestion_type: "repair_migration", + }); + + expect( + classifyCliErrorActionability({ _tag: "LegacyMigrationMissingRemoteError" }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "migration_drift", + error_fingerprint: "tag:LegacyMigrationMissingRemoteError", + suggestion_type: "repair_migration", + }); + + for (const tag of [ + "LegacyMigrationTargetFlagsError", + "LegacyMigrationPasswordFlagsError", + "LegacyMigrationInvalidVersionError", + "LegacyMigrationFileNotFoundError", + ]) { + expect(classifyCliErrorActionability({ _tag: tag })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: `tag:${tag}`, + has_suggestion: true, + suggestion_type: "provide_flags", + }); + } + }); + + test("splits broad stack build failures by safe static details", () => { + expect( + classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "imgproxy requires storage to be enabled", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:StackBuildError:invalid_config", + suggestion_type: "update_config", + }); + + expect( + classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to persist stack cleanup metadata", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:StackBuildError:invalid_config", + suggestion_type: "update_config", + }); + + expect( + classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to prepare stack assets", + }), + ).toMatchObject({ + error_kind: "external_service", + error_category: "network", + error_fingerprint: "tag:StackBuildError:asset_preparation", + suggestion_type: "rerun_debug", + }); + + expect( + classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to build dependency graph", + }), + ).toMatchObject({ + error_kind: "internal_bug", + error_category: "impossible_state", + error_fingerprint: "tag:StackBuildError:internal_build", + suggestion_type: "rerun_debug", + }); + }); + + test("classifies invalid local stack state as user-recoverable config", () => { + expect(classifyCliErrorActionability({ _tag: "InvalidStackStateError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:InvalidStackStateError", + suggestion_type: "update_config", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyDbConfigLoadError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:LegacyDbConfigLoadError", + suggestion_type: "update_config", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyDbConfigParseUrlError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:LegacyDbConfigParseUrlError", + suggestion_type: "update_config", + }); + + for (const tag of [ + "ProjectConfigParseError", + "LegacyConfigPushLoadConfigError", + "LegacySeedConfigLoadError", + "LegacySecretsConfigParseError", + "LegacyDomainsCnameError", + ]) { + expect(classifyCliErrorActionability({ _tag: tag })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: `tag:${tag}`, + suggestion_type: "update_config", + }); + } + }); + + test("classifies normalized local stack lifecycle errors as user-recoverable config", () => { + for (const tag of ["NoRunningStackError", "StateNotFoundError", "DaemonStartError"]) { + expect(classifyCliErrorActionability({ _tag: tag })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: `tag:${tag}`, + has_suggestion: true, + suggestion_type: "update_config", + suggested_command: "supabase start", + }); + } + + for (const tag of ["DaemonStillRunningError", "StackAlreadyRunningError"]) { + expect(classifyCliErrorActionability({ _tag: tag })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: `tag:${tag}`, + has_suggestion: true, + suggestion_type: "update_config", + suggested_command: "supabase stop", + }); + } + }); + + test("classifies port allocation StackError wrappers as local config failures", () => { + const error = new Error("Failed to allocate a free port"); + error.name = "StackError"; + Object.assign(error, { code: "PORT_ALLOCATION" }); + + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "error:StackError:port_allocation", + has_suggestion: true, + suggestion_type: "update_config", + }); + + const unknown = new Error("Failed to start stack"); + unknown.name = "StackError"; + Object.assign(unknown, { code: "UNKNOWN" }); + + expect(classifyCliErrorActionability(unknown)).toMatchObject({ + error_kind: "unknown", + error_category: "unknown", + error_fingerprint: "error:StackError:unknown", + has_suggestion: false, + suggestion_type: "none", + }); + }); + + test("classifies local permission failures as user-actionable permission errors", () => { + expect(classifyCliErrorActionability({ _tag: "LegacyDeleteTokenError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "permission", + error_fingerprint: "tag:LegacyDeleteTokenError", + suggestion_type: "update_config", + }); + + expect(classifyCliErrorActionability({ _tag: "LegacyCredentialDeleteError" })).toMatchObject({ + error_kind: "user_actionable", + error_category: "permission", + error_fingerprint: "tag:LegacyCredentialDeleteError", + suggestion_type: "update_config", + }); + }); + + test("infers legacy HTTP wrapper network and status failures", () => { + expect(classifyCliErrorActionability({ _tag: "LegacyBranchesListNetworkError" })).toMatchObject( + { + error_kind: "external_service", + error_category: "network", + error_fingerprint: "tag:LegacyBranchesListNetworkError", + suggestion_type: "rerun_debug", + }, + ); + + expect( + classifyCliErrorActionability({ _tag: "LegacyBackupListUnexpectedStatusError" }), + ).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:LegacyBackupListUnexpectedStatusError", + has_suggestion: false, + suggestion_type: "none", + }); + + expect( + classifyCliErrorActionability({ _tag: "LegacyConfigPushApiReadStatusError" }), + ).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:LegacyConfigPushApiReadStatusError", + has_suggestion: false, + suggestion_type: "none", + }); + }); + + test("splits ApiError transport failures from status failures", () => { + expect( + classifyCliErrorActionability({ + _tag: "ApiError", + detail: "fetch failed", + }), + ).toMatchObject({ + error_kind: "external_service", + error_category: "network", + error_fingerprint: "tag:ApiError:network", + suggestion_type: "rerun_debug", + }); + + expect( + classifyCliErrorActionability({ + _tag: "ApiError", + statusCode: 401, + detail: "401 Unauthorized", + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:ApiError:auth", + suggestion_type: "login", + suggested_command: "supabase login", + }); + + expect( + classifyCliErrorActionability({ + _tag: "ApiError", + statusCode: 503, + detail: "503 Service Unavailable", + }), + ).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:ApiError:api_status", + suggestion_type: "none", + }); + }); + + test("splits generated API client transport failures from status failures", () => { + expect( + classifyCliErrorActionability({ + _tag: "HttpClientError", + reason: { _tag: "TransportError" }, + }), + ).toMatchObject({ + error_kind: "external_service", + error_category: "network", + error_fingerprint: "tag:HttpClientError:network", + suggestion_type: "rerun_debug", + }); + + expect( + classifyCliErrorActionability({ + _tag: "HttpClientError", + reason: { _tag: "StatusCodeError" }, + response: { status: 401 }, + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:HttpClientError:auth", + suggestion_type: "login", + suggested_command: "supabase login", + }); + + expect( + classifyCliErrorActionability({ + _tag: "HttpClientError", + reason: { _tag: "StatusCodeError" }, + response: { status: 503 }, + }), + ).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:HttpClientError:api_status", + suggestion_type: "none", + }); + + for (const tag of ["HttpBodyError", "SchemaError"]) { + expect(classifyCliErrorActionability({ _tag: tag })).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: `tag:${tag}:api_response`, + has_suggestion: false, + suggestion_type: "none", + }); + + expect(classifyCliErrorActionability({ _tag: tag, response: { status: 200 } })).toMatchObject( + { + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: `tag:${tag}:api_response`, + has_suggestion: false, + suggestion_type: "none", + }, + ); + + expect(classifyCliErrorActionability({ _tag: tag, requestInput: true })).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: `tag:${tag}:request_input`, + has_suggestion: true, + suggestion_type: "provide_flags", + }); + } + }); + + test("buckets user cancellations separately from unknown failures", () => { + for (const tag of [ + "LegacySecretsUnsetCancelledError", + "LegacyOperationCanceledError", + "FunctionDeployCancelledError", + "LegacyBootstrapOverwriteDeclinedError", + ]) { + expect(classifyCliErrorActionability({ _tag: tag })).toMatchObject({ + error_kind: "user_cancelled", + error_category: "cancelled", + error_fingerprint: `tag:${tag}`, + has_suggestion: false, + suggestion_type: "none", + }); + } + }); + + test("unwraps single-error ShowHelp envelopes for parser failures", () => { + expect( + classifyCliErrorActionability({ + _tag: "ShowHelp", + errors: [{ _tag: "MissingOption", option: "project-ref" }], + }), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:MissingOption", + }); + }); + + test("keeps unknown classifications sanitized", () => { + const classified = classifyCliErrorActionability({ + _tag: "UnexpectedFailure", + message: "failed for /Users/person/project with token secret-token", + detail: "host db.example.internal", + suggestion: "paste a private value", + }); + + expect(classified).toEqual({ + error_kind: "unknown", + error_category: "unknown", + error_fingerprint: "tag:UnexpectedFailure", + has_suggestion: false, + suggestion_type: "none", + }); + expect(JSON.stringify(classified)).not.toContain("/Users/person/project"); + expect(JSON.stringify(classified)).not.toContain("secret-token"); + expect(JSON.stringify(classified)).not.toContain("db.example.internal"); + }); + + test("does not fingerprint arbitrary string error contents", () => { + expect(classifyCliErrorActionability("panic with token secret-token")).toEqual({ + error_kind: "unknown", + error_category: "unknown", + error_fingerprint: "string:unknown", + has_suggestion: false, + suggestion_type: "none", + }); + }); + + test("classifies native JavaScript exceptions as internal bugs", () => { + const classified = classifyCliErrorActionability(new TypeError("cannot read properties")); + + expect(classified).toEqual({ + error_kind: "internal_bug", + error_category: "panic", + error_fingerprint: "error:TypeError", + has_suggestion: true, + suggestion_type: "rerun_debug", + }); + expect(JSON.stringify(classified)).not.toContain("cannot read properties"); + }); +}); diff --git a/apps/cli/src/shared/telemetry/event-catalog.ts b/apps/cli/src/shared/telemetry/event-catalog.ts index f35296517e..26239def0a 100644 --- a/apps/cli/src/shared/telemetry/event-catalog.ts +++ b/apps/cli/src/shared/telemetry/event-catalog.ts @@ -29,6 +29,13 @@ export const PropFlags = "flags"; export const PropExitCode = "exit_code"; export const PropDurationMs = "duration_ms"; export const PropOutputFormat = "output_format"; +export const PropErrorKind = "error_kind"; +export const PropErrorCategory = "error_category"; +export const PropErrorFingerprint = "error_fingerprint"; +export const PropHasSuggestion = "has_suggestion"; +export const PropSuggestionType = "suggestion_type"; +export const PropSuggestedCommand = "suggested_command"; +export const PropWorkflow = "workflow"; export const GroupOrganization = "organization"; export const GroupProject = "project"; diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index 24dc02ed8b..aaa31fc631 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -166,6 +166,21 @@ function isRetryableResponse(response: HttpClientResponse.HttpClientResponse): b ); } +function markRequestInputError(error: E): E { + if (typeof error === "object" && error !== null) { + try { + Object.defineProperty(error, "requestInput", { + configurable: true, + enumerable: true, + value: true, + }); + } catch { + return error; + } + } + return error; +} + function applySupabaseRetryPolicy( client: HttpClient.HttpClient, options?: SupabaseApiRetryOptions, @@ -442,7 +457,9 @@ function executeRequest( input: object, ): Effect.Effect { return Effect.gen(function* () { - const request = yield* buildRequest(definition, input); + const request = yield* buildRequest(definition, input).pipe( + Effect.mapError(markRequestInputError), + ); const response = yield* client.execute(request); return yield* HttpClientResponse.filterStatusOk(response); }); @@ -516,7 +533,9 @@ export function makeSupabaseApiClient( return { execute: (definition, input) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input); + const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( + Effect.mapError(markRequestInputError), + ); const response = yield* executeRequest(prepared, definition, validated); if (isJsonOperation(definition)) { return yield* decodeJsonResponse(definition, response); @@ -531,8 +550,11 @@ export function makeSupabaseApiClient( }), executeRaw: (definition, input, headers) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input); + const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( + Effect.mapError(markRequestInputError), + ); const request = yield* buildRequest(definition, validated).pipe( + Effect.mapError(markRequestInputError), Effect.map((request) => headers === undefined ? request : HttpClientRequest.setHeaders(request, headers), ), diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index caad463bfa..ec1668df42 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -154,6 +154,13 @@ const config = { userAgent: "supabase-api/test", } as const; +function exitError(exit: Exit.Exit): unknown { + if (Exit.isSuccess(exit)) { + throw new Error("Expected failed exit"); + } + return Option.getOrThrow(Cause.findErrorOption(exit.cause)); +} + describe("makeSupabaseApiClient", () => { test("retries transport errors for POST requests", async () => { let attempts = 0; @@ -195,6 +202,60 @@ describe("makeSupabaseApiClient", () => { expect(result.ref).toBe("abcdefghijklmnopqrst"); }); + test("marks input schema failures as request input errors", async () => { + const exit = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetProject">(operationDefinitions.v1GetProject, { + ref: "short", + }), + ), + Effect.exit, + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + ref: "abcdefghijklmnopqrst", + }), + ), + ), + ), + ), + ); + + expect(exitError(exit)).toMatchObject({ + _tag: "SchemaError", + requestInput: true, + }); + }); + + test("marks raw input schema failures as request input errors", async () => { + const exit = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.executeRaw(operationDefinitions.v1GetProject, { + ref: "short", + }), + ), + Effect.exit, + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + ref: "abcdefghijklmnopqrst", + }), + ), + ), + ), + ), + ); + + expect(exitError(exit)).toMatchObject({ + _tag: "SchemaError", + requestInput: true, + }); + }); + test("reveals redacted auth tokens only at the transport boundary", async () => { let authorizationHeader: string | undefined;