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
6 changes: 6 additions & 0 deletions .server-changes/errors-message-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion apps/webapp/app/utils/errorFingerprinting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "");
Comment thread
matt-aitken marked this conversation as resolved.
const stack = String(errorObj.stack || errorObj.stacktrace || "");
Comment thread
matt-aitken marked this conversation as resolved.

// Normalize message to group similar errors
Expand Down
44 changes: 44 additions & 0 deletions apps/webapp/test/errorFingerprinting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
});
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
matt-aitken marked this conversation as resolved.
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,
Comment thread
matt-aitken marked this conversation as resolved.
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;
Loading