Skip to content

feat(observability): Phase 3 — ClickHouse analytics tier (4 tables → CH, off-by-default) (#890 Phase 3) - #1991

Merged
2witstudios merged 9 commits into
masterfrom
pu/db-decomp-p3
Jul 10, 2026
Merged

2witstudios merged 9 commits into
masterfrom
pu/db-decomp-p3

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 10, 2026 •

Copy link
Copy Markdown
Owner

Phase 3 — ClickHouse analytics tier (part of the DB Decomposition epic, #890)

Why

apiMetrics, systemLogs, userActivities, errorLogs are the four highest-write-volume tables in main Postgres — pure append-only telemetry with no FKs and no mutations, contending with application traffic on the same instance. This phase moves them to ClickHouse Cloud (columnar, TTL-native, built for high-cardinality analytics), leaving main PG for application data. The hash-chain audit tables stay in Admin PG (Phase 2); ai_usage_logs stays in main PG (billing joins + cost-reconcile UPDATEs — Phase 4 replicates it to CH via ClickPipes CDC).

Ships dark. CLICKHOUSE_ENABLED unset ⇒ every PG write path is byte-identical to master. Off-mode identity is pinned by cutover tests at all six write sites, both reader files, and the GDPR/retention paths.

The four leaves

  1. Buffered client + env gating (c58246eb5 → rebased) — createClickHouseBuffer(table, {insert, …}) (500-row / 1000ms auto-flush, double-buffer, drops + logs payload-free on failure) and a lazy getClickHouseClient() at @pagespace/lib/observability/*. Three-state pure env core (disabled / enabled / misconfigured); credentials are server-side secrets, never bundled, .env.example placeholders only.
  2. MergeTree DDL + insert adapters (ded25200d) — idempotent CREATE TABLE IF NOT EXISTS DDL (applied out-of-band by scripts/clickhouse-migrate.ts, so app creds never need DDL grants) + pure (row) → void never-throw insert adapters. Flag-gated cutover of the four writers in logger-database.ts plus two direct write sites; off-mode PG paths byte-identical.
  3. Server-side readers + error_resolutions mini-table (2b537368b) — admin/web monitoring UIs re-pointed to server-side CH aggregation queries with parity tests vs the old PG queries. CH rows are immutable, so the resolved-flag workflow moves to a new error_resolutions mini-table in main PG (migration 0194) — the two-step cross-store join pattern Phase 5 reuses.
  4. Retention + GDPR cutover, deferred drop (e201e7523) — CH TTLs own analytics retention (api_metrics 90d / system_logs 30d / user_activities 180d / error_logs none by orchestrator ruling — erasure mutations are its only eraser). Art 15 export unions PG+CH; Art 17 erasure deletes from both stores (fail-closed). The four PG tables' DROP is deferred to Phase 6 (no analytics-history backfill — decision task filed).

Review outcome (adversarial /aidd-review, 17 findings)

2 HIGH fixed (295423c30):

  • DSR swallowed a CH purge failure — purge-monitoring was fatal:false, so a CH outage during a worker-path erasure left a subject's error rows (message/stack/ip) retained forever under a completed status. Made fatal when ClickHouse is in play.
  • Half-configured deploy silently blacked out all telemetry — flag-on + missing creds retired the PG writes and dropped every CH row (adapters absorb per-row errors by design), requests still 200. Added a startup probe (probeClickHouseStartup) that crashes at boot on misconfigured, wired into web/admin instrumentation + processor composition roots, plus drain-on-SIGTERM/SIGINT so buffered rows aren't lost on deploy.

3 MED fixed: GDPR export/erasure now gate on config-present (not the write-cutover flag) so a flag rollback can't strand CH rows from Art-15/17; the deferred-drop SQL's DROPs moved inside the DO block after the RAISE so the guard actually aborts under plain psql -f; error_resolutions orphan path assessed.

2 PR-verification escapes fixed (6f0089ded, this session): a MockInstance typecheck error in the new instrumentation tests (turbo had cached a stale admin typecheck) and a stale processor SIGTERM test broken by the new fire-and-forget handler shape.

Remaining LOW findings + the userActivities-Art15 pre-existing gap are logged on the Phase 3 board as pending tasks; deferred CH-grants and CI-CH-service tasks are filed (CI does not run the docker CH integration suite — a filed task adds a CH service; unit/lint/typecheck are the CI gate and are green).

Verification

monorepo typecheck 16/16 · lib 719 pass · web/admin instrumentation 5+5 · processor server 83 (incl. SIGTERM) + wiring 3 · drop-guard 5 · live-CH integration 5/5 (docker container) · lint clean · db:generate no drift (only 0194 error_resolutions, committed).

⚠ OFF BY DEFAULT

CLICKHOUSE_ENABLED unset ⇒ PG unchanged, this PR is a no-op in production. Before any prod enable:

  1. Provision ClickHouse Cloud + run scripts/clickhouse-migrate.ts (DDL bootstrap).
  2. Resolve the filed CH-grants (xqlr5brm5lau6ubjw50ebeaa) and CI-CH-service (pso5dy0gguyr10jtbakj0vy5) gate tasks.
  3. The main-PG DROP of the four tables is deferred to Phase 6 — there is no analytics-history backfill (decision task f70ay5fo488veub4fak4feqs). Pre-cutover PG rows freeze until the drop.

Part of #890 (DB Decomposition & Zero-Trust Logging epic, Phase 3). Handoff: SPEC verifies against the Phase 3 task list, then MERGE.

🤖 Generated with Claude Code


Convergence log

  • ab4a656be — Codex P2 (signal handlers never terminate): the FIX's web/admin instrumentation registered a bespoke SIGTERM/SIGINT drain-only listener that awaited nothing and never exited (suppressing Node's default termination → hang until SIGKILL). Root cause: the Logger singleton already registers a complete flush → drain → exit handler unconditionally at module load, draining all CH buffers. Dropped the redundant listener; both composition roots now deterministically initialize the shared logger at boot so its terminating handler is always installed. Codex P2 "keep purging frozen PG rows" is the ratified, documented retention-freeze tradeoff (decision task f70ay5fo488veub4fak4feqs) — replied, no change.
  • Rebased over #1992 (machines PageType TERMINAL→MACHINE rename): resolved a hard migration collision — refactor(machines): rename TERMINAL page type → MACHINE, terminalId FK → machineId #1992 claimed 0194 + 0195, so the error_resolutions migration was regenerated via db:generate as 0196_married_blink (table byte-identical) and bun.lock reconciled to sdk 2.1.0. Full typecheck 16/16, all suites green post-rebase.
  • CodeRabbit review (5 findings): fixed — processor shutdown steps now each try/catch-guarded so process.exit(0) always runs (Major); retention-policy doc corrected (error_logs TTL exception + TTL merge-vs-insert timing, Minor ×2). Deferred with filed tasks — web getErrorAnalytics failed-login email masking (parity-mandate + off-mode byte-identity tension; task iehscxlxt6e3zb9wkei5hegz); logger signal-handler ownership race with the processor (pre-existing on master, cross-cutting; task jnbi8j2jos02id2yjqnwq0lb). All 5 threads answered with evidence.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6afc2d9b-12f5-4ce1-b9fe-ff8632cd20a0

📥 Commits

Reviewing files that changed from the base of the PR and between ab4a656 and 9243fa5.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (72)
  • .env.example
  • apps/admin/.env.example
  • apps/admin/src/__tests__/instrumentation.test.ts
  • apps/admin/src/instrumentation.ts
  • apps/admin/src/lib/__tests__/monitoring-queries-clickhouse-cutover.test.ts
  • apps/admin/src/lib/__tests__/monitoring-queries.parity.integration.test.ts
  • apps/admin/src/lib/monitoring-queries.ts
  • apps/admin/vitest.config.ts
  • apps/processor/.env.example
  • apps/processor/src/__tests__/server-clickhouse-wiring.test.ts
  • apps/processor/src/server.ts
  • apps/processor/src/workers/account-erasure-worker.ts
  • apps/web/.env.example
  • apps/web/next.config.ts
  • apps/web/src/__tests__/instrumentation.test.ts
  • apps/web/src/app/api/internal/monitoring/ingest/route.ts
  • apps/web/src/instrumentation.ts
  • apps/web/src/lib/monitoring/__tests__/monitoring-queries-clickhouse-cutover.test.ts
  • apps/web/src/lib/monitoring/__tests__/monitoring-queries.parity.integration.test.ts
  • apps/web/src/lib/monitoring/monitoring-queries.ts
  • apps/web/src/lib/repositories/conversation-repository.ts
  • docker-compose.dev.yml
  • docker-compose.yml
  • docs/security/audit-log-retention-policy.md
  • infrastructure/__tests__/root-compose.test.ts
  • packages/db/drizzle/0196_married_blink.sql
  • packages/db/drizzle/meta/0196_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/__tests__/deferred-drop-analytics-tables.test.ts
  • packages/db/src/schema/monitoring.ts
  • packages/lib/package.json
  • packages/lib/src/compliance/erasure/__tests__/erasure-plan.test.ts
  • packages/lib/src/compliance/erasure/__tests__/run-erasure.test.ts
  • packages/lib/src/compliance/erasure/erasure-plan.ts
  • packages/lib/src/compliance/export/gdpr-export.test.ts
  • packages/lib/src/compliance/export/gdpr-export.ts
  • packages/lib/src/compliance/retention/monitoring-retention.test.ts
  • packages/lib/src/compliance/retention/monitoring-retention.ts
  • packages/lib/src/config/__tests__/env-validation.test.ts
  • packages/lib/src/config/env-validation.ts
  • packages/lib/src/logging/__tests__/graceful-shutdown.test.ts
  • packages/lib/src/logging/__tests__/logger-database-clickhouse-cutover.test.ts
  • packages/lib/src/logging/__tests__/logger.test.ts
  • packages/lib/src/logging/__tests__/monitoring-purge.test.ts
  • packages/lib/src/logging/graceful-shutdown.ts
  • packages/lib/src/logging/logger-database.ts
  • packages/lib/src/logging/logger.ts
  • packages/lib/src/logging/monitoring-purge.ts
  • packages/lib/src/observability/__tests__/analytics-gdpr.integration.test.ts
  • packages/lib/src/observability/__tests__/analytics-gdpr.test.ts
  • packages/lib/src/observability/__tests__/analytics-inserts.test.ts
  • packages/lib/src/observability/__tests__/analytics-read-core.test.ts
  • packages/lib/src/observability/__tests__/analytics-rows.test.ts
  • packages/lib/src/observability/__tests__/clickhouse-buffer.test.ts
  • packages/lib/src/observability/__tests__/clickhouse-client.test.ts
  • packages/lib/src/observability/__tests__/clickhouse-ddl.test.ts
  • packages/lib/src/observability/__tests__/clickhouse-env.test.ts
  • packages/lib/src/observability/__tests__/clickhouse-integration.test.ts
  • packages/lib/src/observability/__tests__/error-resolutions.integration.test.ts
  • packages/lib/src/observability/__tests__/error-resolutions.test.ts
  • packages/lib/src/observability/analytics-gdpr.ts
  • packages/lib/src/observability/analytics-inserts.ts
  • packages/lib/src/observability/analytics-read-core.ts
  • packages/lib/src/observability/analytics-reads.ts
  • packages/lib/src/observability/analytics-rows.ts
  • packages/lib/src/observability/clickhouse-buffer.ts
  • packages/lib/src/observability/clickhouse-client.ts
  • packages/lib/src/observability/clickhouse-ddl.ts
  • packages/lib/src/observability/clickhouse-env.ts
  • packages/lib/src/observability/error-resolutions.ts
  • scripts/clickhouse-migrate.ts
  • scripts/deferred-migrations/0890-phase6-drop-analytics-pg-tables.sql
📝 Walkthrough

Walkthrough

This PR adds an optional ClickHouse analytics tier with configuration, local infrastructure, buffered writes, monitoring read cutovers, startup and shutdown wiring, GDPR export/erasure support, error resolutions, retention handling, migrations, and comprehensive unit and integration tests.

Changes

ClickHouse analytics tier

Layer / File(s) Summary
Configuration, storage, and migration scaffolding
.env.example, apps/*/.env.example, docker-compose*.yml, packages/db/*, scripts/deferred-migrations/*
Adds optional ClickHouse configuration, a local service with persistent storage, the error_resolutions table, and guarded deferred PostgreSQL table removal.
ClickHouse configuration and table primitives
packages/lib/src/observability/clickhouse-{env,client,ddl,buffer}.ts, packages/lib/src/observability/analytics-rows.ts
Adds fail-closed configuration resolution, lazy client creation, analytics DDL, typed row mapping, and non-blocking buffered inserts.
Buffered writes and typed analytics reads
packages/lib/src/observability/analytics-{inserts,read-core,reads}.ts
Adds ClickHouse insert adapters, query builders, parsers, typed read functions, draining, and migration tooling.
Monitoring read cutover
apps/admin/src/lib/monitoring-queries.ts, apps/web/src/lib/monitoring/monitoring-queries.ts, apps/*/src/**/monitoring-queries*.test.ts
Routes supported monitoring queries to ClickHouse when enabled and retains PostgreSQL fallbacks, including user lookup handling and parity tests.
Application writes and graceful shutdown
packages/lib/src/logging/*, apps/web/src/app/api/internal/monitoring/ingest/route.ts, apps/*/src/instrumentation.ts, apps/processor/src/server.ts
Routes analytics writes conditionally, probes ClickHouse at startup, and flushes logs and analytics buffers during coordinated shutdown.
GDPR analytics and resolution lifecycle
packages/lib/src/observability/analytics-gdpr.ts, packages/lib/src/observability/error-resolutions.ts, packages/lib/src/logging/monitoring-purge.ts, packages/lib/src/compliance/*
Adds ClickHouse export and erasure, PostgreSQL-backed error resolutions, retention bypass behavior, and fatal ClickHouse purge handling during durable erasure.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: moving the four analytics tables to an off-by-default ClickHouse tier.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/db-decomp-p3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f0089ded6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/web/src/instrumentation.ts Outdated
Comment thread packages/lib/src/compliance/retention/monitoring-retention.ts
@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
packages/lib/src/observability/__tests__/clickhouse-client.test.ts (1)

121-144: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the flag-off + GDPR-first + getClient() interaction.

The test on line 138 verifies shared instances when the flag is ON, but there's no test for the rollback-window scenario: flag OFF, getGdprClient() constructs the instance first, then getClient() is called. This is the critical edge case where the shared instance cache could cause getClient() to return a client despite the flag being off — potentially violating the disabled → null contract documented in clickhouse-client.ts.

🧪 Suggested test
   it('given the flag on and configured, getClient() and getGdprClient() should share one instance', () => {
     const createClient = vi.fn().mockReturnValue(fakeClient);
     const registry = createClickHouseRegistry(makeDeps(configuredEnv, { createClient }));

     expect(registry.getClient()).toBe(registry.getGdprClient());
     expect(createClient).toHaveBeenCalledTimes(1);
   });
+
+  it('given flag OFF but config present, getGdprClient() constructing first should NOT cause getClient() to return a client', () => {
+    const createClient = vi.fn().mockReturnValue(fakeClient);
+    const registry = createClickHouseRegistry(
+      makeDeps({ ...configuredEnv, CLICKHOUSE_ENABLED: undefined }, { createClient }),
+    );
+
+    // GDPR accessor constructs the shared instance (rollback window)
+    expect(registry.getGdprClient()).toBe(fakeClient);
+    expect(createClient).toHaveBeenCalledTimes(1);
+
+    // getClient() must still respect the disabled flag
+    expect(registry.getClient()).toBeNull();
+  });
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/src/observability/__tests__/clickhouse-client.test.ts` around
lines 121 - 144, Add a test covering the flag-off rollback scenario where
getGdprClient() is called before getClient(): configure a complete environment
with CLICKHOUSE_ENABLED unset, mock createClient, assert getGdprClient() returns
the client, then assert getClient() returns null and verify the expected
client-creation behavior. Place it alongside the existing shared-instance tests
in the ClickHouse registry test suite.
packages/lib/src/observability/clickhouse-client.ts (1)

145-153: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

isClickHouseAnalyticsInPlay() duplicates env-reading logic from the registry.

This function reads process.env directly instead of using the registry's getEnv dep, creating a second source of truth for env var names. If var names change, both the registry's getEnv (line 106-113) and this function need updating.

♻️ Optional: delegate to the registry's getEnv
-export const isClickHouseAnalyticsInPlay = (): boolean =>
-  resolveClickHouseGdprMode({
-    CLICKHOUSE_ENABLED: process.env.CLICKHOUSE_ENABLED,
-    CLICKHOUSE_URL: process.env.CLICKHOUSE_URL,
-    CLICKHOUSE_HOST: process.env.CLICKHOUSE_HOST,
-    CLICKHOUSE_USER: process.env.CLICKHOUSE_USER,
-    CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD,
-    CLICKHOUSE_DATABASE: process.env.CLICKHOUSE_DATABASE,
-  }).mode !== 'unconfigured';
+export const isClickHouseAnalyticsInPlay = (): boolean =>
+  resolveClickHouseGdprMode(
+    // Reuse the same env snapshot the registry uses
+    {
+      CLICKHOUSE_ENABLED: process.env.CLICKHOUSE_ENABLED,
+      CLICKHOUSE_URL: process.env.CLICKHOUSE_URL,
+      CLICKHOUSE_HOST: process.env.CLICKHOUSE_HOST,
+      CLICKHOUSE_USER: process.env.CLICKHOUSE_USER,
+      CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD,
+      CLICKHOUSE_DATABASE: process.env.CLICKHOUSE_DATABASE,
+    },
+  ).mode !== 'unconfigured';

Alternatively, expose a getGdprMode() method on the registry and call registry.getGdprMode() here to centralize env access.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/src/observability/clickhouse-client.ts` around lines 145 - 153,
Update isClickHouseAnalyticsInPlay to use the ClickHouse registry’s existing
getEnv dependency, or expose and call a registry getGdprMode() method, instead
of reading process.env directly. Centralize the environment variable names and
resolve the mode through the registry so env access has a single source of
truth.
packages/lib/src/logging/graceful-shutdown.ts (1)

26-38: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound the shutdown drain
run() still waits on flushLogs() and drainAnalytics() with no deadline, and drainAnalytics() can block forever if an insert promise never settles. Add a bounded timeout around the shutdown steps so SIGTERM/SIGINT always reaches exit(0).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/src/logging/graceful-shutdown.ts` around lines 26 - 38, Update
the graceful-shutdown `run` function to enforce a finite timeout for both
`deps.flushLogs()` and `deps.drainAnalytics()`, using a shared timeout helper or
`Promise.race` that rejects when the deadline expires. Preserve the existing
per-step error logging, then ensure `deps.exit(0)` executes after timeout or
failure so shutdown cannot block indefinitely.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/processor/src/server.ts`:
- Around line 406-422: The shutdown handler can reject before reaching
process.exit(0) if either cleanup step fails. Update shutdown to wrap
drainAnalyticsInserts() and queueManager.shutdown() in separate try/catch
blocks, log each failure, and ensure process.exit(0) executes after both
attempts; preserve the existing shuttingDown guard and signal handling.

In `@apps/web/src/lib/monitoring/monitoring-queries.ts`:
- Around line 433-437: Mask email addresses in the ClickHouse failed-login
metadata before returning it. Update the failedLogins mapping in
getErrorAnalytics to apply the shared maskEmailsInMetadata helper (or define an
equivalent local helper), and ensure the result preserves the PG path’s
Record<string, unknown> | null metadata shape.

In `@docs/security/audit-log-retention-policy.md`:
- Around line 24-29: Clarify the ClickHouse analytics-tier description so it
does not imply TTL enforcement for all four tables. Update the paragraph under
“ClickHouse analytics tier (`#890` Phase 3)” to explicitly identify `error_logs`
as the intentional NO-TTL exception, retained only through GDPR erasure, while
the other three tables use their configured TTLs.
- Around line 35-37: Update the ClickHouse TTL timing note in the retention
policy documentation to state that TTL deletion occurs during background merges
or OPTIMIZE TABLE ... FINAL, rather than at insert time. Clarify that rows older
than the TTL horizon may remain visible until the relevant part is merged, and
revise the backfill implication accordingly.

In `@packages/lib/src/logging/logger.ts`:
- Around line 380-385: Remove SIGINT/SIGTERM listener registration from the
shared logger initialization in logger.ts, including the handlers that invoke
shutdown/process.exit. Keep graceful signal handling in the processor app
entrypoint, such as server.ts, or add an explicit opt-out for embedded services
so only the application-owned handler performs queueManager.shutdown().

---

Nitpick comments:
In `@packages/lib/src/logging/graceful-shutdown.ts`:
- Around line 26-38: Update the graceful-shutdown `run` function to enforce a
finite timeout for both `deps.flushLogs()` and `deps.drainAnalytics()`, using a
shared timeout helper or `Promise.race` that rejects when the deadline expires.
Preserve the existing per-step error logging, then ensure `deps.exit(0)`
executes after timeout or failure so shutdown cannot block indefinitely.

In `@packages/lib/src/observability/__tests__/clickhouse-client.test.ts`:
- Around line 121-144: Add a test covering the flag-off rollback scenario where
getGdprClient() is called before getClient(): configure a complete environment
with CLICKHOUSE_ENABLED unset, mock createClient, assert getGdprClient() returns
the client, then assert getClient() returns null and verify the expected
client-creation behavior. Place it alongside the existing shared-instance tests
in the ClickHouse registry test suite.

In `@packages/lib/src/observability/clickhouse-client.ts`:
- Around line 145-153: Update isClickHouseAnalyticsInPlay to use the ClickHouse
registry’s existing getEnv dependency, or expose and call a registry
getGdprMode() method, instead of reading process.env directly. Centralize the
environment variable names and resolve the mode through the registry so env
access has a single source of truth.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 974c16be-4548-4d1b-9c04-a23bf8faa37a

📥 Commits

Reviewing files that changed from the base of the PR and between c9a65f7 and ab4a656.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (72)
  • .env.example
  • apps/admin/.env.example
  • apps/admin/src/__tests__/instrumentation.test.ts
  • apps/admin/src/instrumentation.ts
  • apps/admin/src/lib/__tests__/monitoring-queries-clickhouse-cutover.test.ts
  • apps/admin/src/lib/__tests__/monitoring-queries.parity.integration.test.ts
  • apps/admin/src/lib/monitoring-queries.ts
  • apps/admin/vitest.config.ts
  • apps/processor/.env.example
  • apps/processor/src/__tests__/server-clickhouse-wiring.test.ts
  • apps/processor/src/server.ts
  • apps/processor/src/workers/account-erasure-worker.ts
  • apps/web/.env.example
  • apps/web/next.config.ts
  • apps/web/src/__tests__/instrumentation.test.ts
  • apps/web/src/app/api/internal/monitoring/ingest/route.ts
  • apps/web/src/instrumentation.ts
  • apps/web/src/lib/monitoring/__tests__/monitoring-queries-clickhouse-cutover.test.ts
  • apps/web/src/lib/monitoring/__tests__/monitoring-queries.parity.integration.test.ts
  • apps/web/src/lib/monitoring/monitoring-queries.ts
  • apps/web/src/lib/repositories/conversation-repository.ts
  • docker-compose.dev.yml
  • docker-compose.yml
  • docs/security/audit-log-retention-policy.md
  • infrastructure/__tests__/root-compose.test.ts
  • packages/db/drizzle/0194_watery_rawhide_kid.sql
  • packages/db/drizzle/meta/0194_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/__tests__/deferred-drop-analytics-tables.test.ts
  • packages/db/src/schema/monitoring.ts
  • packages/lib/package.json
  • packages/lib/src/compliance/erasure/__tests__/erasure-plan.test.ts
  • packages/lib/src/compliance/erasure/__tests__/run-erasure.test.ts
  • packages/lib/src/compliance/erasure/erasure-plan.ts
  • packages/lib/src/compliance/export/gdpr-export.test.ts
  • packages/lib/src/compliance/export/gdpr-export.ts
  • packages/lib/src/compliance/retention/monitoring-retention.test.ts
  • packages/lib/src/compliance/retention/monitoring-retention.ts
  • packages/lib/src/config/__tests__/env-validation.test.ts
  • packages/lib/src/config/env-validation.ts
  • packages/lib/src/logging/__tests__/graceful-shutdown.test.ts
  • packages/lib/src/logging/__tests__/logger-database-clickhouse-cutover.test.ts
  • packages/lib/src/logging/__tests__/logger.test.ts
  • packages/lib/src/logging/__tests__/monitoring-purge.test.ts
  • packages/lib/src/logging/graceful-shutdown.ts
  • packages/lib/src/logging/logger-database.ts
  • packages/lib/src/logging/logger.ts
  • packages/lib/src/logging/monitoring-purge.ts
  • packages/lib/src/observability/__tests__/analytics-gdpr.integration.test.ts
  • packages/lib/src/observability/__tests__/analytics-gdpr.test.ts
  • packages/lib/src/observability/__tests__/analytics-inserts.test.ts
  • packages/lib/src/observability/__tests__/analytics-read-core.test.ts
  • packages/lib/src/observability/__tests__/analytics-rows.test.ts
  • packages/lib/src/observability/__tests__/clickhouse-buffer.test.ts
  • packages/lib/src/observability/__tests__/clickhouse-client.test.ts
  • packages/lib/src/observability/__tests__/clickhouse-ddl.test.ts
  • packages/lib/src/observability/__tests__/clickhouse-env.test.ts
  • packages/lib/src/observability/__tests__/clickhouse-integration.test.ts
  • packages/lib/src/observability/__tests__/error-resolutions.integration.test.ts
  • packages/lib/src/observability/__tests__/error-resolutions.test.ts
  • packages/lib/src/observability/analytics-gdpr.ts
  • packages/lib/src/observability/analytics-inserts.ts
  • packages/lib/src/observability/analytics-read-core.ts
  • packages/lib/src/observability/analytics-reads.ts
  • packages/lib/src/observability/analytics-rows.ts
  • packages/lib/src/observability/clickhouse-buffer.ts
  • packages/lib/src/observability/clickhouse-client.ts
  • packages/lib/src/observability/clickhouse-ddl.ts
  • packages/lib/src/observability/clickhouse-env.ts
  • packages/lib/src/observability/error-resolutions.ts
  • scripts/clickhouse-migrate.ts
  • scripts/deferred-migrations/0890-phase6-drop-analytics-pg-tables.sql

Comment thread apps/processor/src/server.ts
Comment thread apps/web/src/lib/monitoring/monitoring-queries.ts
Comment thread docs/security/audit-log-retention-policy.md Outdated
Comment thread docs/security/audit-log-retention-policy.md Outdated
Comment thread packages/lib/src/logging/logger.ts
2witstudios added a commit that referenced this pull request Jul 10, 2026
…rify CH TTL docs (#890 Phase 3)

Addresses CodeRabbit review findings on PR #1991:

- processor server.ts (Major): wrap drainAnalyticsInserts() and
  queueManager.shutdown() each in try/catch so a rejection can never skip
  process.exit(0) (which would hang the process until SIGKILL). Mirrors the
  per-step guards in packages/lib graceful-shutdown.ts. The drain step was
  added by the Phase 3 FIX, so this hardens the shutdown this phase touched.

- docs/security/audit-log-retention-policy.md (Minor ×2): (1) the CH-tier
  paragraph said retention for all 4 tables is enforced by table TTLs, which
  contradicted error_logs' NO-TTL-by-design row — now names the 3 TTL'd tables
  and calls out error_logs as GDPR-erasure-only; (2) clarified TTL timing —
  stored rows are removed lazily at background merges (windows are visibility
  upper bounds), while optimize_on_insert drops already-aged INSERTED rows,
  which is the actual backfill constraint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXHQJ18NqzJmLjeymPTiKu
@2witstudios

Copy link
Copy Markdown
Owner Author

CI note (not this branch): the red ci / Unit Tests + Lint & TypeScript Check are inherited from a master breakage, not Phase 3. pull_request CI builds this branch merged into the current master tip, and master has been red since #1988 (147b86ed): apps/realtime/src/index.ts:310 calls buildAgentTerminalSessionKey({ terminalId, … }) but #1992's PageType/FK rename renamed the in-scope variable to machineId, so it fails to compile (TS18004, deterministic). Last green master was 34766979b (#1986).

This branch builds and tests clean on its own (full local typecheck 16/16, lib 724, processor 86, instrumentation 4+4, drop-guard 5, live-CH integration 5/5). It's rebased onto latest master and does not touch apps/realtime — the realtime fix belongs on master separately (not bundled into this ClickHouse PR). CI will go green here automatically once master is fixed; I'll rebase + re-verify at that point.

2witstudios and others added 9 commits July 10, 2026 17:53
… lazy client, off-by-default env gating (#890 Phase 3 leaf 1)

Analytics-tier groundwork for moving apiMetrics/systemLogs/userActivities/
errorLogs out of the main Postgres. Ships dark: with CLICKHOUSE_ENABLED
unset (default) nothing changes — the existing PG writes continue.

- createClickHouseBuffer(table, opts) pure factory (packages/lib
  observability/clickhouse-buffer): auto-flush at 500 rows or 1000ms,
  double-buffer swap so inserts never block an in-flight flush, flush
  failures drop the batch and log table+message but never row payloads
  (PII), drain() for SIGTERM/SIGINT. Full TDD surface per design ref.
- Three-state env gating (clickhouse-env, mirrors adminDb's mode
  contract): off → disabled, on+configured → enabled, on+missing/invalid
  config → misconfigured (client shell throws at init — never silently
  drop inserts). Only the exact value CLICKHOUSE_ENABLED='true' enables.
- Lazy client shell (clickhouse-client) over @clickhouse/client with
  injectable deps registry; no env reads or connections at import.
  NOTE: design ref named @clickhouse/client-node, which doesn't exist
  on npm — @clickhouse/client is the official Node.js client.
- CLICKHOUSE_* registered optional in serverEnvSchema; placeholder-only
  blocks in root/web/admin/processor .env.example (credentials are
  server-side secrets).
- Pinned clickhouse/clickhouse-server:25.3-alpine dev/CI service in root
  docker-compose (prod is ClickHouse Cloud; tenant/onprem stacks
  deliberately excluded) + root-compose test coverage.

Smoke: SELECT 1 + currentDatabase() verified against the pinned container
through the real client module path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Px6xZZdMHDxiME9MnhMps4
…cutover of the 4 analytics tables (#890 Phase 3 leaf 2)

MergeTree DDL (design ref: ORDER BY per hot path, LowCardinality on bounded
strings, TTL 90d/30d/180d/none, PARTITION BY toYYYYMM) applied via an
idempotent scripts/clickhouse-migrate.ts — explicit apply, not a startup
bootstrap, so the app runtime credential never needs DDL grants.

Pure (row) → void adapters (insertApiMetric/insertSystemLog/
insertUserActivity/insertError) map PG-shaped writes to snake_case CH rows
(pure mappers, injected id/now) and buffer via createClickHouseBuffer into
client.insert(JSONEachRow); never-throw, payload-free error logging.

Cutover gates on isClickHouseEnabled() inside the logger-database writers
plus the two direct write sites (monitoring ingest route systemLogs,
conversation-repository userActivities). Off (default) = byte-identical PG
behavior; on = CH writes, PG writes for the 4 tables retire. aiUsageLogs
stays PG (Phase 4 CDC); readers stay PG until leaf 3.

Also: clickhouse service added to docker-compose.dev.yml (internal: true
blocks host port publishing — leaf-1 escape) and @clickhouse/client stubbed
out of web client bundles (node:os at module scope, dead code in browser).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbHekMp4SUbgXb2gehD2W3
…de ClickHouse + error_resolutions PG mini-table (#890 Phase 3 leaf 3)

Flag-gated (CLICKHOUSE_ENABLED) re-point of the admin/web monitoring
readers over the 4 moved analytics tables to ClickHouse, with CH-vs-PG
parity tests as the acceptance backbone; off-mode PG paths byte-identical.

- lib: analytics-read-core (pure CH SQL builders + PG-parity row parsers:
  UInt64-string coercion, UTC DateTime→Date, category ''→null un-map,
  toDayOfWeek→PG DOW), analytics-reads (thin server-side client shells,
  errors propagate), error-resolutions (resolve upsert → main PG,
  two-step CH+PG merge reader; stores never SQL-joined)
- db: error_resolutions mini-table in main PG (migration 0194 only) —
  the mutable resolved-flag workflow off immutable CH error rows
- admin: getSystemHealth/getApiMetrics/getErrorAnalytics gated to CH
  (active users stay on PG sessions; email masking on both paths);
  getUserActivity pinned PG-only (activityLogs = Phase 5)
- web: those plus getUserActivity (users JOIN → two-step cross-store
  lookup with ghost-user drop parity) and getPerformanceMetrics
- parity integration tests seed both stores identically in a random
  recent hour-aligned window (CH TTLs drop expired rows at insert —
  deep-past seeding silently no-ops) and assert normalized equality
- admin vitest: @pagespace/db/* catch-all alias (parity test imports)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016nysLv84avY5pKvfrhWjFy
…store export/erasure, deferred PG drop (#890 Phase 3 leaf 4)

- CH TTLs verified correct since table creation (api_metrics 90d,
  system_logs 30d, user_activities 180d, error_logs NONE by design —
  GDPR erasure mutations are its only eraser); already pinned by
  clickhouse-ddl.test.ts, no ALTER needed.
- Admin-PG chain tables confirmed no-drop BOTH (security_audit_log
  Art 17(3)(b); siem_delivery_receipts = anchoring evidence);
  create-ahead-only maintenance already pinned; documented in
  docs/security/audit-log-retention-policy.md.
- GDPR: new observability/analytics-gdpr.ts (pure param-bound
  builders/parsers + fail-closed shells). Art 15 export unions PG+CH
  (CH error rows get resolved from error_resolutions); Art 17 erasure
  (monitoring-purge → admin DSAR + account-erasure-worker) deletes from
  BOTH stores via lightweight DELETE mutations. Off-mode byte-identical.
  Live CH round-trip integration test.
- Retention: runMonitoringRetentionCleanup no-ops for the 4 tables when
  CLICKHOUSE_ENABLED (CH TTLs own retention; pre-cutover PG rows freeze
  until the Phase 6 drop); aiUsageLogs retention untouched.
- Drop PREPARED not executed: scripts/deferred-migrations/
  0890-phase6-drop-analytics-pg-tables.sql (RAISE EXCEPTION guard),
  pinned by packages/db deferred-drop-analytics-tables.test.ts.
  Phase 6 tasks filed: kws85p45pvjnivoz3uxs83yp (execute drop),
  f70ay5fo488veub4fak4feqs (pre-cutover history backfill vs age-out).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SA8aS8EUtws7DuP7QQ4dp7
…e ClickHouse tier (#890 Phase 3 FIX)

Resolves the 2 HIGH + 3 MED findings from the Phase 3 adversarial review.

HIGH — self-service DSR swallowed CH purge failure: purge-monitoring is now
fatal when ClickHouse analytics is in play (buildErasurePlan recomputes it from
clickHouseInPlay), so a CH outage during a worker-path erasure aborts the run
instead of marking it completed with the subject's error rows retained.

HIGH — half-configured deploy silently blacked out telemetry: probeClickHouseStartup()
in every app composition root (web/admin instrumentation.ts, processor server.ts)
crashes on 'misconfigured' (flag on, creds missing) instead of silently dropping
every CH row; drainAnalyticsInserts() wired to graceful shutdown so buffered rows
flush on SIGTERM/SIGINT (createShutdownHandler, never-hang on drain failure).

MED — GDPR flag-rollback fail-open: export/erasure consult ClickHouse whenever it
is CONFIGURED (isClickHouseAnalyticsInPlay), not only when the write flag is on.
MED — deferred-drop guard was bypassable: the 4 DROPs now live inside the DO block
after the RAISE EXCEPTION so a single statement genuinely aborts under psql -f.
MED — error_resolutions orphan assessment recorded.

Resumed after the prior FIX agent died mid-work (session boundary); all changes
verified: lib 719 pass, web/admin instrumentation 5+5, processor wiring 3,
deferred-drop guard 5, typecheck clean, db:generate no-drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HW7dvAzDgKbfMqejGaf5gz
…RM test (#890 Phase 3)

Two escapes from the recovered FIX commit (a6c12ac6a), caught during PR
verification:

- web/admin instrumentation.test.ts: `let onSpy: ReturnType<typeof
  vi.spyOn>` resolves the overloaded process.on spy to the too-restrictive
  MockInstance<(this: unknown, ...args: unknown[]) => unknown>, failing
  `tsc --noEmit` (turbo had served a stale cached admin typecheck predating
  the new test file). Annotate as bare `MockInstance` (defaults to
  Procedure = (...args: any[]) => any) — no `any`, no cast.
- processor server.ts: the FIX wrapped the SIGTERM/SIGINT handler as
  `() => { void shutdown(sig); }`, a fire-and-forget that broke the
  pre-existing server.test.ts assertion (`await capturedHandler()` no longer
  awaited the drain → queue-shutdown → exit chain). Return the shutdown()
  promise instead — Node ignores handler return values at runtime (identical
  to the original async handler), restoring test awaitability.

Verified: monorepo typecheck 16/16, lib 719, web/admin instrumentation 5+5,
processor server 83 (incl. SIGTERM) + wiring 3, drop-guard 5, live-CH
integration 5/5, lint clean, db:generate no drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXHQJ18NqzJmLjeymPTiKu
…sh→drain→exit (#890 Phase 3)

Codex review (P2): the web/admin instrumentation registered a bespoke
SIGTERM/SIGINT listener that called drainAnalyticsInserts() but never awaited
it and never exited. Registering a signal listener suppresses Node's default
termination, so an idle process could keep running until SIGKILL, losing the
graceful-drain window the handler was meant to protect.

Root cause: the handler's premise ("covers stdout-only processes where the
logger's handler never installs") was false. Logger.getInstance() runs at the
logger module's top level and its constructor unconditionally calls
startFlushTimer(), which registers a COMPLETE flush → drain → exit handler via
createShutdownHandler — regardless of LOG_DESTINATION. That handler flushes
buffered logs, drains ALL ClickHouse insert buffers (including direct adapter
inserts), then exits. It is the single, terminating owner of graceful shutdown.

Fix: drop the redundant, never-terminating drain-only listener from both
composition roots and instead deterministically initialize the shared logger
at boot (`await import('@pagespace/lib/logging/logger')`), so its shutdown
handler is installed even for a process that receives a signal before serving
its first request. Tests now assert the composition root registers NO bespoke
signal listener (shutdown owned by the logger, covered by
graceful-shutdown.test.ts).

Verified: web/admin instrumentation 4+4, typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXHQJ18NqzJmLjeymPTiKu
…over #1992

Master's #1992 (machines rename) claimed migration 0194 (PageType
TERMINAL→MACHINE) + 0195 (machineId data migration), colliding with this
branch's 0194 error_resolutions migration. Per repo rules (never hand-edit
drizzle SQL), dropped the colliding 0194 during rebase and regenerated the
error_resolutions table via `bun run db:generate` — now 0196_married_blink,
lineage 0195 → 0196. Table definition is byte-identical to the original
(error_id PK, resolved bool default true, resolved_at, resolved_by, resolution).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXHQJ18NqzJmLjeymPTiKu
…rify CH TTL docs (#890 Phase 3)

Addresses CodeRabbit review findings on PR #1991:

- processor server.ts (Major): wrap drainAnalyticsInserts() and
  queueManager.shutdown() each in try/catch so a rejection can never skip
  process.exit(0) (which would hang the process until SIGKILL). Mirrors the
  per-step guards in packages/lib graceful-shutdown.ts. The drain step was
  added by the Phase 3 FIX, so this hardens the shutdown this phase touched.

- docs/security/audit-log-retention-policy.md (Minor ×2): (1) the CH-tier
  paragraph said retention for all 4 tables is enforced by table TTLs, which
  contradicted error_logs' NO-TTL-by-design row — now names the 3 TTL'd tables
  and calls out error_logs as GDPR-erasure-only; (2) clarified TTL timing —
  stored rows are removed lazily at background merges (windows are visibility
  upper bounds), while optimize_on_insert drops already-aged INSERTED rows,
  which is the actual backfill constraint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXHQJ18NqzJmLjeymPTiKu
@2witstudios
2witstudios merged commit d357a67 into master Jul 10, 2026
10 checks passed
@2witstudios
2witstudios deleted the pu/db-decomp-p3 branch July 13, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant