Observability
Logs, errors and context shipped to Axiom through @workspace/observability — the socle, per-app wiring, canonical context keys, levels and guard-rails.
An app wired to the socle ships its createLogger output (above a per-transport level), its unhandled errors and its React crashes to Axiom, each event enriched with a shared context. The generic code lives in @workspace/observability; an app only maps its env to a config, feeds the context and wires the pieces at boot. See Wiring per app for each app.
Principles
- One logging API.
createLogger(name)from@workspace/shared/loggerstays the only way to log.registerLogTransportis the extension point; call sites never change when a transport is added. - One ingestion path, no SDK. Everything goes through
createAxiomIngest(a batchedfetchclient with no dependency). No@axiomhq/*package in any app: the player's bundle budget on old devices rules it out, and a single path keeps behaviour identical across apps. - No React, no env in the package. React 19 root handlers are plain functions typed structurally; configs are validated by
parseAxiomIngestConfigand the env → config mapping lives in each app. - Axiom is optional. No config → no transport, no ingest, nothing else changes. Tests and CI never set a token.
- No personal data by default. The context carries no identity unless an app adds
userIdexplicitly.
The socle: @workspace/observability
One entry point per module, no barrel.
| Export | Provides |
|---|---|
./axiom-ingest | createAxiomIngest({ config, onError, fetch?, clock?, batchSize?, flushIntervalMs?, maxQueue? }) → { ingest, flush({ keepalive }), pending, dispose }. Stamps _time, batches (50 events or 5 s), bounds the queue (500, oldest dropped), never throws; HTTP and network failures go to onError. parseAxiomIngestConfig validates { token, dataset, edge? } (edge = bare host) and throws a readable error. |
./context | createObservabilityContext(initial) → { set, merge, get }. A mutable bag merged into every event; setting a key to undefined removes it. |
./log-transport | createIngestLogTransport(ingest, { minLevel, getContext }) → a LogTransport emitting { kind: "log", level, name, message, ...context, ...logContext }. minLevel defaults to warn. Error values in the context are serialized (name, message, stack). |
./global-error-capture | installGlobalErrorCapture({ logger, target, maxReports? }) listens to error and unhandledrejection, deduplicates by signature (message + first stack frame) and caps reports per session (20). Returns uninstall. |
./react-root-handlers | createReactRootErrorHandlers({ logger }) → { onUncaughtError, onCaughtError, onRecoverableError } to spread into createRoot / hydrateRoot. Logs the error with its componentStack; recoverable errors are warn. |
./dom-translation-guard | installDomTranslationGuard({ window, logger, context?, maxReports? }) keeps React alive when a browser translator (Google, Microsoft) has replaced the text nodes it owns: removeChild of a moved node is skipped, insertBefore a moved node appends. Each conflict is a warn dom translation conflict (operation, tags, displaced text sample, translator, languages), deduplicated and capped like error capture. While the page is translated, translationTool and translationLanguage sit in the context. Returns uninstall. |
./node-process-capture | installProcessErrorCapture({ logger, flush?, flushTimeoutMs? }) for uncaughtException / unhandledRejection: logs, flushes (bounded to 2 s), then exits with code 1 like Node's default. |
Data flow
createLogger("API").warn("…", ctx)
├─ console.warn(…) ← always, if above setLogLevel
└─ every registered LogTransport
└─ createIngestLogTransport ← drops below minLevel
└─ { kind: "log", level, name, message, ...context.get(), ...ctx }
└─ createAxiomIngest.ingest ← batched POST to Axiom
window "error" / "unhandledrejection" ─┐
React root handlers ├─ logger.error(…) → same path
process "uncaughtException" (Node) ─┘Error capture goes through a logger rather than straight to the ingest, so every other transport (console, in-app debug panels) sees the same events.
Canonical context keys
| Key | Set by | Notes |
|---|---|---|
app | each app, at boot | app, learn, player, api |
release | each app, at boot | build id / release identifier |
sessionId | browser apps, at boot | random per page load, no identity |
host | browser apps, at boot | location.host |
route | apps with a router | current pathname |
bundle | player | the host bundle it is embedded in (data-player-bundle) |
role | api | http or http+worker (the pg-boss worker runs in the same process) |
translationTool, translationLanguage | browser apps, through the DOM translation guard | present only while a browser translator has translated the page; the language is the one translated into |
language | apps/app | the interface language the app renders in |
userId | explicitly, where the app has one | never set by default |
actorId | api, per request | the admin behind an impersonated request, when userId is the target; never set by default |
A log's own context wins over the bag: logger.warn("…", { route: "/x" }) overrides route for that event. Keys kind, level, name and message are reserved and cannot be overridden. Keep contexts flat.
Per-request context on a server (route, status, duration) travels in the log call's own fields, never in the shared bag: the bag is process-wide and requests are concurrent. The api keeps one request-scoped bag on top (src/lib/request-context.ts, AsyncLocalStorage), merged over the shared bag by its transport: that is where actorId lives for the span of an impersonated request.
Levels
- The global
setLogLevelgates everything, console included; each transport then applies its ownminLevelon top. - Axiom transports ship
warnand above (the default). Useinfoonly for a targeted measurement worth paying ingest for. error: unexpected failures, uncaught errors, React crashes.warn: degraded but handled (expected mutation errors, SDK timeouts, React recoverable errors).
Guard-rails
- A transport never calls
createLogger.createIngestLogTransportdrops synchronous re-entry, but it cannot see an asynchronous loop. onErrorpassed tocreateAxiomIngestnever logs throughcreateLoggerwhile the ingest transport is registered. A failed flush would log, the log would be ingested, the next flush would fail again: one event per failed flush, forever. Drop the error, or write it to a channel that does not reach Axiom (an in-app debug buffer).- Transports never throw out.
createLoggerwraps each transport call; the ingest transport also swallows its own failures. - Dedup + cap keep a crash loop from flooding the dataset; one signature is reported once per session.
- Flush on the way out:
pagehidewithflush({ keepalive: true })in browsers (covers bfcache and tab close better thanbeforeunload);flush()before exit on servers. - Browser tokens are public. Use an ingest-only token scoped to one dataset per env; server tokens are distinct from browser ones. Rotate on abuse (a proxy in
apps/apiis future work).
Wiring per app
apps/app (Vite SPA)
src/lib/env.ts—VITE_AXIOM_TOKEN,VITE_AXIOM_DATASET,VITE_AXIOM_EDGE(blank = unset) go throughparseAxiomIngestConfig. Both token and dataset unset →env.axiom === null; one without the other, or an edge with a scheme, fails at boot.src/lib/observability-context.ts—getObservabilityContext(): the app's lazy context bag, seeded withapp,release(BUILD_ID, the first 7 chars ofBUILD_SHA),sessionIdandhost.src/lib/observability.ts—installAppObservability({ config, context, target, onIngestError }): creates the ingest and registerscreateIngestLogTransportonly whenconfigis set; always installsinstallGlobalErrorCapture(loggerwindow) so global errors also reach the dev-tools log panel; returns{ reactRootHandlers, flush, uninstall }.src/mount-application.tsx— callsinstallAppObservabilityright aftergetEnv(), before the first log;onIngestErrorwrites to the dev-tools log buffer (pushLog), which never goes back to Axiom; flushes withkeepaliveonpagehide; passesreactRootHandlerstocreateRoot. It installsinstallDomTranslationGuard(loggerdom-translation) on the same bag before the first render, andLanguageSyncProvidersetslanguagenext to<html lang>.src/hooks/wire-observability-identity-sync.ts—wireObservabilityIdentitySync(source, getUserId, context): router-agnostic (getCurrentRoute+subscribe), mergesuserIdandrouteinto the bag now and on every route change.useRouterWithAuthcalls it once at router creation with an adapter over the router'sonResolvedevent, and updatesuserIdon sign-in / sign-out.
React root handlers: apps/app passes onUncaughtError and onRecoverableError only. Crashes caught below the root are logged where they are caught, so passing onCaughtError too would report each one twice:
- Route error components (
RouteError,AuthenticatedRouteError,ExportError) only render the fallback;src/lib/router.tssetsdefaultOnCatch: logRouteCatch(src/lib/route-catch-logging.ts, loggerrouter), which logs render and loader errors aserrorwith theircomponentStack,ApiErrors aswarnwithcodeandstatus, and skips errors forced by the boundary dev tools. Since__roothas an error component, this covers almost every crash under the router. - In-page boundaries (
ErrorBoundary, section / item / cell boundaries) log what they catch themselves, with richer context, and skip forced errors too.
The former @axiomhq/js + @axiomhq/logging + @axiomhq/tanstack-start setup was dropped for the single ingestion path. What went with it: the router_navigation / router_performance events of observeTanStackRouter. route stays on every event through the context; navigation timings are out of scope with Web Vitals and tracing, and would come back as targeted createLogger calls if needed.
apps/learn (Vite SPA)
The same shape as apps/app, without the dev-tools buffer:
src/lib/env.ts— the threeVITE_AXIOM_*variables throughparseAxiomIngestConfig,nullwhen token and dataset are both unset.src/lib/observability-context.ts— the bag seeded withapp: "learn",release(__BUILD_ID__, the first 7 chars ofBUILD_SHA),sessionIdandhost;src/providers/learn-router.tsxmergesrouteon every resolved navigation anduserIdon sign-in / sign-out.src/lib/observability.ts—installLearnObservability: the ingest and its transport when configured,installGlobalErrorCapturealways; a failed flush is dropped, never logged.src/main.tsx— installs it before the first render, flushes onpagehide, passesonUncaughtError+onRecoverableErrortocreateRoot; the router'sdefaultOnCatch(src/lib/route-catch-logging.ts) logs what route boundaries catch.
apps/player (Vite, SSR + legacy host)
src/lib/telemetry/player-observability.ts—installPlayerObservability(), called once bymain.tsxbefore the first render;nullwithout Axiom or outside a browser, and then nothing is registered. It builds the single ingest shared by perf and logs, the context bag (app,sessionId,host,bundle,release=env.buildId, the first 7 chars ofBUILD_SHAunlessVITE_PLAYER_BUILD_IDoverrides it, then the bootdealCode), registerscreateIngestLogTransport(warn+) andinstallGlobalErrorCapture, installsinstallDomTranslationGuard(loggerplayer:dom-translation) on the bag, and owns the onepagehidelistener: it runs the perf deal summary, then flushes withkeepalive, so everything rides one batch.- Loop guard: the ingest's
onErrorwrites to theappdebug source, and the Axiom transport skips everyplayer:debug:*logger — debug-panel diagnostics stay local, and a failed flush can never enqueue another event. The debug panel's own transport coexists unchanged. - React:
createRootgetsonUncaughtError+onRecoverableError;onCaughtErroris left out becauseErrorBoundaryalready logs what it catches (one event per crash, not two). The SSR markup is remounted, not hydrated, so there is nohydrateRootto wire. createPerfTelemetryClient(observability)merges the device facts into the shared context and closes the deal on page hide;usePerfTelemetryContextmergesdealCode,teachingModeandphasefrom their owners, so logs and perf events carry the same context.- Config:
src/lib/runtime-env.ts+server/env.ts+src/lib/telemetry/observability-config.ts. The same threeVITE_AXIOM_*variables, RUNTIME on the SSR host (forwarded through the injected runtime-env element) and BUILD-TIME on the legacy host (VITE_AXIOM_*inapps/player/.env.legacy.local, baked into the bundle). The server validates them at startup and fails fast; the client'sgetAxiomIngestConfig()treats an absent or malformed pair as disabled, so a mis-filled env never breaks the player. - Ads: the Ezoic client logs
ready failed(reason:script-error|timeout,elapsedMs) andready timing(elapsedMs) atwarnthroughadsLogger, so both reach Axiom through the player transport.
apps/api (Node server + pg-boss worker)
src/boot-observability.tsis the entry point's first import. WhenAXIOM_TOKEN+AXIOM_DATASET(server token, distinct from the browser ones;AXIOM_EDGEoptional) are set, it registerscreateIngestLogTransport(warn+, contextapp: "api",role,releasefromAPI_RELEASE) andinstallProcessErrorCapture. Unset, nothing is registered and logs stay on stdout only.index.tsflushes onSIGTERM/SIGINTbefore closing.- Per-request fields (method, path, status) travel on the log call's context, never in the shared bag. Ingest failures go to stderr, never back through
createLogger.
apps/web and apps/admin (Next) — not wired
Neither app ships to Axiom, on purpose. apps/web is a static export (output: "export"): Next server instrumentation (instrumentation.ts, onRequestError) never runs in production, only under next dev and next build. apps/admin is internal, with no deploy and no users. If the public site ever needs its client errors, the player's wiring is the model: a root client component that creates the ingest, registers createIngestLogTransport, installs installGlobalErrorCapture on window and flushes on pagehide, plus a global-error.tsx that logs what it receives.
Adding another transport
Do not touch createLogger; register a transport at boot, before the first log.
import { registerLogTransport } from "@workspace/shared/logger";
registerLogTransport((level, name, message, context) => {
// forward somewhere else; never call createLogger from here
});registerLogTransport returns an unsubscribe, useful in tests; production code registers once and never unregisters.
Tests
.env.testfiles never set an Axiom token, so Vitest and e2e runs never ship events. Don't add one.- Unit-test wiring with a fake
fetch(or a fakeingest) and a plainEventTargetas the capture target.
Player perf telemetry
The player ships animation perf events through the shared ingest. perf-telemetry.ts is a pure aggregator (createPerfTelemetry({ ingest, context, now })), create-perf-telemetry-client.ts wires it, perf-telemetry-provider.tsx subscribes the sampler and mode tracker, and usePerfTelemetryContext receives dealCode, teachingMode, phase, motionProfile and animationMode from their owners.
Events
Every event — perf and log alike, since both read the same context bag — carries the session context: app, sessionId (random per page load, no identity), bundle, release (first 7 chars of the bundle's BUILD_SHA), dealCode, teachingMode, phase, host, userAgent, viewportWidth, viewportHeight, dpr, hardwareConcurrency, deviceMemory, prefersReducedMotion, language, performanceMode (measured: full | reduced | minimal), motionProfile (applied: full | reduced | minimal), animationMode (the user's setting: auto | full | reduced | minimal) — and no personal data. Count animationMode != "auto" to see how many users force a profile.
event | When | Fields |
|---|---|---|
perf.summary | Deal end (play finished, review closed, deal code change, pagehide) — one per animation name | name, count, durationP50/P95/Max, maxFrameP50/P95/Max, longFramesTotal, framesTotal |
perf.sample | Immediately, only when a sample has longFrames > 0 (the bad tail, raw) | the whole AnimationSample (name, startedAt, durationMs, frames, longFrames, maxFrameMs, longAnimationFrames, longTasks, meta) |
perf.mode | The mode tracker degrades (full → reduced → minimal) | mode |
kind: "log" | A createLogger call at warn or above (outside player:debug:*), an uncaught error / unhandled rejection (source: error / unhandledrejection, deduplicated, capped per session), a React root error (source: react-uncaught / react-recoverable) | level, name, message, the log context (errors serialised to { name, message, stack }) |
Percentiles are nearest-rank on the sorted per-deal values. Expected volume: about one perf.summary per animation name per deal (play-card, bid, collect-trick, reveal, bid-drawer-open → ~5 events per deal), plus raw samples only on the slow tail and a handful of mode events per session.
Webhook pipeline logs
The webhook pipeline (ingest → worker → handlers → admin) is server-side (apps/api, packages/database) and uses plain createLogger calls, the same style as account.repository.ts above — no new API.
-
No
console.*. Every log goes throughcreateLogger("<namespace>")from@workspace/shared/logger. -
No
logger.child(...).createLoggerdoesn't expose a child API and this convention doesn't add one. Namespaces below are flat strings picked at each call site, same spirit as "stores via Provider, not getState" — a fixed convention beats a magic hierarchical API. -
Meta is a flat object, never nested. Canonical keys — include whichever are available, never rename them:
{ webhookId, provider, externalId, eventType, attempts?, durationMs? }
Namespaces (fixed, non-hierarchical)
| Namespace | Where |
|---|---|
webhooks:boot | Env validation / warn on missing secret |
webhooks:ingest | The ingest() service |
webhooks:worker | startWebhookWorker + its sweep |
webhooks:handler:<eventType> | Each handler, e.g. webhooks:handler:user.created |
webhooks:admin | Admin server actions |
webhooks:repo | repo:webhook-events — the standard repo namespace, kept distinct from the pipeline namespaces above |
Levels (fixed)
info:persisted,claimed,done,skipped.warn:retryScheduled,duplicate(second ingest of the same event), missing webhook secret outside prod.error:exhausted,unexpected, a handler throwing an untyped error.debug: idempotence noop (incoming payload older than the persisted row).
Examples
// webhooks:ingest
const logger = createLogger("webhooks:ingest");
logger.info("persisted", { webhookId, provider, externalId, eventType });
logger.warn("duplicate", { webhookId, provider, externalId, eventType });
// webhooks:worker
const logger = createLogger("webhooks:worker");
logger.info("claimed", { webhookId, provider, eventType, attempts });
logger.warn("retryScheduled", { webhookId, provider, eventType, attempts });
logger.error("exhausted", { webhookId, provider, eventType, attempts });
// webhooks:handler:<eventType>
const logger = createLogger("webhooks:handler:user.created");
logger.info("done", { webhookId, provider, externalId, eventType, durationMs });
logger.debug("skipped: stale payload", { webhookId, provider, externalId, eventType });
// webhooks:admin
const logger = createLogger("webhooks:admin");
logger.info("done", { webhookId, provider, eventType });
// webhooks:boot
const logger = createLogger("webhooks:boot");
logger.warn("webhook secret missing", { provider });