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
27 changes: 26 additions & 1 deletion src/lib/error-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ export function extractResourceKind(resource: string): string {
.trim();
}

/**
* Extract the first N words from the first line of an error message, after
* stripping quoted user data. Used as a grouping-key fallback when an error
* class lacks a structured `field`/`reason` to key on.
*
* `"Invalid trace ID \"abc\". Expected ..."` → `"Invalid trace ID"` (with maxWords=3)
*/
export function extractMessagePrefix(message: string, maxWords = 3): string {
const firstLine = message.split("\n", 1)[0] ?? "";
return firstLine
.replace(/'[^']*'/g, "")
.replace(/"[^"]*"/g, "")
.replace(/\s+/g, " ")
.trim()
.split(" ")
.slice(0, maxWords)
.join(" ");
}

/**
* 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 Down Expand Up @@ -145,7 +164,13 @@ function setGroupingTags(scope: Sentry.Scope, error: unknown): void {
extractResourceKind(error.headline)
);
} else if (error instanceof ValidationError) {
scope.setTag("cli_error.kind", error.field ?? "");
// 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) {
scope.setTag("cli_error.api_status", String(error.status));
scope.setTag("cli_error.kind", String(error.status));
Expand Down
107 changes: 107 additions & 0 deletions test/lib/error-reporting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as Sentry from "@sentry/node-core/light";
import {
classifySilenced,
enrichEventWithGroupingTags,
extractMessagePrefix,
extractResourceKind,
reportCliError,
} from "../../src/lib/error-reporting.js";
Expand Down Expand Up @@ -66,6 +67,46 @@ describe("extractResourceKind", () => {
});
});

// ---------------------------------------------------------------------------
// extractMessagePrefix
// ---------------------------------------------------------------------------

describe("extractMessagePrefix", () => {
test("returns first 3 words by default", () => {
expect(
extractMessagePrefix('Invalid trace ID "abc". Expected a 32-character.')
).toBe("Invalid trace ID");
});

test("strips quoted substrings before word-counting", () => {
// Quoted input doesn't push the real content past the word limit.
expect(extractMessagePrefix('Invalid event ID "anything"')).toBe(
"Invalid event ID"
);
});

test("stops at first newline", () => {
expect(
extractMessagePrefix("Invalid slug.\n\nTry: sentry project create")
).toBe("Invalid slug.");
});

test("returns '' for empty input", () => {
expect(extractMessagePrefix("")).toBe("");
});

test("respects custom maxWords", () => {
expect(extractMessagePrefix("one two three four five", 2)).toBe("one two");
});

test("same kind across different user-supplied values", () => {
// Invariant: slug variation should not change the kind
expect(extractMessagePrefix('Invalid trace ID "abc"')).toBe(
extractMessagePrefix('Invalid trace ID "def"')
);
});
});

// ---------------------------------------------------------------------------
// classifySilenced
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -186,6 +227,35 @@ describe("reportCliError integration", () => {
withScopeSpy.mockRestore();
});

/**
* Capture the tags that `reportCliError` would set on the scope.
* Intercepts `Sentry.withScope` and runs the callback with a fake scope
* that records `setTag`/`setContext` calls.
*/
function capturedScopeTags(error: unknown): {
tags: Record<string, string>;
contexts: Record<string, unknown>;
} {
const tags: Record<string, string> = {};
const contexts: Record<string, unknown> = {};
const fakeScope = {
setTag(k: string, v: string) {
tags[k] = v;
},
setContext(k: string, v: unknown) {
contexts[k] = v;
},
setFingerprint() {
/* noop */
},
};
withScopeSpy.mockImplementation((fn: (s: unknown) => void) => {
fn(fakeScope);
});
reportCliError(error);
return { tags, contexts };
}

test("captures ContextError with scope (tags applied)", () => {
const err = new ContextError("Organization", "sentry org view <slug>");
reportCliError(err);
Expand All @@ -194,6 +264,43 @@ describe("reportCliError integration", () => {
expect(metricSpy).not.toHaveBeenCalled();
});

test("ValidationError with field uses field as kind", () => {
const { tags } = capturedScopeTags(new ValidationError("Bad", "trace_id"));
expect(tags["cli_error.class"]).toBe("ValidationError");
expect(tags["cli_error.kind"]).toBe("trace_id");
});

test("ValidationError without field falls back to message prefix", () => {
// Without a stable fallback, every unfielded ValidationError would get
// kind="" and collapse into one huge mixed group.
const err = new ValidationError(
'Invalid trace ID "d2ad4a2d947b5983". Expected 32-char hex.'
);
const { tags } = capturedScopeTags(err);
expect(tags["cli_error.class"]).toBe("ValidationError");
expect(tags["cli_error.kind"]).toBe("Invalid trace ID");
});

test("ValidationError kind is stable across different user inputs", () => {
const a = capturedScopeTags(
new ValidationError('Invalid trace ID "abc"')
).tags;
const b = capturedScopeTags(
new ValidationError('Invalid trace ID "xyz-different"')
).tags;
expect(a["cli_error.kind"]).toBe(b["cli_error.kind"]);
});

test("ValidationError kind differentiates by validator", () => {
const traceErr = capturedScopeTags(
new ValidationError('Invalid trace ID "abc"')
).tags;
const eventErr = capturedScopeTags(
new ValidationError('Invalid event ID "abc"')
).tags;
expect(traceErr["cli_error.kind"]).not.toBe(eventErr["cli_error.kind"]);
});

test("captures ResolutionError", () => {
const err = new ResolutionError(
"Project 'x'",
Expand Down
Loading