Error tracing
Configure one capture hook, enrich faults near the request boundary, and capture each failed flow once.
Wire a capture hook
Fault.onCapture receives the fault passed to .capture().
import { Fault } from '@thourum/fault'
Fault.onCapture = (fault) =>
Sentry.captureException(fault, { extra: fault.toJSON() })
For OpenTelemetry, the equivalent wiring can record the exception and add flattened fields. This is illustrative; use the span and flattening utility from your telemetry integration.
Fault.onCapture = (fault) => {
span.recordException(fault)
span.setAttributes(flatten(fault.toJSON()))
}
toJSON() output may contain sensitive metadata; redact it before shipping.
Capture once at the edge
Call .capture() once where the result leaves the request, job, or command boundary. Use mapErr to attach context to the result's new fault, then orInspect to capture without changing the result.
Add request context before capture. Hash identifiers or omit them when the raw value is sensitive.
return runOperation()
.mapErr((fault) => fault.withMetadata({ userId: hash(userId), requestId }))
.orInspect((fault) => fault.capture())
Do not capture the same fault in each helper and again at the edge. Lower layers can add a cause, tag, details, or metadata and return it for one final capture.
Payment flow
This flow retries connection failures, validates each boundary, adds safe request context, and captures once.
export function chargeUser(userId: string, amountCents: number) {
return retry(
() => safeDb(db.query.users.findFirst({ where: eq(users.id, userId) })),
{ times: 3, delayMs: 200, when: (f) => f.tag === 'CONNECTION_ERROR' }, // never retry constraint/logic failures
)
.andThen((user) =>
user
? ok(user)
: err(ServiceError('NOT_FOUND', `user ${userId} not found`)),
)
.andThen((user) =>
user.paidAt
? err(ServiceError('CONFLICT', 'already paid'))
: ok(user),
)
.andThen((user) =>
safeFetchJSON('https://api.stripe.com/v1/payment_intents', {
method: 'POST',
headers: { Authorization: `Bearer ${stripeKey}` },
body: new URLSearchParams({
amount: String(amountCents),
currency: 'eur',
customer: user.stripeId,
}),
})
.andThen(safeZodParse(paymentIntentSchema))
.map((intent) => ({ user, intent })),
)
.andThen(({ user, intent }) =>
intent.status === 'failed'
? err(
ServiceError('PAYMENT_FAILED', 'stripe declined').withMetadata({
intentId: intent.id,
}),
)
: ok({ user, intent }),
)
.andInspect(({ user, intent }) =>
logger.info('payment ok', {
userId: hash(user.id),
intent: intent.id,
amountCents,
}),
)
.mapErr((fault) => fault.withMetadata({ userId: hash(userId), amountCents }))
.orInspect((fault) => fault.capture())
}
safeDb converts database failures to faults, while retry only repeats connection failures.
ServiceError represents expected missing-user and already-paid states in the same result pipeline.
safeFetchJSON and safeZodParse cover the external response and its payload before business checks run.
- The failed intent adds its provider identifier; the success branch logs a hashed user identifier.
- The final
mapErr adds request metadata; orInspect calls .capture() once for every failed path.
Post creation flow
The same edge pattern covers validation, upload, database, and webhook failures without capturing inside each step.
export function createPost(authorId: string, raw: unknown) {
return safeZodParse(postInput, raw)
.asyncAndThen(({ text, file }) =>
uploadToS3(file).map((imageKey) => ({ text, imageKey })),
)
.andThen(({ text, imageKey }) =>
safeDb(
db.insert(posts).values({ authorId, text, imageKey }).returning(),
).map((rows) => rows[0]!),
)
.andCheck((post) =>
retry(
() =>
safeFetch(webhookUrl, {
method: 'POST',
body: JSON.stringify({ event: 'post.created', id: post.id }),
}),
{
times: 5,
delayMs: 500,
when: (fault) =>
fault.tag === 'NETWORK_ERROR' ||
fault.tag === 'INTERNAL_ERROR',
},
),
)
.mapErr((fault) => fault.withMetadata({ authorId: hash(authorId) }))
.orInspect((fault) => fault.capture())
}
uploadToS3 may log its local failure for diagnostics, but the final orInspect is the single capture point. It hashes the author identifier before attaching it to the fault.