observability: backend-agnostic error-capture seam (Phase 1, inert until SENTRY_DSN)
This commit is contained in:
@@ -50,6 +50,13 @@ EMAIL_FROM=
|
||||
# openssl rand -hex 32
|
||||
CRON_SECRET=
|
||||
|
||||
# Error monitoring (optional). Sentry-format DSN for a self-hosted GlitchTip
|
||||
# (or any Sentry-compatible) ingest endpoint. Unset = the capture seam no-ops
|
||||
# (no events sent), so the app ships dark until the backend is provisioned.
|
||||
# Server-side capture only (money path, crons, server actions); no client SDK.
|
||||
# Example: https://<publicKey>@glitchtip.hackerspace.sh/1
|
||||
SENTRY_DSN=
|
||||
|
||||
# Docker build flag (only when building the standalone image)
|
||||
# Set to "1" in the Dockerfile so next.config.mjs emits standalone output.
|
||||
# DOCKER_BUILD=1
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { scrubString, scrubValue } from '@/lib/observability/scrub'
|
||||
import { parseDsn, buildEvent } from '@/lib/observability/capture'
|
||||
|
||||
describe('scrubString', () => {
|
||||
it('redacts email addresses', () => {
|
||||
expect(scrubString('to alice@example.com now')).toBe('to [email] now')
|
||||
})
|
||||
|
||||
it('redacts stripe secret + restricted keys and webhook secret', () => {
|
||||
expect(scrubString('sk_test_abc123XYZ')).toBe('[stripe_key]')
|
||||
expect(scrubString('sk_live_DEADbeef00')).toBe('[stripe_key]')
|
||||
expect(scrubString('rk_test_zzz999')).toBe('[stripe_key]')
|
||||
expect(scrubString('whsec_qWErTy123')).toBe('[stripe_whsec]')
|
||||
})
|
||||
|
||||
it('redacts a Resend key', () => {
|
||||
expect(scrubString('re_AbCd1234efgh')).toBe('[resend_key]')
|
||||
})
|
||||
|
||||
it('redacts JWTs (supabase keys) and bearer tokens', () => {
|
||||
expect(scrubString('eyJhbG.eyJzdWIiOiIx.sIgnAtuRe-_0')).toBe('[jwt]')
|
||||
expect(scrubString('Authorization: Bearer abc.def_123')).toBe('Authorization: Bearer [redacted]')
|
||||
})
|
||||
|
||||
it('redacts high-entropy hex but leaves UUIDs intact', () => {
|
||||
expect(scrubString('key=0123456789abcdef0123456789abcdef')).toBe('key=[hex]')
|
||||
const uuid = 'f56d5f3a-8877-4d56-a6bc-c572474935a8'
|
||||
expect(scrubString(`space ${uuid}`)).toBe(`space ${uuid}`)
|
||||
})
|
||||
|
||||
it('leaves benign text unchanged', () => {
|
||||
expect(scrubString('handler error: timeout after 6s')).toBe('handler error: timeout after 6s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrubValue', () => {
|
||||
it('deep-walks objects and arrays', () => {
|
||||
const out = scrubValue({ a: 'x@y.com', b: { c: ['re_abcd1234', 'ok'] } })
|
||||
expect(out).toEqual({ a: '[email]', b: { c: ['[resend_key]', 'ok'] } })
|
||||
})
|
||||
|
||||
it('breaks cycles without throwing', () => {
|
||||
const o: Record<string, unknown> = { name: 'safe' }
|
||||
o.self = o
|
||||
const out = scrubValue(o) as Record<string, unknown>
|
||||
expect(out.name).toBe('safe')
|
||||
expect(out.self).toBe('[circular]')
|
||||
})
|
||||
|
||||
it('passes through non-string scalars', () => {
|
||||
expect(scrubValue({ n: 5, b: true, z: null })).toEqual({ n: 5, b: true, z: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDsn', () => {
|
||||
it('parses a well-formed DSN into a store URL', () => {
|
||||
const p = parseDsn('https://pubkey@glitchtip.example.com/7')
|
||||
expect(p).toEqual({
|
||||
key: 'pubkey',
|
||||
host: 'glitchtip.example.com',
|
||||
projectId: '7',
|
||||
storeUrl: 'https://glitchtip.example.com/api/7/store/',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for empty/garbage/missing parts', () => {
|
||||
expect(parseDsn(undefined)).toBeNull()
|
||||
expect(parseDsn('')).toBeNull()
|
||||
expect(parseDsn('not a url')).toBeNull()
|
||||
expect(parseDsn('https://glitchtip.example.com/7')).toBeNull() // no key
|
||||
expect(parseDsn('https://pubkey@glitchtip.example.com/')).toBeNull() // no project
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEvent', () => {
|
||||
it('builds an exception event, scrubbing message + stack', () => {
|
||||
const err = new Error('charge failed for alice@example.com')
|
||||
err.stack = 'Error: charge failed for alice@example.com\n at x (sk_test_leak123)'
|
||||
const ev = buildEvent({ error: err }, { surface: 'stripe-webhook', tags: { space: 'abc' } }, 1_700_000_000_000)
|
||||
expect(ev.platform).toBe('node')
|
||||
expect(ev.level).toBe('error')
|
||||
expect(ev.timestamp).toBe(1_700_000_000)
|
||||
expect(ev.transaction).toBe('stripe-webhook')
|
||||
expect(ev.tags).toEqual({ space: 'abc' })
|
||||
expect(ev.exception?.values[0].type).toBe('Error')
|
||||
expect(ev.exception?.values[0].value).toBe('charge failed for [email]')
|
||||
expect(ev.exception?.values[0].stacktrace_raw).toContain('[email]')
|
||||
expect(ev.exception?.values[0].stacktrace_raw).toContain('[stripe_key]')
|
||||
expect(ev.event_id).toMatch(/^[0-9a-f]{32}$/)
|
||||
})
|
||||
|
||||
it('builds a message event and scrubs extra', () => {
|
||||
const ev = buildEvent(
|
||||
{ message: 'prefs lookup failed' },
|
||||
{ level: 'warning', extra: { recipient: 'bob@x.io' } },
|
||||
)
|
||||
expect(ev.message).toBe('prefs lookup failed')
|
||||
expect(ev.level).toBe('warning')
|
||||
expect(ev.extra).toEqual({ recipient: '[email]' })
|
||||
expect(ev.exception).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { pollConnectionLog, ingestEvents, type IngestConn } from '@/lib/door/ingest'
|
||||
import { captureException, captureMessage } from '@/lib/observability/capture'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -50,7 +51,10 @@ export async function POST(req: NextRequest) {
|
||||
.eq('adapter', 'native_heatsync')
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(MAX_CONNECTIONS)
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) {
|
||||
captureException(error, { surface: 'cron/door-ingest', tags: { stage: 'load-connections' } })
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Poll connections concurrently: each connection is independent, and the
|
||||
// executor's per-call timeout means a sequential loop would take the SUM of
|
||||
@@ -65,6 +69,12 @@ export async function POST(req: NextRequest) {
|
||||
const poll = await pollConnectionLog(admin, c)
|
||||
if (!poll.ok) {
|
||||
console.error(`[door-ingest] connection ${c.id}: ${poll.detail}`)
|
||||
captureMessage('door poll failed', {
|
||||
surface: 'cron/door-ingest',
|
||||
level: 'warning',
|
||||
tags: { space: c.space_id, connection: c.id },
|
||||
extra: { detail: poll.detail },
|
||||
})
|
||||
return { failed: true, inserted: 0, resolved: 0 }
|
||||
}
|
||||
const res = await ingestEvents(admin, c.space_id, c.id, poll.events)
|
||||
@@ -79,6 +89,7 @@ export async function POST(req: NextRequest) {
|
||||
if (r.status === 'rejected') {
|
||||
failed++
|
||||
console.error('[door-ingest] connection threw:', r.reason instanceof Error ? r.reason.message : r.reason)
|
||||
captureException(r.reason, { surface: 'cron/door-ingest', tags: { stage: 'poll' } })
|
||||
continue
|
||||
}
|
||||
if (r.value.failed) failed++
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type PrefMap,
|
||||
type NotificationCategory,
|
||||
} from '@/lib/notifications-prefs-logic'
|
||||
import { captureException, captureMessage } from '@/lib/observability/capture'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -63,7 +64,10 @@ export async function POST(req: NextRequest) {
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(CANDIDATES)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) {
|
||||
captureException(error, { surface: 'cron/notifications', tags: { stage: 'candidates' } })
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Fair drain: round-robin the oldest-first candidates across spaces so one
|
||||
// tenant's burst can't head-of-line-block others. Fairness binds only under
|
||||
@@ -110,7 +114,14 @@ export async function POST(req: NextRequest) {
|
||||
.in('member_id', memberIds)
|
||||
// Fail open: if the prefs lookup errors, send everything rather than risk
|
||||
// silently dropping a wanted (e.g. dues-failure) email on a transient blip.
|
||||
if (prefErr) console.error('[cron/notifications] prefs lookup failed:', prefErr.message)
|
||||
if (prefErr) {
|
||||
console.error('[cron/notifications] prefs lookup failed:', prefErr.message)
|
||||
captureMessage('prefs lookup failed (failing open, sending all)', {
|
||||
surface: 'cron/notifications',
|
||||
level: 'warning',
|
||||
extra: { error: prefErr.message },
|
||||
})
|
||||
}
|
||||
for (const p of prefRows ?? []) {
|
||||
const mid = p.member_id as string
|
||||
if (!prefsByMember.has(mid)) prefsByMember.set(mid, {})
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
getSpaceName as readSpaceName,
|
||||
buildManageUrl,
|
||||
} from '@/lib/notifications/enqueue'
|
||||
import { captureException } from '@/lib/observability/capture'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -76,6 +77,10 @@ export async function POST(
|
||||
return NextResponse.json({ received: true, duplicate: true })
|
||||
}
|
||||
console.error('[stripe webhook] dedupe insert failed:', dedupe.error.message)
|
||||
captureException(dedupe.error, {
|
||||
surface: 'stripe-webhook',
|
||||
tags: { space: spaceId, eventType: event.type, stage: 'dedupe' },
|
||||
})
|
||||
return NextResponse.json({ error: 'Webhook processing error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -315,6 +320,10 @@ export async function POST(
|
||||
// than silently short-circuited as a duplicate (no lost events).
|
||||
await admin.from('stripe_webhook_events').delete().eq('event_id', event.id)
|
||||
console.error('[stripe webhook] handler error:', e instanceof Error ? e.message : e)
|
||||
captureException(e, {
|
||||
surface: 'stripe-webhook',
|
||||
tags: { space: spaceId, eventId: event.id, eventType: event.type, stage: 'handler' },
|
||||
})
|
||||
return NextResponse.json({ error: 'Webhook handler error' }, { status: 500 })
|
||||
}
|
||||
|
||||
|
||||
@@ -883,6 +883,32 @@ the member's own notification history. No new permission code.
|
||||
|
||||
---
|
||||
|
||||
### Observability / error monitoring (Phase 1: app-side seam)
|
||||
|
||||
A backend-agnostic capture seam, modeled on the `lib/email/send.ts` transport
|
||||
pattern (a one-purpose fetch transport, no SDK). `lib/observability/capture.ts`
|
||||
sends Sentry-format events to a self-hosted GlitchTip (or any Sentry-compatible)
|
||||
ingest endpoint named by `SENTRY_DSN`. It is **inert by default**: with
|
||||
`SENTRY_DSN` unset every `captureException`/`captureMessage` is a no-op, so the
|
||||
seam ships to prod dark until the backend is provisioned (Phase 2). Capture is
|
||||
always best-effort and never throws into a caller (a logging-backend blip must
|
||||
not be able to break the money path, a cron, or a request); the send is
|
||||
fire-and-forget with a short abort timeout. All event content (message,
|
||||
exception value + stack, tags, extra) is run through the pure, unit-tested
|
||||
`lib/observability/scrub.ts` (redacts emails, JWTs, Bearer tokens, Stripe
|
||||
`sk_`/`rk_`/`whsec_` keys, Resend `re_` keys, and bare high-entropy hex while
|
||||
leaving dashed UUIDs intact) so no credential or PII leaves the box.
|
||||
|
||||
Coverage is two-pronged: `instrumentation.ts` `onRequestError` is the
|
||||
framework-wide net for anything thrown out of a route handler, server
|
||||
component, or server action (broad server-action coverage with no per-action
|
||||
wiring); swallowed best-effort failures that never reach that hook are captured
|
||||
manually at their sites: the Stripe webhook (dedupe + handler 500), both crons
|
||||
(`/api/cron/notifications`, `/api/cron/door-ingest`), the central
|
||||
`enqueueNotification`, and the door-ingest insert. Server-side only (no client
|
||||
SDK / bundle impact) in this phase. Phase 2 stands up the GlitchTip backend on
|
||||
the droplet and sets the DSN.
|
||||
|
||||
## 13. Known Limitations
|
||||
|
||||
1. **Test suite** - Extensive Vitest unit coverage of pure logic (`__tests__/`, run with `pnpm test`, hermetic), Playwright smoke e2e (`e2e/`), and a DB-backed integration suite (`integration/`, run with `pnpm test:integration` against a real Postgres; self-skips without one) that drives the shipped SQL through `psql` and the route handlers through their actual exports: the advisory-lock signup/cancel RPCs (045), the equipment exclusion constraint (042), the self-change trigger + `members_update` WITH CHECK (043/044), the billing/notification idempotency invariants, the Stripe webhook end to end (real signed events, vault secret resolution, replay, out-of-order period guard), and the RLS-layer privilege-status gate (046). Remaining gap: server-action orchestration beyond those critical paths still has no integration coverage, and e2e is mostly page-render checks.
|
||||
|
||||
+18
-2
@@ -14,7 +14,7 @@ Append-only. Newest entries on top. Keep each entry to one screen.
|
||||
|
||||
**Backlog (priority order for the next session):**
|
||||
1. **OWNER-GATED spine validation EXECUTION.** The runbook is written (`docs/SPINE_VALIDATION.md`); the owner now executes Part A (provision) + Part B (prove the dues cycle / notifications / `/me` / door). The agent cannot (external accounts + live session + on-droplet env/cron). This converts shipped -> proven.
|
||||
2. **Production observability.** No error monitoring; money path (Stripe webhook) + both crons + every server action only `console.error`. Add a Sentry-equivalent (design-first on the tool; likely self-hosted GlitchTip).
|
||||
2. **Production observability.** Phase 1 (app-side capture seam) is BUILT + inert, pending deploy (pass 72): backend-agnostic no-SDK fetch seam -> `SENTRY_DSN`, secret-scrubbed, covers the money path + both crons + all server actions (via `onRequestError`). Remaining = Phase 2 (the box): swap + self-hosted GlitchTip on the existing supabase-db + the DSN. Owner chose self-hosted GlitchTip.
|
||||
3. **Owner browser review** (auth-gated, agent cannot): `/me`, the Dues tab, AND the new door surfaces (`/door/manage` inbound panel, `/door/buttons` builder, `/doors` Actions); plus a real-device check of the mobile flex-row fixes with a long name.
|
||||
4. **Extend the integration harness**: payments link/import, presence, forms `submitForm`/`linkSubmissionsByEmail`. (Door RLS, api-buttons RLS, and the permission-escalation guard are now covered.)
|
||||
5. Defense-in-depth noted, not done: `.eq('space_id')` on equipment-cancel / door-slot writes (parent already space-checked); webhook signing secret shown pre-populated to admins (retrievable by design); door-ingest parallel poll bursts up to 50 concurrent DB calls (fine for a small fleet, bounded by `MAX_CONNECTIONS`). Deferred until product need: outbound webhooks epic, server-side search, multi-space-per-user, Zeffy/Venmo live APIs.
|
||||
@@ -25,13 +25,29 @@ Append-only. Newest entries on top. Keep each entry to one screen.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-21 (pass 72): Observability Phase 1 (app-side capture seam, inert)
|
||||
|
||||
Branch `main`. Took backlog item 2. Design-first: owner chose self-hosted GlitchTip (+ swap), so the app-side capture is a backend-agnostic seam pointed at a DSN. Implementation call (made, not asked): a **no-SDK fetch envelope sender** mirroring `lib/email/send.ts`, NOT `@sentry/nextjs` (keeps the client bundle + `next.config` untouched, zero dependency/supply-chain). NOT yet deployed; awaiting owner go.
|
||||
|
||||
**Shipped (local):**
|
||||
- `lib/observability/scrub.ts` (pure): redacts emails, JWTs, Bearer tokens, Stripe `sk_`/`rk_`/`whsec_`, Resend `re_`, bare 32+ hex; leaves dashed UUIDs intact. Deep-walks objects (depth/breadth-capped, cycle-safe).
|
||||
- `lib/observability/capture.ts`: `parseDsn` + pure `buildEvent` (scrubbed) + `captureException`/`captureMessage`. Inert when `SENTRY_DSN` unset (no-op). Best-effort fire-and-forget POST to `…/api/<projectId>/store/` with a 2.5s abort; never throws into a caller.
|
||||
- `instrumentation.ts` `onRequestError` -> the broad net for any thrown route/RSC/server-action error (no per-action wiring). Manual capture at the swallow/500 sites that never reach it: Stripe webhook (dedupe + handler), `/api/cron/notifications` (candidates + prefs), `/api/cron/door-ingest` (load + poll-fail + threw), `enqueueNotification` (central), `lib/door/ingest.ts` insert.
|
||||
- `__tests__/observability.test.ts` (+13). Docs: `.env.example` `SENTRY_DSN`; ARCHITECTURE observability subsection.
|
||||
|
||||
**Gate:** `pnpm test` **639** (was 626; +13) green; `pnpm build` clean. Server-side capture only (no client SDK). No migration, no schema, no RLS.
|
||||
|
||||
**Inert in prod until Phase 2:** with `SENTRY_DSN` unset nothing is sent, so this is a safe deploy. **Phase 2 (the box, not started):** add a 2GB swapfile (droplet is RAM-tight: 3.8G total, ~865M free, NO swap, 2 vCPU, 7 supabase containers + the app), stand up GlitchTip (web+worker+redis, memory-capped) with `DATABASE_URL` -> a new DB on the existing `supabase-db` (no 2nd Postgres), nginx subdomain + cert for the UI, generate the DSN, set it in `.env.production`, verify a test capture. RAM-watch throughout.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-21 (pass 71): Spine provisioning A1+A2 executed on prod (owner-supported)
|
||||
|
||||
Branch `main`. Owner asked the agent to provision directly; did it over SSH on the Droplet. No code change, no deploy (config + crontab on the box only; the sole repo edit is this HANDOFF).
|
||||
|
||||
**Real prod layout (docs drift; corrected in memory `prod-ops-access`).** App is `/opt/hackerspace-ops` (NOT `/opt/hackerspace`), runs as systemd `hackerspace-app` via `next start` (native Node, NOT docker compose), env file `/opt/hackerspace-ops/.env.production` (`deploy:deploy 0600`), `deploy` has passwordless sudo. Supabase runs in docker on the same box.
|
||||
|
||||
**A1 Resend (DONE + proven).** `.env.production` was missing all three spine vars. Backed it up, added `RESEND_API_KEY` (from `secrets/base.env`) + `EMAIL_FROM="hackerspace.sh <noreply@hackerspace.sh>"`. The key is a restricted send-only key (can't list domains), so proved the domain another way: a direct `POST https://api.resend.com/emails` from `noreply@hackerspace.sh` returned a message id -> domain verified, transport works.
|
||||
**A1 Resend (DONE + proven, incl. the full pipeline).** `.env.production` was missing all three spine vars. Backed it up, added `RESEND_API_KEY` (from `secrets/base.env`) + `EMAIL_FROM="hackerspace.sh <noreply@hackerspace.sh>"`. The key is a restricted send-only key (can't list domains), so proved the domain via a direct `POST https://api.resend.com/emails` (returned a message id). Then proved the FULL app outbox pipeline: injected one `pending` `notifications` row (member_id NULL = always-send, space HeatSync Labs) via the service client and ran the authed dispatcher on the box -> `{scanned:1,sent:1}`, row reached `status=sent`; deleted the test row (notifications back to 0). Remaining notification gap is only the ENQUEUE side on real domain events (Part B, needs a live session).
|
||||
|
||||
**A2 CRON_SECRET + crontabs (DONE + proven).** Generated `CRON_SECRET` on the box, added to `.env.production`; installed BOTH crontab lines in the `deploy` crontab with `CRON_SECRET=` defined inline at the top (a bare `$CRON_SECRET` in a crontab line is empty -> silent 401; the runbook example is a footgun). Restarted `hackerspace-app`. Verified: public no-auth + wrong-bearer now **401** (were 503); authed localhost **200** (`notifications {scanned:0..}`, `door-ingest {ok:true,polled:0..}`). Outbox now drains every minute.
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Next.js instrumentation. `onRequestError` is the framework-wide hook that
|
||||
// fires for any error thrown out of a route handler, server component, or
|
||||
// server action -- so it is the broad net that gives every server action
|
||||
// error capture without per-action wiring. Swallowed best-effort failures
|
||||
// (notification fan-outs, cron soft-fails, the Stripe handler that returns 500
|
||||
// instead of throwing) do NOT reach this hook, so those are captured manually
|
||||
// at their sites. Inert until SENTRY_DSN is set (the seam no-ops).
|
||||
import { captureException } from '@/lib/observability/capture'
|
||||
|
||||
export async function register(): Promise<void> {
|
||||
// No global init: the capture seam is a stateless fetch transport.
|
||||
}
|
||||
|
||||
export async function onRequestError(
|
||||
err: unknown,
|
||||
request: { path?: string; method?: string },
|
||||
context: { routerKind?: string; routePath?: string; routeType?: string },
|
||||
): Promise<void> {
|
||||
captureException(err, {
|
||||
surface: 'request',
|
||||
tags: {
|
||||
route: context?.routePath,
|
||||
routeType: context?.routeType,
|
||||
method: request?.method,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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