fix: improve error labelling, grouping, and stack traces in the Errors feature (#4225)

## Problem

Several display/grouping issues in the **Errors** feature, all rooted in
how the ClickHouse error materialized views (`errors_mv_v1`,
`error_occurrences_mv_v1`) read the stored error JSON produced by
`parseError`:

1. **Messageless errors show "Unknown error".** An empty message falls
straight through `coalesce(nullIf(message,''), 'Unknown error')` to the
literal, even though the error's class `name` is available (e.g. an
Effect tagged error `ListMessagesError` with no message).
2. **Unrelated errors collapse into one group.**
`calculateErrorFingerprint` keys on `type : message : stack`, where
`type` is always the union tag (`BUILT_IN_ERROR`, …), `message` is
empty, and the stack isn't read — so every messageless built-in error
(and every string/custom error) hashes to the same constant input → one
fingerprint.
3. **error_type shows the internal tag.** `coalesce(type, name, …)`
always resolves to `type` (always present), so the column shows
`BUILT_IN_ERROR` instead of the real class name.
4. **Stack traces never populate.** The MVs read `error.data.stack`, but
the serializer stores the trace under `stackTrace` — so the column is
always empty.

## Fix

All display changes are `ALTER TABLE … MODIFY QUERY` on the two views
(migration `035`); the fingerprint change is in the webapp.

- **Fingerprint** (`errorFingerprinting.ts`): fall back **message → name
→ raw**. Messageless errors now group by class name (or raw value for
non-Error throws); message-bearing errors are **unchanged**
(short-circuits at `message`), so existing groups don't split — only
currently-messageless errors get their own group going forward.
- **error_message**: same `message → name → raw` fallback before
`'Unknown error'`.
- **error_type**: coalesce `name → code → 'Error'` (drops the reliance
on the union tag). Built-in → class name, internal → `code`,
string/custom → `Error`.
- **stack trace**: read `error.data.stackTrace`. Bounded as before
(serializer caps 50 frames / 1024 chars per line; MV clips to 2000
chars).

## Migration notes

- `MODIFY QUERY` swaps the view query in place (no drop/recreate gap);
Down restores the previous query.
- **Existing rows are left unchanged** — changes apply only to rows
inserted after the migration. No backfill.

## Tests

`errorFingerprinting.test.ts` — 57 pass, incl. new cases for messageless
class names, string/custom raw values, and stability of message-bearing
fingerprints.

Fixes the display-derivation half of TRI-11938 (error_type + stack
trace); relates to TRI-9254 and TRI-9250.
This commit is contained in:
Matt Aitken
2026-07-10 18:30:02 +01:00
committed by GitHub
parent 4be32d411c
commit 2cac63f13a
4 changed files with 247 additions and 1 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
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.
+5 -1
View File
@@ -12,7 +12,11 @@ export function calculateErrorFingerprint(error: unknown): string {
// 2. It won't be an instanceof Error because it's from the database.
const errorObj = error as any;
const errorType = String(errorObj.type || errorObj.name || "Error");
const message = String(errorObj.message || "");
// Fall back to the error class name, then the raw serialized value, so
// messageless errors (e.g. tagged errors) and non-Error throws (strings,
// plain objects) still group by something distinctive instead of collapsing
// into a single fingerprint. Message-bearing errors are unaffected.
const message = String(errorObj.message || errorObj.name || errorObj.raw || "");
const stack = String(errorObj.stack || errorObj.stacktrace || "");
// Normalize message to group similar errors
@@ -402,4 +402,48 @@ describe("calculateErrorFingerprint", () => {
};
expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2));
});
describe("message fallback to name / raw", () => {
it("distinguishes messageless built-in errors by their class name", () => {
const error1 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" };
const error2 = { type: "BUILT_IN_ERROR", name: "SendMessageError", message: "" };
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
});
it("groups two messageless errors of the same class together", () => {
const error1 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" };
const error2 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" };
expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2));
});
it("does not change the fingerprint of a message-bearing error", () => {
// Fingerprint must stay stable for the common path, otherwise existing
// error groups would split on deploy.
const withMessage = {
type: "BUILT_IN_ERROR",
name: "SomeError",
message: "Connection timeout",
};
const withoutName = { type: "BUILT_IN_ERROR", message: "Connection timeout" };
expect(calculateErrorFingerprint(withMessage)).toBe(calculateErrorFingerprint(withoutName));
});
it("distinguishes string errors by their raw value", () => {
const error1 = { type: "STRING_ERROR", raw: "rate limited" };
const error2 = { type: "STRING_ERROR", raw: "connection refused" };
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
});
it("distinguishes custom errors by their raw value", () => {
const error1 = { type: "CUSTOM_ERROR", raw: '{"code":"E_LIMIT"}' };
const error2 = { type: "CUSTOM_ERROR", raw: '{"code":"E_AUTH"}' };
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
});
it("prefers message over name and raw when all are present", () => {
const withMessage = { type: "BUILT_IN_ERROR", name: "Ignored", message: "real message" };
const messageOnly = { type: "BUILT_IN_ERROR", message: "real message" };
expect(calculateErrorFingerprint(withMessage)).toBe(calculateErrorFingerprint(messageOnly));
});
});
});
@@ -0,0 +1,192 @@
-- +goose Up
-- Fix how the error materialized views derive their display columns from the
-- stored error JSON:
-- * error_type: use the real class name (or internal code), not the generic
-- serialization tag (BUILT_IN_ERROR / STRING_ERROR / ...).
-- * error_message: fall back name -> raw before 'Unknown error' so messageless
-- errors (tagged errors) and non-Error throws still get a meaningful title.
-- * stack trace: read error.data.stackTrace (the stored field), not
-- error.data.stack, which was always empty.
-- Display-only. Only affects rows inserted after this migration.
ALTER TABLE trigger_dev.errors_mv_v1 MODIFY QUERY
SELECT
organization_id,
project_id,
environment_id,
task_identifier,
error_fingerprint,
any(coalesce(nullIf(toString(error.data.name), ''), nullIf(toString(error.data.code), ''), 'Error')) as error_type,
any(coalesce(
nullIf(substring(toString(error.data.message), 1, 500), ''),
nullIf(toString(error.data.name), ''),
nullIf(substring(toString(error.data.raw), 1, 500), ''),
'Unknown error'
)) as error_message,
any(coalesce(substring(toString(error.data.stackTrace), 1, 2000), '')) as sample_stack_trace,
toDateTime(max(created_at)) as last_seen_date,
min(created_at) as first_seen,
max(created_at) as last_seen,
sumState(toUInt64(1)) as occurrence_count,
uniqState(task_version) as affected_task_versions,
anyState(run_id) as sample_run_id,
anyState(friendly_id) as sample_friendly_id,
sumMapState([status], [toUInt64(1)]) as status_distribution
FROM trigger_dev.task_runs_v2
WHERE
error_fingerprint != ''
AND status IN ('SYSTEM_FAILURE', 'CRASHED', 'INTERRUPTED', 'COMPLETED_WITH_ERRORS', 'TIMED_OUT')
AND _is_deleted = 0
GROUP BY
organization_id,
project_id,
environment_id,
task_identifier,
error_fingerprint;
ALTER TABLE trigger_dev.error_occurrences_mv_v1 MODIFY QUERY
SELECT
organization_id,
project_id,
environment_id,
task_identifier,
error_fingerprint,
task_version,
toStartOfMinute (created_at) as minute,
any (
coalesce(
nullIf(toString (error.data.name), ''),
nullIf(toString (error.data.code), ''),
'Error'
)
) as error_type,
any (
coalesce(
nullIf(substring(toString (error.data.message), 1, 500), ''),
nullIf(toString (error.data.name), ''),
nullIf(substring(toString (error.data.raw), 1, 500), ''),
'Unknown error'
)
) as error_message,
any (
coalesce(
substring(toString (error.data.stackTrace), 1, 2000),
''
)
) as stack_trace,
count() as count
FROM
trigger_dev.task_runs_v2
WHERE
error_fingerprint != ''
AND status IN (
'SYSTEM_FAILURE',
'CRASHED',
'INTERRUPTED',
'COMPLETED_WITH_ERRORS',
'TIMED_OUT'
)
AND _is_deleted = 0
GROUP BY
organization_id,
project_id,
environment_id,
task_identifier,
error_fingerprint,
task_version,
minute;
-- +goose Down
ALTER TABLE trigger_dev.errors_mv_v1 MODIFY QUERY
SELECT
organization_id,
project_id,
environment_id,
task_identifier,
error_fingerprint,
any(coalesce(nullIf(toString(error.data.type), ''), nullIf(toString(error.data.name), ''), 'Error')) as error_type,
any(coalesce(nullIf(substring(toString(error.data.message), 1, 500), ''), 'Unknown error')) as error_message,
any(coalesce(substring(toString(error.data.stack), 1, 2000), '')) as sample_stack_trace,
toDateTime(max(created_at)) as last_seen_date,
min(created_at) as first_seen,
max(created_at) as last_seen,
sumState(toUInt64(1)) as occurrence_count,
uniqState(task_version) as affected_task_versions,
anyState(run_id) as sample_run_id,
anyState(friendly_id) as sample_friendly_id,
sumMapState([status], [toUInt64(1)]) as status_distribution
FROM trigger_dev.task_runs_v2
WHERE
error_fingerprint != ''
AND status IN ('SYSTEM_FAILURE', 'CRASHED', 'INTERRUPTED', 'COMPLETED_WITH_ERRORS', 'TIMED_OUT')
AND _is_deleted = 0
GROUP BY
organization_id,
project_id,
environment_id,
task_identifier,
error_fingerprint;
ALTER TABLE trigger_dev.error_occurrences_mv_v1 MODIFY QUERY
SELECT
organization_id,
project_id,
environment_id,
task_identifier,
error_fingerprint,
task_version,
toStartOfMinute (created_at) as minute,
any (
coalesce(
nullIf(toString (error.data.type), ''),
nullIf(toString (error.data.name), ''),
'Error'
)
) as error_type,
any (
coalesce(
nullIf(
substring(toString (error.data.message), 1, 500),
''
),
'Unknown error'
)
) as error_message,
any (
coalesce(
substring(toString (error.data.stack), 1, 2000),
''
)
) as stack_trace,
count() as count
FROM
trigger_dev.task_runs_v2
WHERE
error_fingerprint != ''
AND status IN (
'SYSTEM_FAILURE',
'CRASHED',
'INTERRUPTED',
'COMPLETED_WITH_ERRORS',
'TIMED_OUT'
)
AND _is_deleted = 0
GROUP BY
organization_id,
project_id,
environment_id,
task_identifier,
error_fingerprint,
task_version,
minute;