Skip to Content
Living documentation — last reviewed 2026-05-28
ArchitectureObservability

Observability

Three tools, plus two audit tables.

ToolRoleProject
SentryErrors, traces, slow-query taggingtaikan-backend (API) + taikan-frontend (web). Marketing and minisites share configs.
PostHogFrontend events, funnel analytics, Spotter cost telemetry, session replayEU instance (https://eu.i.posthog.com)
PinoStructured API logsStreamed wherever Railway captures stdout
Bull-BoardBullMQ job inspection (internal)Mounted in the API

Org-scoped audit logging (FIT-20) is shipped: audit_events written by AuditService, with an owner-facing viewer — see audit-logging and ADR-0018. The platform-admin audit_logs table is a separate, narrower ledger (see Audit logging below).

Sentry

API

Init in apps/api/src/instrument.ts. Imported at the top of main.ts so it loads before NestFactory.

Key behaviour:

SettingValueWhy
dsnSENTRY_DSN envEmpty disables Sentry.
environmentNODE_ENVdevelopment, test, production.
releaseSENTRY_RELEASESet in CI/Railway to git SHA.
enabledisProdSentry off in dev (uses Sentry.init but no transmission).
tracesSampleRateprod 0.3, dev 1.0Catch real perf issues without burning quota.
profilesSampleRateprod 0.1, dev 0Profile a slice of slow transactions.
enableLogsisProdLogs forwarded to Sentry only in prod.
pinoIntegrationprod only, on error and fatalPino → Sentry forwarding.
beforeSenddrops 401 / 403Expected auth failures aren’t reported.
beforeSendTransactiontags slow_db: true when any db span > 1sLets you filter slow-query traces.

Per-request scope is set in LoggingInterceptor (apps/api/src/app/logging.interceptor.ts):

  • Sentry.getCurrentScope().setTag('requestId', requestId)
  • Sentry.getCurrentScope().setUser({ id: userId }) when authenticated
  • orgId when present, via attributeRequestToOrg (apps/api/src/common/sentry-org-attribution.ts): the error tag, plus a span attribute of the same name. The request’s root span gets it directly; every span started afterwards in the request (db, http.client, gen_ai.*, …) gets it from a spanStart hook registered in main.ts. McpAuthService does the same for /mcp, whose org comes from the token, not the URL. Explore: filter or group spans by orgId.

The AllExceptionsFilter extends SentryGlobalFilter so unhandled exceptions are reported automatically.

Entitlement ledger checks (ADR-0024; the ledger is the sole credit authority). All carry subscription ids only, no PII, and a check tag starting entitlement_ledger, which the production Sentry alert rule “Entitlement ledger: drift / unopened / negative counter” routes to email and Linear. What to do about each: Ledger operations.

check tagSourceWhen
entitlement_ledger_driftEntitlementDriftService.handleScan, daily 04:10 UTC (scheduled job, CRONS_ENABLED)Warning, once per run, when any subscription’s remaining_credits mirror differs from its ledger sum: counts, orgs affected, total drift, up to 20 subscription ids.
entitlement_ledger_negative_counterEntitlementDriftService.handleScanError, once per run, when any tracked subscription’s mirror is below zero (only a mirror that already drifted can get there). Same context as drift.
entitlement_ledger_unopenedEntitlementDriftService.handleScan; reportUnopenedLedger in entitlement-ledger.tsThe scan: a warning when tracked subscriptions have no grant/reset row (unbackfilled count). Live: a read of such a subscription, which falls back to its counter — a structured warn log on every read (subscriptionId, read path debit/waitlist_entry/manual_adjustment/sweep/read/credit_note) and a Sentry warning once per subscription per API process. Should never fire after the 2026-10-14 backfill; if it does, a writer skipped its ledger row.

The scan also pings the HEARTBEAT_URL_ENTITLEMENT_DRIFT Better Stack heartbeat after every completed run (monitoring.md).

Booking-policy rollout signals (ADR-0024, flag booking-policy-rules). Each decision is one structured log line with a check tag (Better Stack) and one server PostHog event (BookingEvents in libs/shared/src/lib/analytics/booking-events.ts, organization group, ids and enums only). Helpers: apps/api/src/booking-policy/booking-policy-reports.ts.

check tagPostHog eventWhen
booking_quota_refusedbooking_quota_refused { reason, sessionId, planId, bucketId }book() refused by one of the plan’s entry quotas: not_covered_by_plan (a matching quota of 0) or bucket_exhausted (a matching quota full); bucketId is that quota, the first by position.
booking_window_refusedbooking_window_refused { reason, sessionId, layer, ruleId }A member’s booking refused outside the registration window (registration_not_yet_open, registration_closed); layer/ruleId say what set the instant (org_default when no rule did). The log line adds membershipId and the instant (at). Fires with the flag off too.
cancellation_closedbooking_cancel_refused { reason, sessionId, layer, ruleId }A cancel refused past its deadline; layer/ruleId say what set the deadline (org_default for a booking without a snapshot, and for every booking while the flag is off). Fires with the flags off too.
booking_policy_conflictbooking_policy_conflict { layer, settings, conflictCount }A rule save refused 409 by an overlapping same-layer rule.
booking_rule_savedbooking_rule_saved { action, layer }A rule created, edited or archived (action), after the write. The log line adds ruleId.
plan_quotas_savedplan_quotas_saved { quotaCount }A plan’s quota list saved (it replaces the last whole), after the write. The log line adds planId.
sweep_parity_revokedsweep_parity_revoked { reason, sessionId }The entitlement sweep revoked a booking only because booking-policy-rules adds the expiry/horizon check (credits_expired, plan_ends_before_session). After commit.

Rows that do not parse never fail a request; each read that leaves one out logs one warn with a check tag, no event: policy_snapshot_invalid (bookingId; the cancel reads the org columns), booking_policy_rule_invalid (orgId, ruleIds; the rules are left out, as if archived), plan_quota_invalid (bucketIds; the quotas are left out). They should never fire: every writer validates. If one does, a row was edited by hand or holds a retired shape.

Waitlist signals (always on, no flag). Same shape: a structured log line with a check tag and a server PostHog event from BookingEvents, organization group, ids and enums only. Every promotion signal is sent after the promoting transaction commits (publishPromotions in apps/api/src/bookings/waitlist-promotion-policy.ts), so a serializable retry or a rollback never reports a seat nobody got. Rules: scheduling-bookings › Waitlist.

check tagPostHog eventWhen
waitlist_promotedwaitlist_promoted { sessionId, bookingId, trigger, graceApplied, removedCount }One per promotion. trigger: cancel, admin_cancel, sweep, capacity, arbox, book, scheduler (the 5-minute backstop refill). graceApplied: promoted past the cancel deadline, so a 10-minute free cancel opened. removedCount: the member’s same-day waitlist places it released.
—waitlist_removed_elsewhere { sessionId, promotedSessionId }One per released same-day waitlist place (sessionId is the released one).
—seats_filled_from_waitlist { sessionId, count, trigger }One per fill that promoted anyone, per session and trigger.
waitlist_promotion_skippedwaitlist_promotion_skipped { reason, sessionId }A free seat passed over a waitlisted member: an evaluator reason, no_credits (the debit failed) or subscription_inactive (plan paused, cancelled or any status other than active/past_due/debt). The log adds bookingId. A member whose membership is no longer active is passed over silently.
waitlist_hold_limit_refusedwaitlist_hold_limit_refused { subscriptionId }A waitlist join refused (400 no_credits) because the pack’s balance is already committed to other waitlist places.
—booking_grace_cancel { bookingId }A promoted booking cancelled inside its free-cancel grace (refunded).
waitlist_expiredwaitlist_expired { count }WaitlistExpiryService closed waitlist places on classes that started; one per org per run, only when it closed any.
member_removed_bookings_cancelled—A departing (removed, suspended, deleted, erased) member’s future bookings cancelled; adds membershipId, cancelled, seatsFreed. member_removed_bookings_failed is the error twin from the listener.
waitlist_fill_failed—WaitlistPromotionService.fillSessions failed for a session (capacity edit, Arbox import, departure, the 5-minute refill). Error level; the next 5-minute refill retries it.

Web

@sentry/nextjs configured via the standard sentry.client.config.ts / sentry.server.config.ts / sentry.edge.config.ts pattern (Next.js Sentry SDK). Source maps uploaded at build time when SENTRY_AUTH_TOKEN is set; build still succeeds without it.

AnalyticsUserSync in apps/web/src/providers.tsx ties Clerk user identity into Sentry:

Sentry.setUser({ id: user.id, email: user.primaryEmailAddress?.emailAddress }); Sentry.setTag('posthog_session_id', sessionId);

posthog_session_id lets you cross-link a Sentry issue to the PostHog session replay.

Marketing + minisites

Each has its own Sentry project key in env. Same SDK, smaller footprint.

Env vars

SENTRY_DSN API SENTRY_RELEASE optional, used for release tracking NEXT_PUBLIC_SENTRY_DSN Web (client + server) EXPO_PUBLIC_SENTRY_DSN Mobile (taikan-mobile) — distinct project SENTRY_URL default https://de.sentry.io SENTRY_ORG taikan (or taikan1 for mobile) SENTRY_API_PROJECT taikan-backend SENTRY_WEB_PROJECT taikan-frontend SENTRY_MOBILE_PROJECT taikan-mobile SENTRY_AUTH_TOKEN CI/Railway/EAS — enables source map upload

Mobile (taikan-mobile)

Separate Sentry project (taikan1/taikan-mobile). Init happens at module top of app/_layout.tsx, before any RN screen mounts. The @sentry/react-native/expo config plugin uploads source maps + debug IDs during EAS Build (needs SENTRY_AUTH_TOKEN as an EAS secret).

Sample rates:

SettingValueWhy
tracesSampleRate0No perf traces until we have a specific bottleneck — they balloon event volume.
replaysOnErrorSampleRate1.0Capture every error session.
replaysSessionSampleRate0.110% of normal sessions for baseline behavior.
sendDefaultPiifalseMatches the shipping PrivacyInfo.xcprivacy (NSPrivacyTracking=false).

The mobile-replay integration ships UI frames; combined with replaysOnErrorSampleRate: 1.0 you get a visual recording of every crash. iOS dSYMs and Android proguard mapping are auto-uploaded by EAS.

PostHog

Web client

Initialised in apps/web/src/providers/posthog-provider.tsx. Env: NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST (default https://eu.i.posthog.com).

PostHogPageView (apps/web/src/components/posthog-pageview.tsx) sends a $pageview per navigation. AnalyticsUserSync calls posthog.identify(user.id, { email, name }) on Clerk-user changes and posthog.reset() on sign-out.

Mobile client

posthog-react-native. Env: EXPO_PUBLIC_POSTHOG_KEY, EXPO_PUBLIC_POSTHOG_HOST (default EU). Initialised inside useAnalyticsIdentify() (src/hooks/use-analytics-identify.ts) once Clerk loads — distinct id = Clerk user id, mirrors web so the same identified user shows up across both surfaces. Screen views are tracked via Expo Router’s navigation events.

API server-side

apps/api/src/event-tracking/event-tracking.service.ts. Env: POSTHOG_API_KEY, POSTHOG_PROJECT_ID, POSTHOG_HOST (default EU). Used to fire server-authoritative events:

  • user_signed_up (from the Clerk webhook handler)
  • Payment funnel: checkout_started, form_created, webhook_received, transaction-stage events
  • Spotter telemetry: per-turn cost, cache hit ratio, tool counts (apps/api/src/ai/agent/observability/)
  • Booking and waitlist decisions (BookingEvents, see the Sentry › API tables above)

Why server-side

The web client can’t be trusted to fire a “payment succeeded” event — it might be a different browser, an offline reconnect, or a payment-provider callback hitting only the API. Critical funnel events fire from the API.

Data warehouse source (Railway Postgres)

PostHog pulls production Postgres into the warehouse so product data can be joined against events in HogQL. Source 01a0e18c-777e-0000-464c-2ef5399c10f3, created 2026-09-27, replacing a dead Neon source that had been failing silently since 2026-02-12.

  • Connection: yamanote.proxy.rlwy.net:10911, database railway, schema public, over the Railway public TCP proxy (TLS 1.3). No SSH tunnel, no CDC (wal_level = replica), no direct query — import mode only.
  • Credentials: dedicated Postgres role posthog_ro. Login-only, no superuser, default_transaction_read_only = on, statement_timeout = 120s. It holds no INSERT/UPDATE/DELETE and no CREATE on public.
  • Tables (17, HogQL prefix prodpostgres_): bookings, class_sessions, class_types, locations, memberships, organizations, payment_transactions, plans, programs, subscriptions, users, workouts, workout_sections, workout_movements, workout_assignments, workout_results, workout_set_results. All incremental on updated_at, primary key id, every 24h at 03:00 UTC. auto_sync_new_schemas is off.
  • Deliberately excluded: israel_streets (63,571 static rows — syncing it on full refresh alone would blow the 1M rows/month free allotment), israel_cities, audit_events, ai_*, leads*, member_profiles, form_*, legal_consents, device_tokens.
  • PII policy — enforced in Postgres, not in PostHog. The eight tables carrying identifiers or large embeddings are granted with column-level GRANT SELECT (col, …), so the excluded columns are invisible to schema discovery and can never be selected back on by a UI click. prodpostgres_users therefore holds only id, clerk_id, created_at, updated_at, deleted_at, analytics_consent, analytics_consent_at, guided_tour_completed_at — no email, name, phone, national id, or embeddings. Same pattern drops free text and provider payloads from payment_transactions, contact details and join_token from organizations, and pgvector columns from programs / workouts. Any migration that adds a column to one of those eight tables must also add an explicit grant, or the column silently never syncs.
  • Rows only ever soft-delete (deleted_at), so incremental merge is safe; filter deleted_at IS NULL at query time.
  • Health-alert caveat: PostHog sets should_sync = false and stops scheduling a source after a hard error — that is how the Neon source stayed broken for 5.5 months unnoticed. Nothing in docs/runbooks/monitoring.md watches PostHog Health, so a failure here is still silent. Check https://eu.posthog.com/project/125602/health  when warehouse tables look stale.
  • Spaces columns: posthog_ro reads locations and class_types with a table-level SELECT, so locations.concurrency, sort_order, is_default, auto_created, kind, parent_id and class_types.default_location_id / default_area_id need no grant. class_sessions is column-granted: location_id is in the grant, area_id is not, so the owner runs GRANT SELECT (area_id) ON class_sessions TO posthog_ro once after the TKN-479 migrations deploy (not part of a migration). New columns appear in the warehouse only after a PostHog schema refresh of the source.
  • class_sessions.location_id means the hall since TKN-479 (ADR-0024 D13): a business’s pre-hall “locations” became areas of one hidden hall, and a session’s old location is its area_id. A warehouse query that grouped sessions by location must group by coalesce(area_id, location_id) to keep its meaning; locations.kind tells halls from areas.

Space context

  • API: when SpaceScopeService.resolve accepts a spaceId, it tags the request’s Sentry scope with space.id and space.context=on. A spaceId from a caller who is not a member, or one that belongs to another organization, logs a structured pino warn { orgId, userId | clerkId, spaceId, reason: not_a_member | other_org } (a probing signal; the response is the same 403/404 as always). Malformed, unknown and inactive ids log at debug.
  • Splits by space (TKN-467, TKN-464): an accepted groupBy=space tags the scope space.group_by=space (the Overview reads, the Reports snapshot, attendance report, retention, attendance KPIs and exports); an unknown groupBy (400 invalid_group_by) and a “Spaces this week” or Reports “Spaces” (/analytics/spaces/range) read without the context (404 space_context_off) log at debug. Neither is a probing signal: the caller is already a member.
  • Every session has a space: DefaultSpace logs info { orgId, spaceId?, reason: no_default | no_active_space } when a business’s default is reassigned or its hidden “Main space” is created at write time. After the backfill both should be rare; a steady stream means a writer is racing a removed default. A class type’s space change logs info { orgId, classTypeId, fromLocationId, toLocationId, moved, overridden, kept }.
  • Web: WorkingContextProvider sets the Sentry tag space_context (all | single) on load and on every switch. The space’s name is never sent to Sentry or PostHog.
  • Kiosk door: a door request the API accepts tags the Sentry scope space.kiosk=door; the web sets the same tag while a door is on screen and clears it when the door closes.
  • Events: see docs/features/space-context/README.md#analytics.

Pino structured logs

Set up in apps/api/src/app/app.module.ts via LoggerModule.forRoot:

  • pino-pretty transport in non-prod (colorized, human-readable).
  • JSON logs in prod (Railway captures and forwards).
  • autoLogging: true — request/response auto-logged.
  • genReqId — x-request-id header passthrough, else generates req_<ts36>_<rand>.
  • customLogLevel:
    • err || statusCode >= 500 → error
    • statusCode >= 400 → warn
    • else → info
  • Serializers strip request to { id, method, url, remoteAddress } and response to { statusCode }.

Slow-request warnings

LoggingInterceptor emits a SLOW warn whenever a request exceeds 800ms (lowered from 3s so the 500–2000ms tail surfaces).

Error log shape

{ "level": "error", "requestId": "req_l2x...", "userId": "user_2gT...", "orgId": "0193...", "duration": 1241, "statusCode": 500, "error": "...", "pgError": "duplicate key value violates unique constraint", // when err.cause is a pg error "stack": "..." // only for 5xx }

Health endpoint

GET /health — apps/api/src/app/health.controller.ts. Public + skip-throttle. Returns:

{ "status": "ok" | "degraded", "timestamp": "2026-05-28T...", "checks": { "database": "up" | "down", "redis": "up" | "down" } }

Used by Railway for liveness probing and by uptime monitors.

Bull-Board

apps/api/src/bull-board/bull-board.module.ts mounts the BullMQ admin UI (route name lives in that module). Access is gated to Taikan-admin users.

Use it to:

  • Inspect queue depth and failed jobs.
  • Retry failures.
  • Browse job payloads.

In a production incident, Bull-Board is the fastest way to see whether the push-notification, embedding, or import queues are backed up.

Spotter observability

Spotter ships a dedicated observability service: apps/api/src/ai/agent/observability/agent-observability.service.ts. Tracks:

  • Per-turn cost (USD) — derived from token counts × model price.
  • Cache hit ratio (RAG + tool memoization).
  • Tool counts per conversation.
  • Storage growth (snapshot service: storage-snapshot.service.ts).

These flow into PostHog so the admin cost dashboards can show per-org Spotter spend and watch the cache-hit ratio drift.

Audit logging

Two tables, deliberately not merged (see docs/features/audit-logging/data-model.md › Relationship to audit_logs):

  • audit_events (libs/db/src/lib/schema/audit-events.ts) — the org-scoped trail: every consequential change inside one gym, written by AuditService, read by the owner through a filterable viewer with CSV export, aged to R2 after 24 months. This is the “who did what” record for member-facing incidents. Behavior, categories and code map: docs/features/audit-logging/, decision: ADR-0018.
  • audit_logs (libs/db/src/lib/schema/admin.ts) — the platform-admin ledger: Taikan staff acting across orgs, keyed on actor_clerk_id. Only select admin paths write it (tier changes, user resets, bulk scheduling ops); there is no global interceptor and no org-facing UI for it.

For incident forensics inside an org, start from audit_events; for platform-admin actions, audit_logs; for anything neither captures (migrations, infra), Pino logs (Railway log retention) + Sentry breadcrumbs.

Per-request id propagation

Every request that arrives at the API:

  1. Reads x-request-id if present, else generates one (Pino).
  2. Sets requestId on Pino’s per-request log context.
  3. Tags Sentry scope with the same id.
  4. Sets the response header X-Request-Id (LoggingInterceptor).

serverFetch on the web side surfaces it back into Sentry context on failed API calls:

scope.setContext('api_request', { path, status, requestId: res.headers.get('x-request-id') });

When a customer reports a bug, ask for the X-Request-Id from a network tab — it pivots into both Sentry and Pino quickly.