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
51 changes: 51 additions & 0 deletions .lore.md

Large diffs are not rendered by default.

170 changes: 133 additions & 37 deletions src/lib/error-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@ import * as Sentry from "@sentry/node-core/light";
import {
ApiError,
AuthError,
CliError,
ContextError,
DeviceFlowError,
HostScopeError,
OutputError,
ResolutionError,
SeerError,
TimeoutError,
UpgradeError,
ValidationError,
WizardError,
} from "./errors.js";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -101,6 +104,43 @@ function recordSilencedError(error: unknown, reason: SilenceReason): void {
// Grouping tags
// ---------------------------------------------------------------------------

/** Endpoint normalization patterns — compiled once at module scope. */
const ENDPOINT_PATTERNS: [RegExp, string][] = [
[/\/organizations\/[^/]+/, "/organizations/{org}"],
[/\/projects\/[^/]+\/[^/]+/, "/projects/{org}/{project}"],
[/\/issues\/[^/]+/, "/issues/{id}"],
[/\/events\/[^/]+/, "/events/{id}"],
[/\/groups\/[^/]+/, "/groups/{id}"],
[/\/releases\/[^/]+/, "/releases/{version}"],
[/\/teams\/[^/]+\/[^/]+/, "/teams/{org}/{team}"],
[/\/dashboards\/[^/]+/, "/dashboards/{id}"],
[/\/customers\/[^/]+/, "/customers/{org}"],
];

/**
* Strip remaining bare numeric segments (e.g. /12345/) but preserve
* the API version prefix /0/ which is always the second segment.
*/
const BARE_NUMERIC_SEGMENT_RE = /(?<=\/api\/0\/.*)\/\d+(?=\/|$)/g;

/**
* Normalize an API endpoint path by parameterizing variable segments.
*
* Replaces org slugs, project slugs, issue IDs, event IDs, and other
* entity identifiers with placeholders so that server-side fingerprint
* rules can sub-group `ApiError` by endpoint shape rather than exact path.
*
* `"/api/0/projects/my-org/my-project/events/abc123/"` →
* `"/api/0/projects/{org}/{project}/events/{id}/"`
*/
export function normalizeEndpoint(endpoint: string): string {
let result = endpoint;
for (const [pattern, replacement] of ENDPOINT_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result.replace(BARE_NUMERIC_SEGMENT_RE, "/{id}");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate exported normalizeEndpoint name across modules

Low Severity

A new exported normalizeEndpoint function is introduced in error-reporting.ts that parameterizes path segments for Sentry grouping, but src/commands/api.ts already exports a completely different normalizeEndpoint that normalizes endpoints for API requests (trailing slashes, prefix stripping). Two exported functions with the same name but very different behavior risk accidental misuse via auto-import.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eec7396. Configure here.


/**
* Strip quoted substrings, numeric/hex IDs, and org/project paths from a
* resource string to produce a stable "kind" for grouping.
Expand All @@ -111,14 +151,24 @@ function recordSilencedError(error: unknown, reason: SilenceReason): void {
* `"not found in neurio/installer-app"` → `"not found"`
*/
export function extractResourceKind(resource: string): string {
return resource
.replace(/'[^']*'/g, "")
.replace(/"[^"]*"/g, "")
.replace(/\b[0-9a-f]{16,32}\b/gi, "")
.replace(/\bin\s+[\w-]+\/[\w-]+/g, "")
.replace(/\b\d+\b/g, "")
.replace(/\s+/g, " ")
.trim();
return (
resource
.replace(/'[^']*'/g, "")
.replace(/"[^"]*"/g, "")
.replace(/\b[0-9a-f]{16,32}\b/gi, "")
.replace(/\bin\s+[\w-]+(?:\/[\w-]+)*/g, "")
// Strip hyphenated slugs after known entity names (e.g., "Organization my-company").
// Requires at least one hyphen to avoid stripping English words ("Project not found").
// Safe for current callers: resource values with slugs use quotes (stripped above),
// and headline values don't start with entity names.
.replace(
/\b(Organization|Dashboard|Dashboards|Project|Team)\s+[\w][\w-]*-[\w-]*/gi,
"$1"
)
.replace(/\b\d+\b/g, "")
.replace(/\s+/g, " ")
.trim()
);
}

/**
Expand All @@ -140,6 +190,64 @@ export function extractMessagePrefix(message: string, maxWords = 3): string {
.join(" ");
}

/**
* Derive a stable `cli_error.kind` grouping key from an error instance.
*
* Returns `undefined` when the error is not a recognized CLI error class
* (the caller should still set `cli_error.class` for basic grouping).
*/
function deriveErrorKind(error: Error): string | undefined {
if (error instanceof ContextError) {
return error.resource;
}
if (error instanceof ResolutionError) {
return (
extractResourceKind(error.resource) +
" " +
extractResourceKind(error.headline)
);
}
// Fall back to the first few words of the message when no field is set
// (e.g. validateHexId throws with no `field`, so using field would
// collapse every unfielded ValidationError into one group).
if (error instanceof ValidationError) {
return error.field ?? extractMessagePrefix(error.message);
}
if (error instanceof ApiError) {
return String(error.status);
}
if (error instanceof SeerError) {
return error.reason;
}
if (error instanceof AuthError) {
return error.reason;
}
if (error instanceof UpgradeError) {
return error.reason;
}
if (error instanceof DeviceFlowError) {
return error.code;
}
if (error instanceof TimeoutError) {
return "timeout";
}
if (error instanceof HostScopeError) {
return "host_scope";
}
if (error instanceof WizardError) {
return "wizard";
}
// Catch-all for bare CliError — must be checked AFTER all subclasses
// because instanceof matches the entire prototype chain.
// ConfigError and OutputError intentionally fall through here:
// ConfigError has no structured field beyond message; OutputError is
// silenced by classifySilenced() before reaching deriveErrorKind().
if (error instanceof CliError) {
return extractMessagePrefix(error.message, 4);
}
return;
}

/**
* Set `cli_error.*` tags on a Sentry scope for an error that will be
* captured. These tags are matched by server-side fingerprint rules to
Expand All @@ -149,6 +257,7 @@ export function extractMessagePrefix(message: string, maxWords = 3): string {
* - `cli_error.class` — error class name (e.g. `"ContextError"`)
* - `cli_error.kind` — stable grouping key derived from structured fields
* - `cli_error.api_status` — HTTP status (ApiError only)
* - `cli_error.api_endpoint` — normalized API path (ApiError only)
*/
function setGroupingTags(scope: Sentry.Scope, error: unknown): void {
if (!(error instanceof Error)) {
Expand All @@ -157,36 +266,16 @@ function setGroupingTags(scope: Sentry.Scope, error: unknown): void {

scope.setTag("cli_error.class", error.name);

if (error instanceof ContextError) {
scope.setTag("cli_error.kind", error.resource);
} else if (error instanceof ResolutionError) {
scope.setTag(
"cli_error.kind",
extractResourceKind(error.resource) +
" " +
extractResourceKind(error.headline)
);
} else if (error instanceof ValidationError) {
// Fall back to the first few words of the message when no field is set
// (e.g. validateHexId throws with no `field`, so using field would
// collapse every unfielded ValidationError into one group).
scope.setTag(
"cli_error.kind",
error.field ?? extractMessagePrefix(error.message)
);
} else if (error instanceof ApiError) {
const kind = deriveErrorKind(error);
if (kind !== undefined) {
scope.setTag("cli_error.kind", kind);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-CliError errors through reportCliError miss kind tag

Low Severity

When a non-CliError exception (e.g., TypeError) is routed through reportCliError, deriveErrorKind returns undefined so cli_error.kind is never set on the scope. Then enrichEventWithGroupingTags in beforeSend can't help because its early-return guard sees cli_error.class already present and skips the event — including the new message-prefix logic. The same error type caught by the uncaught-exception handler would get cli_error.kind since cli_error.class isn't pre-set. This contradicts the PR's stated goal of zero error classes without cli_error.kind.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eec7396. Configure here.


if (error instanceof ApiError) {
scope.setTag("cli_error.api_status", String(error.status));
scope.setTag("cli_error.kind", String(error.status));
} else if (error instanceof SeerError) {
scope.setTag("cli_error.kind", error.reason);
} else if (error instanceof AuthError) {
scope.setTag("cli_error.kind", error.reason);
} else if (error instanceof UpgradeError) {
scope.setTag("cli_error.kind", error.reason);
} else if (error instanceof DeviceFlowError) {
scope.setTag("cli_error.kind", error.code);
} else if (error instanceof TimeoutError) {
scope.setTag("cli_error.kind", "timeout");
if (error.endpoint) {
scope.setTag("cli_error.api_endpoint", normalizeEndpoint(error.endpoint));
}
}
}

Expand Down Expand Up @@ -273,5 +362,12 @@ export function enrichEventWithGroupingTags(
event.tags = event.tags ?? {};
event.tags["cli_error.class"] = exc.type;

// Set kind from exception message prefix so server-side rules can group
// non-CliError exceptions (TypeError, Error, WizardCancelledError, etc.)
// that bypass reportCliError (uncaught exceptions, unhandled rejections).
if (exc.value) {
event.tags["cli_error.kind"] = extractMessagePrefix(exc.value, 4);
}

return event;
}
36 changes: 36 additions & 0 deletions src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,35 @@ export function isEpipeError(event: Sentry.ErrorEvent): boolean {
return false;
}

/**
* Detect EBADF (bad file descriptor) errors in Sentry events.
*
* These occur when the init wizard's stdin reopen (`stdin-reopen.ts`) queues
* reads on a destroyed file descriptor. Same class of OS-level noise as EPIPE
* — not actionable, just different fd numbers producing duplicate issues.
*
* @internal Exported for testing
*/
export function isEbadfError(event: Sentry.ErrorEvent): boolean {
const exceptions = event.exception?.values;
if (exceptions) {
for (const ex of exceptions) {
if (ex.value?.includes("EBADF")) {
return true;
}
}
}

const systemError = event.contexts?.node_system_error as
| { code?: string }
| undefined;
if (systemError?.code === "EBADF") {
return true;
}

return false;
}

/**
* Check if an error is a user-caused (401–499) API error.
*
Expand Down Expand Up @@ -612,6 +641,13 @@ export function initSentry(
return null;
}

// EBADF errors come from the init wizard's stdin reopen queuing reads
// on a destroyed fd. Same class of OS-level noise as EPIPE — different
// fd numbers just produce duplicate issues. Not actionable — drop them.
if (isEbadfError(event)) {
return null;
}

// Normalize relative frame paths to absolute. Bun's compiled binaries
// with sourcemap: "linked" produce relative paths like "dist-bin/bin.js"
// in Error.stack. Sentry's symbolicator only matches absolute paths
Expand Down
44 changes: 44 additions & 0 deletions test/lib/error-reporting.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const slugArb = array(
.map((chars) => chars.join(""))
.filter((s) => !(s.startsWith("-") || s.endsWith("-")) && s.length > 0);

/** Slug that contains at least one hyphen (matches the entity-name strip regex). */
const hyphenatedSlugArb = slugArb.filter((s) => s.includes("-"));

/** 32-character lowercase hex id (trace/event/log id). */
const hexIdArb = stringMatching(/^[0-9a-f]{32}$/).filter(
(s) => s.length === 32
Expand Down Expand Up @@ -130,4 +133,45 @@ describe("extractResourceKind — property tests", () => {
{ numRuns: DEFAULT_NUM_RUNS }
);
});

test("bare slug after 'in' (no slash) is stripped for any slug", () => {
fcAssert(
property(slugArb, slugArb, (a, b) => {
expect(extractResourceKind(`not found in ${a}`)).toBe(
extractResourceKind(`not found in ${b}`)
);
expect(extractResourceKind(`not found in ${a}`)).toBe("not found");
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});

test("hyphenated slug after entity name is stripped for any slug", () => {
fcAssert(
property(hyphenatedSlugArb, hyphenatedSlugArb, (a, b) => {
expect(extractResourceKind(`Organization ${a}`)).toBe(
extractResourceKind(`Organization ${b}`)
);
expect(extractResourceKind(`Organization ${a}`)).toBe("Organization");
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});

test("Dashboard with numeric ID and slug produces same kind", () => {
fcAssert(
property(
numericIdArb,
slugArb,
numericIdArb,
slugArb,
(n1, s1, n2, s2) => {
expect(extractResourceKind(`Dashboard ${n1} in ${s1}`)).toBe(
extractResourceKind(`Dashboard ${n2} in ${s2}`)
);
}
),
{ numRuns: DEFAULT_NUM_RUNS }
);
});
});
Loading
Loading