import { SafeError } from '@exodus/errors'
try {
throw new Error('Something went wrong')
} catch (e) {
const safeError = SafeError.from(e)
// It's now safe to report or log the error, even to a remote server.
console.error({
name: safeError.name, // Sanitized error name.
code: safeError.code, // Optional error code (if present).
hint: safeError.hint, // Optional error hint (if present).
stack: safeError.stack, // Sanitized stack trace.
timestamp: safeError.timestamp, // When the error occurred.
})
}
In large codebases, errors can be thrown from anywhere, making it impossible to audit every error message for sensitive information. A single error containing sensitive data could potentially expose user information. Centralizing error handling with SafeError
makes it possible to enforce security and consistency across the board, by ensuring:
- Controlled Error Flow: All errors go through a single, well-tested sanitization layer before they hit error tracking systems.
- Enforceable Security: Error handling can be owned through codeowners and covered by tests, so nothing slips through unnoticed.
In addition to enforcing these practices, SafeError
includes a few key design decisions that make it safer and more reliable than native Error objects:
-
Message Sanitization: The
message
property from built-in Errors is intentionally omitted as it often contains sensitive information. Instead, ahint
property is used that contains only sanitized, non-sensitive information. -
Native Stack Parsing: The library uses the
Error.prepareStackTrace
API to parse stack traces, providing consistent and reliable stack trace information across different JavaScript environments. -
Immutability: Once created, a
SafeError
instance cannot be modified, preventing tampering with error data. -
Safe Serialization: The
toJSON
method ensures safe serialization for logging or sending to error tracking services.
Parsing/sanitization of error messages is unreliable and the cost of failure is potential loss of user funds and permanent reputation damage.
Unfortunately, the built-in .stack
property is mutable and outside of our control. Instead, we use the Error.prepareStackTrace
API, which enables us to make sure we access the actual call stack and not a cached err.stack
value that may have already been consumed and modified. We then parse it into a structured format that we can safely sanitize and control. This approach provides consistent, reliable stack traces across different environments (currently supporting both V8 and Hermes).
That likely means that something accesses error.stack
before the Safe Error constructor had a chance to apply the custom Error.prepareStackTrace
handler. This could be React, a high-level error handler, or any other framework. error.stack
is computed only on the first access, so the custom handler won’t be called on subsequent attempts (see the stack.test.js
for a quick demo).
If you can identify the exact place where .stack
is accessed, consider capturing the stack trace explicitly like this:
import { captureStackTrace, SafeError } from '@exodus/errors'
try {
// the devil's work
} catch (e) {
captureStackTrace(e)
void e.stack // Intentionally access the property to "break" it here.
SafeError.from(e).stack // A non-empty string!
// 🎉 Congrats — you just (hopefully) saved hours of debugging! The custom stack trace parsing logic now works because the stack was captured explicitly above.
}