observability: backend-agnostic error-capture seam (Phase 1, inert until SENTRY_DSN)
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
cardMatchesEvent,
|
||||
type DoorIngestEvent,
|
||||
} from '@/lib/door-log-logic'
|
||||
import { captureException } from '@/lib/observability/capture'
|
||||
|
||||
type Admin = ReturnType<typeof createAdminClient>
|
||||
|
||||
@@ -115,6 +116,7 @@ export async function ingestEvents(
|
||||
// Anything else (e.g. a transient DB error) is surfaced via console so
|
||||
// the row is not silently lost; the next poll re-attempts it.
|
||||
console.error('[door-ingest] insert failed:', error.message)
|
||||
captureException(error, { surface: 'door/ingest', tags: { stage: 'insert' } })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// unique index makes duplicate enqueues a no-op via ignoreDuplicates.
|
||||
|
||||
import type { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { captureException } from '@/lib/observability/capture'
|
||||
|
||||
type AdminClient = ReturnType<typeof createAdminClient>
|
||||
|
||||
@@ -87,12 +88,20 @@ export async function enqueueNotification(
|
||||
)
|
||||
if (error) {
|
||||
console.error(`[notifications] enqueue ${params.type} failed:`, error.message)
|
||||
captureException(error, {
|
||||
surface: 'notifications/enqueue',
|
||||
tags: { space: params.spaceId, type: params.type },
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[notifications] enqueue ${params.type} threw:`,
|
||||
e instanceof Error ? e.message : e,
|
||||
)
|
||||
captureException(e, {
|
||||
surface: 'notifications/enqueue',
|
||||
tags: { space: params.spaceId, type: params.type, stage: 'throw' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Backend-agnostic error capture seam. Mirrors lib/email/send.ts: a one-purpose
|
||||
// transport over fetch, no SDK. Sends Sentry-format events to a self-hosted
|
||||
// GlitchTip (or any Sentry-compatible) ingest endpoint named by SENTRY_DSN.
|
||||
//
|
||||
// Inert by default: with SENTRY_DSN unset every call is a no-op, so the seam
|
||||
// can ship to prod and stay dark until the backend is provisioned. Capture is
|
||||
// always best-effort and never throws into a caller -- observability must not
|
||||
// be able to break the money path, a cron, or a request.
|
||||
import { scrubString, scrubValue } from './scrub'
|
||||
|
||||
export type CaptureLevel = 'error' | 'warning' | 'info'
|
||||
|
||||
export interface CaptureContext {
|
||||
surface?: string // e.g. 'stripe-webhook', 'cron/notifications'
|
||||
tags?: Record<string, string | number | null | undefined>
|
||||
extra?: Record<string, unknown>
|
||||
level?: CaptureLevel
|
||||
}
|
||||
|
||||
interface ParsedDsn {
|
||||
key: string
|
||||
host: string
|
||||
projectId: string
|
||||
storeUrl: string
|
||||
}
|
||||
|
||||
// DSN shape: https://<publicKey>@<host>[:port]/<projectId>
|
||||
export function parseDsn(dsn: string | undefined | null): ParsedDsn | null {
|
||||
if (!dsn) return null
|
||||
try {
|
||||
const u = new URL(dsn)
|
||||
const key = u.username
|
||||
const projectId = u.pathname.replace(/^\/+/, '')
|
||||
if (!key || !projectId) return null
|
||||
return {
|
||||
key,
|
||||
host: u.host,
|
||||
projectId,
|
||||
storeUrl: `${u.protocol}//${u.host}/api/${projectId}/store/`,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface SentryEvent {
|
||||
event_id: string
|
||||
timestamp: number
|
||||
platform: 'node'
|
||||
level: CaptureLevel
|
||||
environment: string
|
||||
logger: string
|
||||
server_name?: string
|
||||
transaction?: string
|
||||
tags?: Record<string, string>
|
||||
extra?: Record<string, unknown>
|
||||
message?: string
|
||||
exception?: { values: Array<{ type: string; value: string; stacktrace_raw?: string }> }
|
||||
}
|
||||
|
||||
function cleanTags(tags: CaptureContext['tags']): Record<string, string> | undefined {
|
||||
if (!tags) return undefined
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(tags)) {
|
||||
if (v === null || v === undefined) continue
|
||||
out[k] = scrubString(String(v))
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined
|
||||
}
|
||||
|
||||
// Pure event assembly (no I/O), so it is unit-testable. Everything that can
|
||||
// carry a secret -- message, exception value/stack, tags, extra -- is scrubbed
|
||||
// here, NOT at the call site, so no caller can accidentally leak.
|
||||
export function buildEvent(
|
||||
input: { error?: unknown; message?: string },
|
||||
ctx: CaptureContext = {},
|
||||
now: number = Date.now(),
|
||||
): SentryEvent {
|
||||
const event: SentryEvent = {
|
||||
event_id: cryptoRandomHex32(),
|
||||
timestamp: Math.floor(now / 1000),
|
||||
platform: 'node',
|
||||
level: ctx.level ?? 'error',
|
||||
environment: process.env.NODE_ENV ?? 'development',
|
||||
logger: 'hsm',
|
||||
}
|
||||
if (ctx.surface) event.transaction = scrubString(ctx.surface)
|
||||
const tags = cleanTags(ctx.tags)
|
||||
if (tags) event.tags = tags
|
||||
if (ctx.extra) event.extra = scrubValue(ctx.extra)
|
||||
|
||||
if (input.error !== undefined) {
|
||||
const err = input.error
|
||||
const type = err instanceof Error ? err.name || 'Error' : 'NonError'
|
||||
const value =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : safeStringify(err)
|
||||
const stack = err instanceof Error && err.stack ? scrubString(err.stack) : undefined
|
||||
event.exception = { values: [{ type, value: scrubString(value), stacktrace_raw: stack }] }
|
||||
} else if (input.message !== undefined) {
|
||||
event.message = scrubString(input.message)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
function cryptoRandomHex32(): string {
|
||||
try {
|
||||
return globalThis.crypto.randomUUID().replace(/-/g, '')
|
||||
} catch {
|
||||
return Math.random().toString(16).slice(2).padEnd(32, '0').slice(0, 32)
|
||||
}
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(v) ?? String(v)
|
||||
} catch {
|
||||
return String(v)
|
||||
}
|
||||
}
|
||||
|
||||
const SEND_TIMEOUT_MS = 2500
|
||||
|
||||
async function send(event: SentryEvent): Promise<void> {
|
||||
const dsn = parseDsn(process.env.SENTRY_DSN)
|
||||
if (!dsn) return
|
||||
const controller = new AbortController()
|
||||
const t = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS)
|
||||
try {
|
||||
await fetch(dsn.storeUrl, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Sentry-Auth': `Sentry sentry_version=7, sentry_client=hsm/1.0, sentry_key=${dsn.key}`,
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
})
|
||||
} catch {
|
||||
// Best-effort: a logging-backend blip must never surface to the caller.
|
||||
} finally {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}
|
||||
|
||||
export function captureException(error: unknown, ctx: CaptureContext = {}): void {
|
||||
if (!process.env.SENTRY_DSN) return
|
||||
// Fire and forget; swallow everything.
|
||||
void send(buildEvent({ error }, { ...ctx, level: ctx.level ?? 'error' })).catch(() => {})
|
||||
}
|
||||
|
||||
export function captureMessage(message: string, ctx: CaptureContext = {}): void {
|
||||
if (!process.env.SENTRY_DSN) return
|
||||
void send(buildEvent({ message }, { ...ctx, level: ctx.level ?? 'info' })).catch(() => {})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Pure secret-scrubbing for outbound error events. Observability data must
|
||||
// never carry a credential or PII off the box, so every string that goes into
|
||||
// an event (message, exception value, tags, extra) is run through scrubString,
|
||||
// and scrubValue deep-walks objects. Kept pure + framework-free so it is unit
|
||||
// tested in isolation; the transport (capture.ts) only composes it.
|
||||
|
||||
// Order matters: structured tokens (jwt, bearer, keys) are redacted before the
|
||||
// broad email pass so a key embedded in a URL is not partially missed.
|
||||
const RULES: Array<[RegExp, string]> = [
|
||||
// JSON Web Tokens (Supabase anon/service keys are JWTs): header.payload.sig
|
||||
[/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[jwt]'],
|
||||
// Authorization: Bearer <token>
|
||||
[/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [redacted]'],
|
||||
// Stripe secret/restricted keys + webhook signing secret
|
||||
[/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]+/g, '[stripe_key]'],
|
||||
[/\bwhsec_[A-Za-z0-9]+/g, '[stripe_whsec]'],
|
||||
// Resend API key
|
||||
[/\bre_[A-Za-z0-9_]{8,}/g, '[resend_key]'],
|
||||
// Email addresses (PII)
|
||||
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[email]'],
|
||||
// Bare high-entropy hex blobs (>=32 chars, no dashes so UUIDs survive intact)
|
||||
[/\b[0-9a-fA-F]{32,}\b/g, '[hex]'],
|
||||
]
|
||||
|
||||
export function scrubString(input: string): string {
|
||||
let out = input
|
||||
for (const [re, replacement] of RULES) out = out.replace(re, replacement)
|
||||
return out
|
||||
}
|
||||
|
||||
// Deep-walk a value, scrubbing every string. Bounded depth and breadth so a
|
||||
// pathological object can never make scrubbing itself a hot loop. Cycles are
|
||||
// broken via a seen set.
|
||||
const MAX_DEPTH = 6
|
||||
const MAX_KEYS = 100
|
||||
|
||||
export function scrubValue<T>(value: T, depth = 0, seen = new WeakSet<object>()): T {
|
||||
if (typeof value === 'string') return scrubString(value) as unknown as T
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
if (depth >= MAX_DEPTH) return '[truncated]' as unknown as T
|
||||
if (seen.has(value as object)) return '[circular]' as unknown as T
|
||||
seen.add(value as object)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, MAX_KEYS).map(v => scrubValue(v, depth + 1, seen)) as unknown as T
|
||||
}
|
||||
const out: Record<string, unknown> = {}
|
||||
let n = 0
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (n++ >= MAX_KEYS) break
|
||||
out[k] = scrubValue(v, depth + 1, seen)
|
||||
}
|
||||
return out as unknown as T
|
||||
}
|
||||
Reference in New Issue
Block a user