Skip to content

Commit b7efc4e

Browse files
committed
fix: label and group errors without a message by their name
Errors thrown without a message (e.g. tagged errors) and non-Error throws (strings, plain objects) were all shown as "Unknown error" and collapsed into a single fingerprint, so unrelated failures across different tasks shared one group. Fall back message -> name -> raw when computing both the error fingerprint and the displayed message. Message-bearing errors are unaffected, so existing groups don't split; only errors that currently have no message get a meaningful title and their own group going forward. Also fix two display-derivation bugs in the same error materialized views (via MODIFY QUERY, existing rows unchanged): - error_type now shows the real class name / internal code instead of the generic serialization tag (BUILT_IN_ERROR, STRING_ERROR, ...). - stack traces now read the stored stackTrace field, not the always-empty error.data.stack, so they populate again.
1 parent 983bd03 commit b7efc4e

4 files changed

Lines changed: 247 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
The Errors page now shows better details for each error. Errors that don't carry a message — such as errors thrown without a message, or values thrown that aren't `Error` objects — get a meaningful title instead of all reading "Unknown error", and are grouped by their name (or value) rather than collapsed into a single group. The error type now shows the actual error name, and stack traces now appear where previously they were missing.

apps/webapp/app/utils/errorFingerprinting.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ export function calculateErrorFingerprint(error: unknown): string {
1212
// 2. It won't be an instanceof Error because it's from the database.
1313
const errorObj = error as any;
1414
const errorType = String(errorObj.type || errorObj.name || "Error");
15-
const message = String(errorObj.message || "");
15+
// Fall back to the error class name, then the raw serialized value, so
16+
// messageless errors (e.g. tagged errors) and non-Error throws (strings,
17+
// plain objects) still group by something distinctive instead of collapsing
18+
// into a single fingerprint. Message-bearing errors are unaffected.
19+
const message = String(errorObj.message || errorObj.name || errorObj.raw || "");
1620
const stack = String(errorObj.stack || errorObj.stacktrace || "");
1721

1822
// Normalize message to group similar errors

apps/webapp/test/errorFingerprinting.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,4 +402,48 @@ describe("calculateErrorFingerprint", () => {
402402
};
403403
expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2));
404404
});
405+
406+
describe("message fallback to name / raw", () => {
407+
it("distinguishes messageless built-in errors by their class name", () => {
408+
const error1 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" };
409+
const error2 = { type: "BUILT_IN_ERROR", name: "SendMessageError", message: "" };
410+
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
411+
});
412+
413+
it("groups two messageless errors of the same class together", () => {
414+
const error1 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" };
415+
const error2 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" };
416+
expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2));
417+
});
418+
419+
it("does not change the fingerprint of a message-bearing error", () => {
420+
// Fingerprint must stay stable for the common path, otherwise existing
421+
// error groups would split on deploy.
422+
const withMessage = {
423+
type: "BUILT_IN_ERROR",
424+
name: "SomeError",
425+
message: "Connection timeout",
426+
};
427+
const withoutName = { type: "BUILT_IN_ERROR", message: "Connection timeout" };
428+
expect(calculateErrorFingerprint(withMessage)).toBe(calculateErrorFingerprint(withoutName));
429+
});
430+
431+
it("distinguishes string errors by their raw value", () => {
432+
const error1 = { type: "STRING_ERROR", raw: "rate limited" };
433+
const error2 = { type: "STRING_ERROR", raw: "connection refused" };
434+
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
435+
});
436+
437+
it("distinguishes custom errors by their raw value", () => {
438+
const error1 = { type: "CUSTOM_ERROR", raw: '{"code":"E_LIMIT"}' };
439+
const error2 = { type: "CUSTOM_ERROR", raw: '{"code":"E_AUTH"}' };
440+
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
441+
});
442+
443+
it("prefers message over name and raw when all are present", () => {
444+
const withMessage = { type: "BUILT_IN_ERROR", name: "Ignored", message: "real message" };
445+
const messageOnly = { type: "BUILT_IN_ERROR", message: "real message" };
446+
expect(calculateErrorFingerprint(withMessage)).toBe(calculateErrorFingerprint(messageOnly));
447+
});
448+
});
405449
});
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
-- +goose Up
2+
-- Fix how the error materialized views derive their display columns from the
3+
-- stored error JSON:
4+
-- * error_type: use the real class name (or internal code), not the generic
5+
-- serialization tag (BUILT_IN_ERROR / STRING_ERROR / ...).
6+
-- * error_message: fall back name -> raw before 'Unknown error' so messageless
7+
-- errors (tagged errors) and non-Error throws still get a meaningful title.
8+
-- * stack trace: read error.data.stackTrace (the stored field), not
9+
-- error.data.stack, which was always empty.
10+
-- Display-only. Only affects rows inserted after this migration.
11+
12+
ALTER TABLE trigger_dev.errors_mv_v1 MODIFY QUERY
13+
SELECT
14+
organization_id,
15+
project_id,
16+
environment_id,
17+
task_identifier,
18+
error_fingerprint,
19+
20+
any(coalesce(nullIf(toString(error.data.name), ''), nullIf(toString(error.data.code), ''), 'Error')) as error_type,
21+
any(coalesce(
22+
nullIf(substring(toString(error.data.message), 1, 500), ''),
23+
nullIf(toString(error.data.name), ''),
24+
nullIf(substring(toString(error.data.raw), 1, 500), ''),
25+
'Unknown error'
26+
)) as error_message,
27+
any(coalesce(substring(toString(error.data.stackTrace), 1, 2000), '')) as sample_stack_trace,
28+
29+
toDateTime(max(created_at)) as last_seen_date,
30+
31+
min(created_at) as first_seen,
32+
max(created_at) as last_seen,
33+
sumState(toUInt64(1)) as occurrence_count,
34+
uniqState(task_version) as affected_task_versions,
35+
36+
anyState(run_id) as sample_run_id,
37+
anyState(friendly_id) as sample_friendly_id,
38+
39+
sumMapState([status], [toUInt64(1)]) as status_distribution
40+
FROM trigger_dev.task_runs_v2
41+
WHERE
42+
error_fingerprint != ''
43+
AND status IN ('SYSTEM_FAILURE', 'CRASHED', 'INTERRUPTED', 'COMPLETED_WITH_ERRORS', 'TIMED_OUT')
44+
AND _is_deleted = 0
45+
GROUP BY
46+
organization_id,
47+
project_id,
48+
environment_id,
49+
task_identifier,
50+
error_fingerprint;
51+
52+
ALTER TABLE trigger_dev.error_occurrences_mv_v1 MODIFY QUERY
53+
SELECT
54+
organization_id,
55+
project_id,
56+
environment_id,
57+
task_identifier,
58+
error_fingerprint,
59+
task_version,
60+
toStartOfMinute (created_at) as minute,
61+
any (
62+
coalesce(
63+
nullIf(toString (error.data.name), ''),
64+
nullIf(toString (error.data.code), ''),
65+
'Error'
66+
)
67+
) as error_type,
68+
any (
69+
coalesce(
70+
nullIf(substring(toString (error.data.message), 1, 500), ''),
71+
nullIf(toString (error.data.name), ''),
72+
nullIf(substring(toString (error.data.raw), 1, 500), ''),
73+
'Unknown error'
74+
)
75+
) as error_message,
76+
any (
77+
coalesce(
78+
substring(toString (error.data.stackTrace), 1, 2000),
79+
''
80+
)
81+
) as stack_trace,
82+
count() as count
83+
FROM
84+
trigger_dev.task_runs_v2
85+
WHERE
86+
error_fingerprint != ''
87+
AND status IN (
88+
'SYSTEM_FAILURE',
89+
'CRASHED',
90+
'INTERRUPTED',
91+
'COMPLETED_WITH_ERRORS',
92+
'TIMED_OUT'
93+
)
94+
AND _is_deleted = 0
95+
GROUP BY
96+
organization_id,
97+
project_id,
98+
environment_id,
99+
task_identifier,
100+
error_fingerprint,
101+
task_version,
102+
minute;
103+
104+
-- +goose Down
105+
106+
ALTER TABLE trigger_dev.errors_mv_v1 MODIFY QUERY
107+
SELECT
108+
organization_id,
109+
project_id,
110+
environment_id,
111+
task_identifier,
112+
error_fingerprint,
113+
114+
any(coalesce(nullIf(toString(error.data.type), ''), nullIf(toString(error.data.name), ''), 'Error')) as error_type,
115+
any(coalesce(nullIf(substring(toString(error.data.message), 1, 500), ''), 'Unknown error')) as error_message,
116+
any(coalesce(substring(toString(error.data.stack), 1, 2000), '')) as sample_stack_trace,
117+
118+
toDateTime(max(created_at)) as last_seen_date,
119+
120+
min(created_at) as first_seen,
121+
max(created_at) as last_seen,
122+
sumState(toUInt64(1)) as occurrence_count,
123+
uniqState(task_version) as affected_task_versions,
124+
125+
anyState(run_id) as sample_run_id,
126+
anyState(friendly_id) as sample_friendly_id,
127+
128+
sumMapState([status], [toUInt64(1)]) as status_distribution
129+
FROM trigger_dev.task_runs_v2
130+
WHERE
131+
error_fingerprint != ''
132+
AND status IN ('SYSTEM_FAILURE', 'CRASHED', 'INTERRUPTED', 'COMPLETED_WITH_ERRORS', 'TIMED_OUT')
133+
AND _is_deleted = 0
134+
GROUP BY
135+
organization_id,
136+
project_id,
137+
environment_id,
138+
task_identifier,
139+
error_fingerprint;
140+
141+
ALTER TABLE trigger_dev.error_occurrences_mv_v1 MODIFY QUERY
142+
SELECT
143+
organization_id,
144+
project_id,
145+
environment_id,
146+
task_identifier,
147+
error_fingerprint,
148+
task_version,
149+
toStartOfMinute (created_at) as minute,
150+
any (
151+
coalesce(
152+
nullIf(toString (error.data.type), ''),
153+
nullIf(toString (error.data.name), ''),
154+
'Error'
155+
)
156+
) as error_type,
157+
any (
158+
coalesce(
159+
nullIf(
160+
substring(toString (error.data.message), 1, 500),
161+
''
162+
),
163+
'Unknown error'
164+
)
165+
) as error_message,
166+
any (
167+
coalesce(
168+
substring(toString (error.data.stack), 1, 2000),
169+
''
170+
)
171+
) as stack_trace,
172+
count() as count
173+
FROM
174+
trigger_dev.task_runs_v2
175+
WHERE
176+
error_fingerprint != ''
177+
AND status IN (
178+
'SYSTEM_FAILURE',
179+
'CRASHED',
180+
'INTERRUPTED',
181+
'COMPLETED_WITH_ERRORS',
182+
'TIMED_OUT'
183+
)
184+
AND _is_deleted = 0
185+
GROUP BY
186+
organization_id,
187+
project_id,
188+
environment_id,
189+
task_identifier,
190+
error_fingerprint,
191+
task_version,
192+
minute;

0 commit comments

Comments
 (0)