Files
accounted/lib/observability/sink.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

232 lines
9.4 KiB
TypeScript

/**
* Provider-agnostic observability sink.
*
* WHY THIS EXISTS
* ---------------
* This codebase has no error tracking. Everything goes to `console.*` and
* whatever Vercel keeps in its short log retention window; nothing aggregates,
* nothing alerts, and none of the 16 scheduled cron jobs report a failure
* anywhere a human will see it. The observable cost is bugs that ran broken
* for every user until a customer wrote in.
*
* This module is the seam. Call sites (mainly `lib/logger.ts`) talk to a small
* stable interface; a vendor adapter is registered once at startup. Until an
* adapter is registered the sink is a no-op, so nothing changes today and the
* self-hosted build stays free of a third-party runtime dependency.
*
* WHAT A VENDOR ADAPTER MUST IMPLEMENT
* ------------------------------------
* Provide an object satisfying `ObservabilitySink` and register it once, from
* a server-side init path, before any request is served:
*
* registerObservabilitySink({
* name: 'my-vendor',
* captureException(error, context) { ... },
* captureMessage(message, level, context) { ... },
* async flush(timeoutMs) { return true },
* })
*
* Contract the adapter must honour:
*
* - `captureException(error, context)` and `captureMessage(...)` are
* SYNCHRONOUS and MUST NOT THROW and MUST NOT return a promise the caller
* is expected to await. They are called from logging paths that run inside
* request handlers, cron loops and the bookkeeping engine; a slow or
* throwing sink would become an outage in the accounting path. Queue
* internally and ship in the background. (The wrappers in this file also
* guard with try/catch, but do not rely on that.)
* - `error` is NEVER a live `Error` instance. It arrives already serialized
* and redacted as `{ name, message, stack?, code? }` (or an arbitrary
* redacted value when a non-Error was thrown). Adapters that want a real
* stack should reconstruct it from `error.stack`.
* - `context` is a flat bag of ALREADY-REDACTED primitives/objects. Do not
* re-fetch the original values from anywhere. Reserved keys the adapter
* should map onto vendor concepts rather than dumping as extras:
* release release / version tag (git sha, see `getRelease()`)
* environment 'production' | 'preview' | 'development' | custom
* runtime 'server' | 'client'
* module logger module name, e.g. 'api/invoice.send'
* operation route / cron operation name
* requestId correlation id, also on the X-Request-Id response header
* companyId tenant id (safe: an opaque uuid, not PII)
* userId auth user id (opaque uuid)
* logMessage the human-readable log line the event came from
* alert true when the call site explicitly demanded paging
* cron true when the event came from a scheduled job
* Everything else is free-form structured context; treat it as extras.
* - `flush(timeoutMs)` resolves `true` when the queue drained, `false` on
* timeout. It must never reject. Serverless functions freeze immediately
* after the response, so an adapter that buffers MUST implement this.
* - The adapter is responsible for its own sampling, rate limiting and
* grouping. This layer deliberately does not dedupe: suppressing the first
* occurrence of a failure is how outages stay invisible.
*
* GDPR NOTE (non-negotiable)
* --------------------------
* Every public entry point in this file runs `redact()` from
* `./redact` before the sink sees anything. That is a structural guarantee,
* not a convention: there is no code path from application data to a vendor
* that skips the personnummer regex and the key denylist. Do not add one, and
* do not "optimise away" the second redaction pass on records the logger has
* already cleaned (redaction is idempotent and the cost is on the error path
* only).
*/
import { redact, redactString } from './redact'
/** Severity as the sink sees it. Maps onto every vendor's level vocabulary. */
export type ObservabilityLevel = 'debug' | 'info' | 'warning' | 'error'
/** Flat bag of already-redacted structured fields. See the header for reserved keys. */
export type ObservabilityContext = Record<string, unknown>
export interface ObservabilitySink {
/** Adapter name, used by `isObservabilityConfigured()` and diagnostics. */
readonly name: string
/** Report a failure. Synchronous, must not throw. */
captureException(error: unknown, context: ObservabilityContext): void
/** Report a noteworthy event without an Error object. Synchronous, must not throw. */
captureMessage(message: string, level: ObservabilityLevel, context: ObservabilityContext): void
/** Drain the queue. Resolves false on timeout; must never reject. */
flush(timeoutMs?: number): Promise<boolean>
}
/**
* Default implementation: drops everything on the floor.
*
* Deliberately silent rather than console-logging, because the logger has
* already written the same record to the console by the time the sink is
* called. Duplicating it here would double every error line.
*/
export const noopSink: ObservabilitySink = {
name: 'noop',
captureException() {},
captureMessage() {},
flush: async () => true,
}
// Module-level singleton, same pattern as `lib/events/bus.ts`.
let activeSink: ObservabilitySink = noopSink
/**
* Register the vendor adapter. Call once, from a server-side init path, before
* traffic is served. A second call replaces the first (last one wins), which
* keeps dev hot-reload from stacking adapters.
*/
export function registerObservabilitySink(sink: ObservabilitySink): void {
activeSink = sink
}
export function getObservabilitySink(): ObservabilitySink {
return activeSink
}
/** False while the no-op default is installed, i.e. no provider is configured. */
export function isObservabilityConfigured(): boolean {
return activeSink !== noopSink
}
/** Restore the no-op default. Intended for tests and for disabling at runtime. */
export function resetObservabilitySink(): void {
activeSink = noopSink
}
/**
* Release tag for every event, so a spike can be attributed to a deploy from
* day one.
*
* `next.config.ts` inlines `VERCEL_GIT_COMMIT_SHA` into `NEXT_PUBLIC_BUILD_ID`
* at build time (it powers the DeployReloadPrompt and /api/version), which
* makes it the one release identifier that is already present in BOTH the
* server and the client bundle. Reading the same value here means the sink,
* the reload prompt and /api/version all agree on what "this deploy" is.
* `process.env.NEXT_PUBLIC_BUILD_ID` must stay written out literally: Next
* does a textual substitution on it, so a computed lookup would not be
* replaced. `VERCEL_GIT_COMMIT_SHA` is the runtime fallback for server-only
* contexts that were not built by Next (scripts, standalone workers).
*
* Returns null on dev and self-hosted builds, where there is no release to tag.
*/
export function getRelease(): string | null {
const override = process.env.OBSERVABILITY_RELEASE
if (override) return override
const buildId = process.env.NEXT_PUBLIC_BUILD_ID
if (buildId) return buildId
const sha = process.env.VERCEL_GIT_COMMIT_SHA
return sha ? sha : null
}
/** production | preview | development, or an explicit override. */
export function getEnvironment(): string {
return (
process.env.OBSERVABILITY_ENVIRONMENT ||
process.env.VERCEL_ENV ||
process.env.NODE_ENV ||
'development'
)
}
/**
* Redact caller-supplied context, then stamp the reserved fields.
*
* The reserved fields are applied AFTER the spread so a caller cannot
* accidentally (or maliciously) override the release/environment tagging with
* a context key of the same name.
*/
function enrich(context: ObservabilityContext | undefined): ObservabilityContext {
const safe = (redact(context ?? {}) as ObservabilityContext) ?? {}
return {
...safe,
release: getRelease(),
environment: getEnvironment(),
runtime: typeof window === 'undefined' ? 'server' : 'client',
}
}
/**
* Report a failure to the configured provider.
*
* Redacts both the error and the context first. Never throws: a broken sink
* must not take down the code path that was merely trying to report a problem.
*/
export function captureException(error: unknown, context: ObservabilityContext = {}): void {
try {
activeSink.captureException(redact(error), enrich(context))
} catch {
// A failing provider must never surface as an application error.
}
}
/**
* Report a noteworthy event that has no Error object attached.
*
* Redacts the message and the context first. Never throws.
*/
export function captureMessage(
message: string,
level: ObservabilityLevel = 'error',
context: ObservabilityContext = {},
): void {
try {
activeSink.captureMessage(redactString(message), level, enrich(context))
} catch {
// A failing provider must never surface as an application error.
}
}
/**
* Drain the provider's queue.
*
* Serverless functions freeze the moment the response is returned, so any
* long-running path that must not lose its last events (cron handlers, queue
* workers) should await this before returning. Resolves false if the provider
* timed out or threw; never rejects.
*/
export async function flushObservability(timeoutMs = 2000): Promise<boolean> {
try {
return await activeSink.flush(timeoutMs)
} catch {
return false
}
}