Skip to content

Commit 95adc7b

Browse files
committed
feat: add error message normalization and integration
Add utility to normalize error messages by extracting dynamic values (UUIDs, IDs, hashes, etc.) and replacing them with placeholders. This enables better error grouping in monitoring tools like Bugsnag. Integrate normalization into Bugsnag error handler with custom grouping hash.
1 parent e5ea46c commit 95adc7b

4 files changed

Lines changed: 497 additions & 7 deletions

File tree

src/services/errorManagement/bugsnag.test.ts

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,13 @@ const createTestEvent = (errorClass: string, errorMessage: string): Event => {
1515
const testClient = Bugsnag.createClient({
1616
apiKey: "0123456789abcdef0123456789abcdef",
1717
onError: (event) => {
18+
event.errors[0].errorClass = errorClass;
1819
capturedEvent = event;
1920
return false;
2021
},
2122
});
2223

23-
testClient.notify(
24-
new Error(errorMessage),
25-
(event) => {
26-
event.errors[0].errorClass = errorClass;
27-
},
28-
() => {},
29-
);
24+
testClient.notify(new Error(errorMessage));
3025

3126
if (!capturedEvent) {
3227
throw new Error("Failed to capture test event");
@@ -36,6 +31,109 @@ const createTestEvent = (errorClass: string, errorMessage: string): Event => {
3631
};
3732

3833
describe("handleBugsnagError", () => {
34+
it("should normalize generic Error instances", () => {
35+
const event = createTestEvent(
36+
"Error",
37+
"HTTP 404 Not Found: /api/executions/019b3428-a0f0-d259-b764-ae0efbf37a64/status",
38+
);
39+
40+
const addMetadataSpy = vi.spyOn(event, "addMetadata");
41+
handleBugsnagError(event);
42+
43+
expect(event.groupingHash).toBe(
44+
"HTTP 404 Not Found: /api/executions/{var1}/status",
45+
);
46+
expect(event.errors[0].errorClass).toBe(
47+
"HTTP 404 Not Found: /api/executions/{var1}/status",
48+
);
49+
expect(addMetadataSpy).toHaveBeenCalledWith("context", {
50+
pathname: mockPathname,
51+
});
52+
expect(addMetadataSpy).toHaveBeenCalledWith("extracted_values", {
53+
"{var1}": "019b3428-a0f0-d259-b764-ae0efbf37a64",
54+
});
55+
});
56+
57+
it("should NOT normalize custom error classes", () => {
58+
const errorMessage = "Network timeout after 30 seconds";
59+
const event = createTestEvent("NetworkError", errorMessage);
60+
const originalErrorClass = event.errors[0].errorClass;
61+
62+
const addMetadataSpy = vi.spyOn(event, "addMetadata");
63+
handleBugsnagError(event);
64+
65+
expect(event.errors[0].errorClass).toBe(originalErrorClass);
66+
expect(event.errors[0].errorClass).toBe("NetworkError");
67+
expect(event.groupingHash).toBeUndefined();
68+
expect(addMetadataSpy).toHaveBeenCalledWith("context", {
69+
pathname: mockPathname,
70+
});
71+
expect(addMetadataSpy).not.toHaveBeenCalledWith(
72+
"extracted_values",
73+
expect.anything(),
74+
);
75+
});
76+
77+
it("should NOT add extracted_values metadata when no values are extracted", () => {
78+
const event = createTestEvent("Error", "Simple error message");
79+
80+
const addMetadataSpy = vi.spyOn(event, "addMetadata");
81+
handleBugsnagError(event);
82+
83+
expect(addMetadataSpy).not.toHaveBeenCalledWith(
84+
"extracted_values",
85+
expect.anything(),
86+
);
87+
expect(addMetadataSpy).toHaveBeenCalledWith("context", {
88+
pathname: mockPathname,
89+
});
90+
});
91+
92+
it("should handle multiple dynamic values in error message", () => {
93+
const event = createTestEvent(
94+
"Error",
95+
"Failed to copy /files/abc123defghi/item to /files/xyz789ghijkl/backup",
96+
);
97+
98+
const addMetadataSpy = vi.spyOn(event, "addMetadata");
99+
handleBugsnagError(event);
100+
101+
expect(event.groupingHash).toBe(
102+
"Failed to copy /files/{var1}/item to /files/{var2}/backup",
103+
);
104+
expect(addMetadataSpy).toHaveBeenCalledWith("extracted_values", {
105+
"{var1}": "abc123defghi",
106+
"{var2}": "xyz789ghijkl",
107+
});
108+
});
109+
110+
it("should preserve HTTP status codes in error messages", () => {
111+
const event = createTestEvent(
112+
"Error",
113+
"HTTP 502 Bad Gateway: Gateway timeout",
114+
);
115+
116+
handleBugsnagError(event);
117+
118+
expect(event.groupingHash).toBe("HTTP 502 Bad Gateway: Gateway timeout");
119+
expect(event.groupingHash).not.toContain("{var");
120+
});
121+
122+
it("should extract hash values from error messages", () => {
123+
const event = createTestEvent(
124+
"Error",
125+
"Checksum mismatch: expected a1b2c3d4e5f67890123456789abcdef0",
126+
);
127+
128+
const addMetadataSpy = vi.spyOn(event, "addMetadata");
129+
handleBugsnagError(event);
130+
131+
expect(event.groupingHash).toBe("Checksum mismatch: expected {var1}");
132+
expect(addMetadataSpy).toHaveBeenCalledWith("extracted_values", {
133+
"{var1}": "a1b2c3d4e5f67890123456789abcdef0",
134+
});
135+
});
136+
39137
it("should add pathname context for all errors", () => {
40138
const genericError = createTestEvent("Error", "Generic error");
41139
const customError = createTestEvent("ValidationError", "Validation failed");

src/services/errorManagement/bugsnag.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import Bugsnag, { type BrowserConfig, type Event } from "@bugsnag/js";
22
import BugsnagPluginReact from "@bugsnag/plugin-react";
33

4+
import { normalizeErrorMessage } from "./normalizeErrorMessage";
5+
46
const BUGSNAG_API_KEY = import.meta.env.VITE_BUGSNAG_API_KEY || "";
57
const BUGSNAG_NOTIFY_ENDPOINT =
68
import.meta.env.VITE_BUGSNAG_NOTIFY_ENDPOINT || "https://notify.bugsnag.com";
79
const BUGSNAG_SESSIONS_ENDPOINT =
810
import.meta.env.VITE_BUGSNAG_SESSIONS_ENDPOINT ||
911
"https://sessions.bugsnag.com";
12+
const BUGSNAG_CUSTOM_GROUPING_KEY = import.meta.env
13+
.VITE_BUGSNAG_CUSTOM_GROUPING_KEY;
14+
15+
const GENERIC_ERROR_CLASS = "Error";
1016

1117
const getBugsnagConfig = (): BrowserConfig => {
1218
return {
@@ -21,6 +27,28 @@ const getBugsnagConfig = (): BrowserConfig => {
2127
};
2228

2329
export const handleBugsnagError = (event: Event): void => {
30+
if (
31+
event.errors.length > 0 &&
32+
event.errors[0].errorClass === GENERIC_ERROR_CLASS
33+
) {
34+
const errorMessage = event.errors[0].errorMessage;
35+
const { normalizedMessage, extractedValues } =
36+
normalizeErrorMessage(errorMessage);
37+
38+
event.groupingHash = normalizedMessage;
39+
event.errors[0].errorClass = normalizedMessage;
40+
41+
if (BUGSNAG_CUSTOM_GROUPING_KEY) {
42+
event.addMetadata("custom", {
43+
[BUGSNAG_CUSTOM_GROUPING_KEY]: normalizedMessage,
44+
});
45+
}
46+
47+
if (Object.keys(extractedValues).length > 0) {
48+
event.addMetadata("extracted_values", extractedValues);
49+
}
50+
}
51+
2452
event.addMetadata("context", { pathname: window.location.pathname });
2553
};
2654

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { normalizeErrorMessage } from "./normalizeErrorMessage";
4+
5+
describe("normalizeErrorMessage", () => {
6+
it("should extract UUIDs from error messages", () => {
7+
const message =
8+
"HTTP 404 Not Found: Resource not found (URL: http://localhost:8000/api/executions/019b3428-a0f0-d259-b764-ae0efbf37a64/status)";
9+
const result = normalizeErrorMessage(message);
10+
11+
expect(result.normalizedMessage).toBe(
12+
"HTTP 404 Not Found: Resource not found (URL: http://localhost:{var2}/api/executions/{var1}/status)",
13+
);
14+
expect(result.extractedValues["{var1}"]).toBe(
15+
"019b3428-a0f0-d259-b764-ae0efbf37a64",
16+
);
17+
expect(result.extractedValues["{var2}"]).toBe("8000");
18+
});
19+
20+
it("should extract numeric IDs from paths", () => {
21+
const message =
22+
"HTTP 500 Internal Server Error (URL: /api/runs/12345/tasks)";
23+
const result = normalizeErrorMessage(message);
24+
25+
expect(result.normalizedMessage).toBe(
26+
"HTTP 500 Internal Server Error (URL: /api/runs/{var1}/tasks)",
27+
);
28+
expect(result.extractedValues["{var1}"]).toBe("12345");
29+
});
30+
31+
it("should preserve HTTP status codes", () => {
32+
const message = "HTTP 502 Bad Gateway: Gateway timeout";
33+
const result = normalizeErrorMessage(message);
34+
35+
expect(result.normalizedMessage).toBe(
36+
"HTTP 502 Bad Gateway: Gateway timeout",
37+
);
38+
expect(result.extractedValues).toEqual({});
39+
});
40+
41+
it("should extract alphanumeric IDs", () => {
42+
const message = "Error fetching /api/resources/abc123def456/details";
43+
const result = normalizeErrorMessage(message);
44+
45+
expect(result.normalizedMessage).toBe(
46+
"Error fetching /api/resources/{var1}/details",
47+
);
48+
expect(result.extractedValues["{var1}"]).toBe("abc123def456");
49+
});
50+
51+
it("should extract query parameters with IDs", () => {
52+
const message = "Failed to load /api/data?id=abc123&status=pending";
53+
const result = normalizeErrorMessage(message);
54+
55+
expect(result.normalizedMessage).toBe(
56+
"Failed to load /api/data?id={var1}&status=pending",
57+
);
58+
expect(result.extractedValues["{var1}"]).toBe("abc123");
59+
});
60+
61+
it("should extract hash values (MD5, SHA-1, SHA-256)", () => {
62+
// Test MD5 hash (32 characters)
63+
const md5Message =
64+
"Validation failed for hash a1b2c3d4e5f67890123456789abcdef0";
65+
const md5Result = normalizeErrorMessage(md5Message);
66+
67+
expect(md5Result.normalizedMessage).toBe(
68+
"Validation failed for hash {var1}",
69+
);
70+
expect(md5Result.extractedValues["{var1}"]).toBe(
71+
"a1b2c3d4e5f67890123456789abcdef0",
72+
);
73+
74+
// Test SHA-1 hash (40 characters)
75+
const sha1Message =
76+
"Error: checksum mismatch abc123def4567890abcdef1234567890abcdef12";
77+
const sha1Result = normalizeErrorMessage(sha1Message);
78+
79+
expect(sha1Result.normalizedMessage).toBe(
80+
"Error: checksum mismatch {var1}",
81+
);
82+
expect(sha1Result.extractedValues["{var1}"]).toBe(
83+
"abc123def4567890abcdef1234567890abcdef12",
84+
);
85+
86+
// Test SHA-256 hash (64 characters)
87+
const sha256Message =
88+
"Failed to verify 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
89+
const sha256Result = normalizeErrorMessage(sha256Message);
90+
91+
expect(sha256Result.normalizedMessage).toBe("Failed to verify {var1}");
92+
expect(sha256Result.extractedValues["{var1}"]).toBe(
93+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
94+
);
95+
});
96+
97+
it("should extract quoted strings", () => {
98+
const message = 'Error: File "document.pdf" not found';
99+
const result = normalizeErrorMessage(message);
100+
101+
expect(result.normalizedMessage).toBe("Error: File {var1} not found");
102+
expect(result.extractedValues["{var1}"]).toBe("document.pdf");
103+
});
104+
105+
it("should truncate long messages to 200 characters", () => {
106+
const longMessage = "Error: " + "a".repeat(300);
107+
const result = normalizeErrorMessage(longMessage);
108+
109+
// After normalization, all 'a's get extracted as a quoted string placeholder
110+
// So the message becomes much shorter
111+
expect(result.normalizedMessage.length).toBeLessThanOrEqual(200);
112+
});
113+
114+
it("should handle multiple dynamic values", () => {
115+
const message =
116+
"Failed to copy /files/abc123defghi/item to /files/xyz789ghijkl/backup";
117+
const result = normalizeErrorMessage(message);
118+
119+
expect(result.normalizedMessage).toBe(
120+
"Failed to copy /files/{var1}/item to /files/{var2}/backup",
121+
);
122+
expect(result.extractedValues["{var1}"]).toBe("abc123defghi");
123+
expect(result.extractedValues["{var2}"]).toBe("xyz789ghijkl");
124+
});
125+
126+
it("should clean up multiple spaces and empty parentheses", () => {
127+
const message = "Error: Multiple spaces and () empty parens ()";
128+
const result = normalizeErrorMessage(message);
129+
130+
// The function cleans up multiple spaces to single spaces and removes empty parens
131+
expect(result.normalizedMessage).toContain("Error:");
132+
expect(result.normalizedMessage).toContain("Multiple");
133+
expect(result.normalizedMessage).not.toContain("()");
134+
expect(result.normalizedMessage.trim()).toBe(result.normalizedMessage);
135+
});
136+
137+
it("should NOT extract short hex strings that are not real hashes", () => {
138+
// 26 characters - too short to be a real hash, should not be extracted
139+
const message = "Error with value a1b2c3d4e5f6789012345678 in system";
140+
const result = normalizeErrorMessage(message);
141+
142+
expect(result.normalizedMessage).toBe(
143+
"Error with value a1b2c3d4e5f6789012345678 in system",
144+
);
145+
expect(result.extractedValues).toEqual({});
146+
});
147+
148+
it("should preserve HTTP status codes with multiple spaces", () => {
149+
const message = "HTTP 502 Bad Gateway: Gateway timeout";
150+
const result = normalizeErrorMessage(message);
151+
152+
expect(result.normalizedMessage).toContain("HTTP 502");
153+
expect(result.normalizedMessage).toContain("Gateway timeout");
154+
// The 502 should NOT be extracted as a variable
155+
expect(result.normalizedMessage).not.toContain("{var");
156+
});
157+
});

0 commit comments

Comments
 (0)