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
80 changes: 73 additions & 7 deletions apps/cli/src/shared/telemetry/error-actionability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,11 +656,13 @@ function readDeclaration(error: unknown): CliErrorActionabilityDeclaration | und
}
}

function safeIdentifier(value: string | undefined): string | undefined {
/** `allowDots` admits namespaced tags such as `Namespace.NamespaceError`. */
function safeIdentifier(value: string | undefined, allowDots = false): string | undefined {
if (value === undefined) return undefined;
// The length cap is defense-in-depth: every legitimate identifier is a
// class/tag name, so an oversized value is never a real CLI error source.
return /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(value) ? value : undefined;
const pattern = allowDots ? /^[A-Za-z][A-Za-z0-9_.]{0,63}$/ : /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
return pattern.test(value) ? value : undefined;
}

function readErrorTag(error: unknown): string | undefined {
Expand Down Expand Up @@ -698,15 +700,17 @@ function readDeclaredErrorFingerprintId(error: unknown): string | undefined {
* identifier minification. Never invoke prototype getters here: only the
* static data property created by Effect is a safe fingerprint authority.
*/
function readStableTaggedPrototypeName(error: unknown): string | undefined {
function readStableTaggedPrototypeName(error: unknown, allowDots = false): string | undefined {
if (!(error instanceof Error)) return undefined;
let prototype: unknown = Object.getPrototypeOf(error);
while (prototype !== Error.prototype) {
if (!isErrorRecord(prototype)) return undefined;
const descriptor = Object.getOwnPropertyDescriptor(prototype, "name");
if (descriptor !== undefined && "value" in descriptor) {
const name =
typeof descriptor.value === "string" ? safeIdentifier(descriptor.value) : undefined;
typeof descriptor.value === "string"
? safeIdentifier(descriptor.value, allowDots)
: undefined;
if (name !== undefined && name !== "Error") return name;
}
prototype = Object.getPrototypeOf(prototype);
Expand Down Expand Up @@ -962,17 +966,79 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability {
return toActionability(actionability.unknown, "error", undefined);
}

/** A fixed list, so a code groups the same way on every platform. */
const defectSystemErrorCodes = new Set([
"EACCES",
"EADDRINUSE",
"EADDRNOTAVAIL",
"EAGAIN",
"EAI_AGAIN",
"EAI_FAIL",
"EAI_NONAME",
"EBADF",
"EBUSY",
"ECONNABORTED",
"ECONNREFUSED",
"ECONNRESET",
"EEXIST",
"EHOSTUNREACH",
"EINVAL",
"EISDIR",
"ELOOP",
"EMFILE",
"ENAMETOOLONG",
"ENETUNREACH",
"ENFILE",
"ENOENT",
"ENOMEM",
"ENOSPC",
"ENOTDIR",
"ENOTEMPTY",
"ENOTFOUND",
"ENOTSUP",
"EPERM",
"EPIPE",
"EROFS",
"ESRCH",
"ETIMEDOUT",
"EXDEV",
]);

/**
* Names an unclassified defect by its class's prototype tag and a known system error code, or by its type
* for a non-error value. Instance fields other than an errno code are never read, so only
* source-owned identifiers reach telemetry.
*/
function defectIdentity(defect: unknown): string {
try {
const error = unwrapNativeFailure(defect);
if (!isErrorRecord(error)) return typeof error;
if (!(error instanceof Error)) return "object";
const base = readStableTaggedPrototypeName(error, true) ?? "Error";
// A data descriptor, so reading the code never runs a getter.
const code = Object.getOwnPropertyDescriptor(error, "code")?.value;
return typeof code === "string" && defectSystemErrorCodes.has(code) ? `${base}:${code}` : base;
} catch {
return "Unreadable";
}
}
Comment thread
avallete marked this conversation as resolved.

export function classifyCliCauseActionability(cause: Cause.Cause<unknown>): CliErrorActionability {
let firstKnownDefect: CliErrorActionability | undefined;
let hasUnknownDefect = false;
let unknownDefect: { readonly defect: unknown } | undefined;
for (const reason of cause.reasons) {
if (!Cause.isDieReason(reason)) continue;
const classified = classifyCliErrorActionability(reason.defect);
if (classified.error_kind === CliErrorKind.InternalBug) return classified;
if (classified.error_kind === CliErrorKind.Unknown) hasUnknownDefect = true;
if (classified.error_kind === CliErrorKind.Unknown) unknownDefect ??= { defect: reason.defect };
else firstKnownDefect ??= classified;
}
if (hasUnknownDefect) return toActionability(actionability.internalPanic, "error", "Defect");
if (unknownDefect !== undefined) {
return {
...toActionability(actionability.internalPanic, "error", "Defect"),
error_fingerprint: `error:Defect:${defectIdentity(unknownDefect.defect)}`,
};
}
if (firstKnownDefect !== undefined) return firstKnownDefect;
if (Cause.hasInterruptsOnly(cause)) {
return toActionability(actionability.cancelled, "error", "Interrupt");
Expand Down
39 changes: 39 additions & 0 deletions apps/cli/src/shared/telemetry/error-actionability.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,45 @@ describe("classifyCliCauseActionability", () => {
});
});

it("names an unclassified defect by its source-owned tag and errno code, never instance data", () => {
const secret = "privateToken";
class NamespaceDefect extends Data.TaggedError("Namespace.NamespaceError")<{
readonly message: string;
}> {}
const cases: ReadonlyArray<readonly [unknown, string]> = [
[new UndeclaredError({ message: secret }), "error:Defect:UndeclaredError"],
[new NamespaceDefect({ message: secret }), "error:Defect:Namespace.NamespaceError"],
[Object.assign(new Error(secret), { code: "ENOENT" }), "error:Defect:Error:ENOENT"],
[Object.assign(new Error(secret), { code: "EAI_AGAIN" }), "error:Defect:Error:EAI_AGAIN"],
[new Error(secret), "error:Defect:Error"],
[Object.assign(new Error(secret), { name: secret, _tag: secret }), "error:Defect:Error"],
[
Object.assign(new Error(secret), { code: `E${secret.toUpperCase()}` }),
"error:Defect:Error",
],
[{ _tag: secret }, "error:Defect:object"],
[
Object.defineProperty(new Error(secret), "code", {
get: () => {
throw new Error(secret);
},
}),
"error:Defect:Error",
],
[secret, "error:Defect:string"],
];
Comment thread
avallete marked this conversation as resolved.

for (const [defect, fingerprint] of cases) {
const result = classifyCliCauseActionability(Cause.die(defect));
expect(result).toMatchObject({
error_kind: "internal_bug",
error_category: "panic",
error_fingerprint: fingerprint,
});
expect(JSON.stringify(result)).not.toContain(secret);
}
});

it("does not leak details from cause chains", () => {
const secret = "private-token";
const cause = Cause.combine(Cause.die(new TypeError("boom")), Cause.die(new Error(secret)));
Expand Down
Loading