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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/cli/docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ sanitized error classification:
- `has_suggestion`
- `suggestion_type`
- `suggested_command` (only when the remediation is an allowlisted command)
- `config_key` (only on `invalid_config` failures from config resolution: the failing config key
path, such as `inbucket` or `auth.email.smtp.port`, comma-joined when several removed keys fail;
keys only, never values, file paths or user-chosen names, which collapse to their nearest known
parent key)

These values come exclusively from the closed taxonomy in
Apart from `config_key`, these values come exclusively from the closed taxonomy in
`src/shared/telemetry/error-actionability.ts` — never from raw error text —
and the KPI query semantics (strict recovery, repeat errors, internal/unknown
bug rate) are documented there in `CliErrorActionabilityMetricDefinitions`.
Expand Down
52 changes: 49 additions & 3 deletions apps/cli/src/commands/functions/new/new.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { BunServices } from "@effect/platform-bun";
import { BunCrypto, BunServices } from "@effect/platform-bun";
import { describe, expect, it } from "@effect/vitest";
import { Effect, Exit, FileSystem, Layer, Path } from "effect";
import { Effect, Exit, FileSystem, Layer, Path, Stdio } from "effect";

import {
mockCommandSettings,
mockTelemetryStateTracked,
useTempWorkdir,
withConfigEnv,
} from "../../../../tests/helpers/command-mocks.ts";
import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts";
import {
mockContextualAnalytics,
mockOutput,
mockProcessControl,
mockStdin,
mockTty,
} from "../../../../tests/helpers/mocks.ts";
import { CliArgs } from "../../../shared/cli/cli-args.service.ts";
import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts";
import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts";
import { YesFlag } from "../../../command-internal/global-flags.ts";
import { CliConfigValueError } from "../../../config/cli-config.errors.ts";
import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts";
Expand Down Expand Up @@ -192,6 +200,44 @@ describe("functions new integration", () => {
}).pipe(Effect.provide(layer), (body) => withConfigEnv({ SUPABASE_API_PORT: "abc" }, body));
});

it.effect("records the failing config key on the failed command event", () => {
const { layer, workdir } = setup();
const analytics = mockContextualAnalytics();
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true });
yield* fs.writeFileString(
path.join(workdir, "supabase", "config.toml"),
"[inbucket]\nenabled = true\n",
);

const exit = yield* functionsNew({ functionName: "hello", auth: "apikey" }).pipe(
withCommandTelemetry(),
Effect.exit,
);

expect(Exit.isFailure(exit)).toBe(true);
expect(analytics.captured).toHaveLength(1);
expect(analytics.captured[0]?.properties).toMatchObject({
command: "functions new",
exit_code: 1,
error_category: "invalid_config",
config_key: "inbucket",
});
}).pipe(
Effect.provide(
Layer.mergeAll(
layer,
analytics.layer,
mockProcessControl().layer,
Stdio.layerTest({ args: Effect.succeed(["functions", "new", "hello"]) }),
commandRuntimeLayer(["functions", "new"]).pipe(Layer.provide(BunCrypto.layer)),
),
),
);
});

it.effect("warns and skips the config append when the function is already declared", () => {
const { layer, out, workdir } = setup();
return Effect.gen(function* () {
Expand Down
105 changes: 105 additions & 0 deletions apps/cli/src/config/cli-config-failure-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
CliConfigSchema,
type CliConfigParseError,
type CliConfigRemovedKeyError,
} from "@supabase/config";
import { Predicate, Schema, type SchemaIssue } from "effect";

import { cliConfigDocumentOnlyPaths, cliConfigRegistry } from "./cli-config-keys.ts";
import type { CliConfigValueError } from "./cli-config.errors.ts";

const REMOVED_KEY_PATHS: ReadonlyArray<string> = [
"inbucket",
"experimental.orioledb_version",
"auth.external.linkedin",
"auth.external.slack",
];

const withAncestors = (path: string): ReadonlyArray<string> => {
const segments = path.split(".");
return segments.map((_, index) => segments.slice(0, index + 1).join("."));
};

const KNOWN_PATHS: ReadonlySet<string> = new Set(
[
...Object.keys(CliConfigSchema.fields),
...cliConfigRegistry.keys.map((key) => key.path),
...cliConfigRegistry.families.map((family) => family.prefix),
...cliConfigDocumentOnlyPaths,
].flatMap(withAncestors),
);

const taggedAs =
<E extends { readonly _tag: string }>(tag: E["_tag"]) =>
(error: unknown): error is E =>
Predicate.isTagged(error, tag);

const isRemovedKeyError = taggedAs<CliConfigRemovedKeyError>("CliConfigRemovedKeyError");
const isParseError = taggedAs<CliConfigParseError>("CliConfigParseError");
const isValueError = taggedAs<CliConfigValueError>("CliConfigValueError");

const removedKeyPath = (displayKey: string): string | undefined => {
const path = displayKey.replaceAll(/[[\]]/g, "");
return REMOVED_KEY_PATHS.find(
(canonical) =>
path === canonical || (path.startsWith("remotes.") && path.endsWith(`.${canonical}`)),
);
};

const knownPrefix = (segments: ReadonlyArray<string>): string | undefined => {
for (let length = segments.length; length > 0; length--) {
const candidate = segments.slice(0, length).join(".");
if (KNOWN_PATHS.has(candidate)) return candidate;
}
return undefined;
};

/** Remote blocks decode as one record, so their issue paths start with the remote name instead of `remotes`. */
const decodedAsRemoteBlocks = (issue: SchemaIssue.Issue): boolean =>
issue._tag === "Composite" &&
issue.ast._tag === "Objects" &&
issue.ast.propertySignatures.length === 0;

const firstIssuePath = (
issue: SchemaIssue.Issue,
path: ReadonlyArray<string>,
): ReadonlyArray<string> | undefined => {
switch (issue._tag) {
case "Pointer":
return firstIssuePath(issue.issue, [...path, ...issue.path.map(String)]);
case "Filter":
case "Encoding":
return firstIssuePath(issue.issue, path);
case "Composite":
case "AnyOf":
for (const nested of issue.issues) {
const found = firstIssuePath(nested, path);
if (found !== undefined) return found;
}
return undefined;
default:
return path.length > 0 ? path : undefined;
}
};

/** The source-owned config key path a config load failure names, never a value, file path or user-chosen name. */
export const cliConfigFailureKey = (error: unknown): string | undefined => {
if (isRemovedKeyError(error)) {
const matched = new Set<string>();
for (const { key } of error.removedKeys) {
const canonical = removedKeyPath(key);
if (canonical !== undefined) matched.add(canonical);
}
return matched.size === 0 ? undefined : [...matched].sort().join(",");
}
if (isValueError(error)) {
return knownPrefix(error.path.split("."));
}
if (isParseError(error) && Schema.isSchemaError(error.cause)) {
const { issue } = error.cause;
const path = firstIssuePath(issue, []);
if (path === undefined) return undefined;
return knownPrefix(decodedAsRemoteBlocks(issue) ? path.slice(1) : path);
}
return undefined;
};
Loading
Loading