diff --git a/.server-changes/errors-message-fallback.md b/.server-changes/errors-message-fallback.md new file mode 100644 index 0000000000..1c10ad72b7 --- /dev/null +++ b/.server-changes/errors-message-fallback.md @@ -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. diff --git a/apps/webapp/app/utils/errorFingerprinting.ts b/apps/webapp/app/utils/errorFingerprinting.ts index d598158e84..18fb365c1a 100644 --- a/apps/webapp/app/utils/errorFingerprinting.ts +++ b/apps/webapp/app/utils/errorFingerprinting.ts @@ -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 diff --git a/apps/webapp/test/errorFingerprinting.test.ts b/apps/webapp/test/errorFingerprinting.test.ts index b478825293..3fcb29a46b 100644 --- a/apps/webapp/test/errorFingerprinting.test.ts +++ b/apps/webapp/test/errorFingerprinting.test.ts @@ -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)); + }); + }); }); diff --git a/internal-packages/clickhouse/schema/035_fix_error_display_derivation.sql b/internal-packages/clickhouse/schema/035_fix_error_display_derivation.sql new file mode 100644 index 0000000000..aba8bafb13 --- /dev/null +++ b/internal-packages/clickhouse/schema/035_fix_error_display_derivation.sql @@ -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;