2cac63f13a
## 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.
98 lines
3.8 KiB
TypeScript
98 lines
3.8 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
/**
|
|
* Calculate error fingerprint using Sentry-style normalization.
|
|
* Groups similar errors together by normalizing dynamic values.
|
|
*/
|
|
export function calculateErrorFingerprint(error: unknown): string {
|
|
if (!error || typeof error !== "object" || Array.isArray(error)) return "";
|
|
|
|
// This is a but ugly but…
|
|
// 1. We can't use a schema here because it's a hot path and needs to be fast.
|
|
// 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");
|
|
// 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
|
|
const normalizedMessage = normalizeErrorMessage(message);
|
|
|
|
// Extract and normalize first few stack frames
|
|
const normalizedStack = normalizeStackTrace(stack);
|
|
|
|
// Create fingerprint from type + normalized message + stack
|
|
const fingerprintInput = `${errorType}:${normalizedMessage}:${normalizedStack}`;
|
|
|
|
// Use SHA-256 hash, take first 16 chars for compact storage
|
|
return createHash("sha256").update(fingerprintInput).digest("hex").substring(0, 16);
|
|
}
|
|
|
|
/**
|
|
* Normalize error message by replacing dynamic values with placeholders.
|
|
* This allows similar errors to be grouped together.
|
|
*/
|
|
export function normalizeErrorMessage(message: string): string {
|
|
if (!message) return "";
|
|
|
|
return (
|
|
message
|
|
// UUIDs (8-4-4-4-12 format)
|
|
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>")
|
|
// Run IDs (run_xxxxx format)
|
|
.replace(/run_[a-zA-Z0-9]+/g, "<run-id>")
|
|
// Task run friendly IDs (task_xxxxx or similar)
|
|
.replace(/\b[a-z]+_[a-zA-Z0-9]{8,}\b/g, "<id>")
|
|
// --- Specific patterns must run before generic numeric/path replacements ---
|
|
// ISO 8601 timestamps
|
|
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?/g, "<timestamp>")
|
|
// Unix timestamps (10 or 13 digits)
|
|
.replace(/\b\d{10,13}\b/g, "<timestamp>")
|
|
// URLs (before path regex, which would strip the URL's path component)
|
|
.replace(/https?:\/\/[^\s]+/g, "<url>")
|
|
// --- Generic replacements ---
|
|
// Standalone numeric IDs (4+ digits)
|
|
.replace(/\b\d{4,}\b/g, "<id>")
|
|
// File paths (Unix style)
|
|
.replace(/(?:\/[^/\s]+){2,}/g, "<path>")
|
|
// File paths (Windows style)
|
|
.replace(/[A-Z]:\\(?:[^\\]+\\)+[^\\]+/g, "<path>")
|
|
// Email addresses
|
|
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "<email>")
|
|
// Memory addresses (0x...)
|
|
.replace(/0x[0-9a-fA-F]{8,}/g, "<addr>")
|
|
// Quoted strings with dynamic content
|
|
.replace(/"[^"]{20,}"/g, '"<string>"')
|
|
.replace(/'[^']{20,}'/g, "'<string>'")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Normalize stack trace by taking first few frames and removing dynamic parts.
|
|
*/
|
|
export function normalizeStackTrace(stack: string): string {
|
|
if (!stack) return "";
|
|
|
|
// Take first 5 stack frames only
|
|
const lines = stack.split("\n").slice(0, 5);
|
|
|
|
return lines
|
|
.map((line) => {
|
|
// Remove line and column numbers (file.ts:123:45 -> file.ts:_:_)
|
|
line = line.replace(/:\d+:\d+/g, ":_:_");
|
|
// Remove standalone numbers
|
|
line = line.replace(/\b\d+\b/g, "_");
|
|
// Remove file paths but keep filename
|
|
line = line.replace(/(?:\/[^/\s]+)+\/([^/\s]+)/g, "$1");
|
|
// Normalize whitespace
|
|
line = line.trim();
|
|
return line;
|
|
})
|
|
.filter((line) => line.length > 0)
|
|
.join("|");
|
|
}
|