Files
sim/packages/utils/src/errors.ts
T
Waleed 9b4793083a fix(observability): attach the real cause at three error-swallowing sites (#6336)
Three log sites discarded the underlying error, which blocked root-cause
analysis in production.

- Trace secret projection swallowed TraceSecretProjectionError in four
  catch blocks (per-field omission, whole-tree fallback, post-transform
  invariant, structural traversal) across ~30 distinct throw sites, so no
  warning said which invariant fired. Each now reports the failure. Only
  TraceSecretProjectionError messages are logged — they are fixed literals
  describing an invariant. A failure raised outside the module may quote
  trace content (a JSON parse error embeds the text it choked on), so those
  are reported by name only.
- ExecutionLogger's unbilled-charge error logged `"error":{}` because a
  plain Error has non-enumerable message/stack. It now logs describeError.
- WorkspaceFileStorage / FetchExternalUrl logged `saveError:{}` for the
  same reason, and the upload wrapper rethrew without a cause, so Drizzle's
  `Failed query:` wrapper dropped the Postgres SQLSTATE. The wrapper now
  chains the cause and both sites log describeError, which reports the
  deepest link's code.

describeError additionally strips the `params:` tail Drizzle appends to its
message, so bound parameter values never reach logs.
2026-08-06 12:22:54 -07:00

157 lines
5.3 KiB
TypeScript

/**
* Normalizes an unknown caught value into an Error instance.
* Replaces the common `e instanceof Error ? e : new Error(String(e))` pattern in catch clauses.
*/
export function toError(value: unknown): Error {
if (value instanceof Error) return value
if (typeof value === 'string') return new Error(value)
return new Error(String(value))
}
/**
* Extracts a string message from an unknown caught value.
* Use instead of `e instanceof Error ? e.message : 'fallback'` in catch clauses.
*
* - Error instance → `error.message`
* - Non-empty string → the string itself (handles `throw 'msg'` patterns)
* - Otherwise → `fallback` if provided, or `String(value)`
*/
export function getErrorMessage(value: unknown, fallback?: string): string {
if (value instanceof Error) return value.message
if (typeof value === 'string' && value.length > 0) return value
return fallback ?? String(value)
}
/**
* Returns PostgreSQL error code (e.g. `23505` for unique_violation) when present on a thrown value.
* Normalizes common Drizzle / `postgres` driver shapes and walks `cause` chains.
*/
export function getPostgresErrorCode(error: unknown): string | undefined {
return readPgErrorField(error, 'code')
}
/**
* Returns the name of the PostgreSQL constraint that triggered the error (e.g. the unique index
* name on a `23505`), when present on a thrown value. Mirrors the field populated by the
* `postgres` / `pg` drivers, walking `cause` chains the same way as `getPostgresErrorCode`.
*/
export function getPostgresConstraintName(error: unknown): string | undefined {
return readPgErrorField(error, 'constraint_name') ?? readPgErrorField(error, 'constraint')
}
export interface DescribedError {
name: string
message: string
code?: string
errno?: string
syscall?: string
/** `"Name: message"` per link in the `.cause` chain, outermost first. Present only when the chain has more than one link. */
causeChain?: string[]
}
/**
* Always-on diagnostic view of an error and its `.cause` chain.
*
* Reports the fields of the DEEPEST `.cause` link, because a wrapped driver
* error (e.g. Drizzle's `"Failed query: ..."` wrapping an `ECONNRESET`) carries
* the real reason there, not on the outer wrapper. Always returns a populated
* object — including for non-`Error` throws and unclassified errors like
* `AbortError`. Cycle-safe and depth-bounded.
*
* Loggers do not serialize the non-enumerable `Error.prototype.cause`, so pass
* the result as an explicit structured field rather than the raw error.
*
* Bound parameter values are stripped from every reported message: Drizzle's
* `DrizzleQueryError` appends `\nparams: <values>` to the failing SQL, and those
* values are user data that must never reach logs.
*/
export function describeError(error: unknown): DescribedError {
const chain: Error[] = []
const seen = new Set<unknown>()
let current: unknown = error
while (current instanceof Error && !seen.has(current) && chain.length < 10) {
seen.add(current)
chain.push(current)
current = current.cause
}
if (chain.length === 0) {
const normalized = toError(error)
return { name: normalized.name, message: redactBoundParameters(normalized.message) }
}
const deepest = chain[chain.length - 1] as Error & Record<string, unknown>
const asString = (value: unknown): string | undefined =>
typeof value === 'string' ? value : undefined
const code = asString(deepest.code)
const errno = asString(deepest.errno)
const syscall = asString(deepest.syscall)
return {
name: deepest.name,
message: redactBoundParameters(deepest.message),
...(code ? { code } : {}),
...(errno ? { errno } : {}),
...(syscall ? { syscall } : {}),
...(chain.length > 1
? { causeChain: chain.map((e) => `${e.name}: ${redactBoundParameters(e.message)}`) }
: {}),
}
}
/** Replaces a driver-appended `params: <values>` tail with a redaction marker. */
function redactBoundParameters(message: string): string {
const index = message.indexOf('\nparams:')
return index === -1 ? message : `${message.slice(0, index)}\nparams: [redacted]`
}
/**
* First link in the `.cause` chain (including `error` itself) matching
* `predicate`. Lets a caller recover a specific wrapped error class instead of
* re-parsing a formatted message. Cycle-safe and depth-bounded, mirroring
* {@link describeError}'s walk.
*/
export function findCause<T>(
error: unknown,
predicate: (value: unknown) => value is T
): T | undefined {
const seen = new Set<unknown>()
let current: unknown = error
while (current instanceof Error && !seen.has(current) && seen.size < 10) {
seen.add(current)
if (predicate(current)) return current
current = current.cause
}
return undefined
}
function readPgErrorField(error: unknown, field: string): string | undefined {
const seen = new Set<unknown>()
let current: unknown = error
while (current !== undefined && current !== null) {
if (seen.has(current)) {
break
}
seen.add(current)
if (typeof current === 'object') {
const value = (current as Record<string, unknown>)[field]
if (typeof value === 'string') {
return value
}
}
if (current instanceof Error && current.cause !== undefined) {
current = current.cause
continue
}
break
}
return undefined
}