Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ jobs:
env:
CI: true
run: pnpm --filter cwv-monitor-app test:perf

- name: Run monitor anomaly detection tests
if: steps.changes.outputs.monitor_changed == 'true'
env:
CI: true
run: pnpm --filter cwv-monitor-app test:anomaly

# SDK can destroy client build, so let's verify it here too
- name: Check demo (Build)
Expand Down
20 changes: 20 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
```
apps/
monitor-app # Next.js dashboard & API
anomaly-worker # Sidecar service for anomaly detection (new)

packages/
client-sdk # Browser SDK for CWV collection
Expand All @@ -22,15 +23,24 @@ flowchart LR
Ingest["POST /api/ingest<br>schema + rate limit"]
Dashboard["Dashboard UI + auth"]
end
subgraph Worker["Anomaly Worker"]
Poller["Poller (node-cron)"]
Notifier["Slack/Teams Notifier"]
end
subgraph CH["ClickHouse"]
Raw["cwv_events & custom_events<br>MergeTree, TTL 90d"]
Agg["cwv_daily_aggregates<br>AggregatingMergeTree, 365d"]
Anom["v_cwv_anomalies (View)<br>Z-Score logic"]
end
SDK -->|"batched payload"| Ingest
Ingest -->|"validated & enriched"| Raw
Raw -->|"mv_cwv_daily_aggregates"| Agg
Dashboard -->|"analytics queries"| Agg
Dashboard -->|"drill-down"| Raw
Poller -->|"polls every hour"| Anom
Poller -->|"fetches project info"| Raw
Poller -->|"marks as notified"| Raw
Poller -->|"sends notifications"| Notifier
```

```mermaid
Expand Down Expand Up @@ -74,6 +84,16 @@ Lightweight browser SDK that collects CWV metrics (LCP, INP, CLS, TTFB, FCP), `$

Shared TypeScript schemas for ingest payloads. Imported by both SDK and monitor app to prevent drift.

### 3.4. Anomaly Worker (`apps/anomaly-worker`)

A lightweight sidecar service that periodically polls ClickHouse for statistical anomalies (z_score > 3) and sends notifications.

- **Stack:** Node.js, node-cron, ClickHouse client, pino
- **Polling:** Scheduled every hour to detect new regressions
- **State:** Tracks notified anomalies in `processed_anomalies` table to prevent duplicates
- **Notifications:** Supports Slack and Microsoft Teams webhooks
- **Deployment:** Dedicated sidecar container (see `docker/anomaly-worker.Dockerfile`)

## 4. Data Store

**ClickHouse** — High-performance columnar database for analytics.
Expand Down
20 changes: 20 additions & 0 deletions apps/anomaly-worker/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# ClickHouse connection details
CLICKHOUSE_HOST=localhost
CLICKHOUSE_PORT=8123
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=secret
CLICKHOUSE_DB=cwv_monitor
AI_ANALYST_CLICKHOUSE_PASSWORD=
BETTER_AUTH_SECRET=
INITIAL_USER_EMAIL=
INITIAL_USER_PASSWORD=
INITIAL_USER_NAME=

# Application
LOG_LEVEL=info
NODE_ENV=development
AUTH_BASE_URL=http://localhost:3000

# Anomaly Detection Webhooks (Optional)
SLACK_WEBHOOK_URL=
TEAMS_WEBHOOK_URL=
1 change: 1 addition & 0 deletions apps/anomaly-worker/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
41 changes: 41 additions & 0 deletions apps/anomaly-worker/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "anomaly-worker",
"version": "0.1.0",
"private": true,
"description": "A lightweight worker for detecting anomalies.",
"main": "dist/anomaly-worker/src/index.js",
"type": "module",
"scripts": {
"prebuild": "rm -rf dist",
"build": "tsc",
"start": "cross-env NODE_OPTIONS=\"-r dotenv/config\" tsx src/index.ts",
"dev": "tsx src/index.ts | pino-pretty",
"test": "vitest",
"test:watch": "vitest --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@clickhouse/client": "^1.14.0",
"@t3-oss/env-nextjs": "^0.13.8",
"arktype": "^2.1.28",
"date-fns": "^4.1.0",
"node-cron": "^3.0.3",
"pino": "^10.1.0",
"remeda": "^2.32.0",
"waddler": "^0.1.1",
"zod": "^4.1.13",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/node": "^24",
"@types/node-cron": "^3.0.11",
"pino-pretty": "^13.0.0",
"tsx": "^4.19.2",
"typescript": "^5.9.3",
"vitest": "^4.0.15",
"tsconfig-paths": "^4.2.0",
"cross-env": "^7.0.3"
}
}
54 changes: 54 additions & 0 deletions apps/anomaly-worker/src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

const LOG_LEVELS = ["fatal", "error", "warn", "info", "debug", "trace", "silent"] as const;

export const env = createEnv({
server: {
AUTH_BASE_URL: z.url(),
TRUST_PROXY: z.enum(["true", "false"]).default("false"),
CLICKHOUSE_HOST: z.string().min(1, "CLICKHOUSE_HOST is required"),
CLICKHOUSE_PORT: z.string().min(1, "CLICKHOUSE_PORT is required"),
CLICKHOUSE_USER: z.string().min(1, "CLICKHOUSE_USER is required"),
CLICKHOUSE_PASSWORD: z.string(),
CLICKHOUSE_DB: z.string().min(1, "CLICKHOUSE_DB is required"),
AI_ANALYST_CLICKHOUSE_USER: z.string().min(1).default("ai_analyst_user"),
AI_ANALYST_CLICKHOUSE_PASSWORD: z.string().min(1),
BETTER_AUTH_SECRET: z.string(),
CLICKHOUSE_ADAPTER_DEBUG_LOGS: z.coerce.boolean().default(false),
MIN_PASSWORD_SCORE: z.coerce.number().min(0).max(4).default(2),
RATE_LIMIT_WINDOW_MS: z.coerce.number().positive().default(60_000),
MAX_LOGIN_ATTEMPTS: z.coerce.number().positive().default(5),
INITIAL_USER_EMAIL: z.email(),
INITIAL_USER_PASSWORD: z.string().min(8),
INITIAL_USER_NAME: z.string().min(3),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
LOG_LEVEL: z.enum(LOG_LEVELS).default("info"),
SLACK_WEBHOOK_URL: z.url().optional(),
TEAMS_WEBHOOK_URL: z.url().optional(),
},
client: {},
runtimeEnv: {
AUTH_BASE_URL: process.env.AUTH_BASE_URL,
TRUST_PROXY: process.env.TRUST_PROXY,
CLICKHOUSE_HOST: process.env.CLICKHOUSE_HOST,
CLICKHOUSE_PORT: process.env.CLICKHOUSE_PORT,
CLICKHOUSE_USER: process.env.CLICKHOUSE_USER,
CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD,
CLICKHOUSE_DB: process.env.CLICKHOUSE_DB,
AI_ANALYST_CLICKHOUSE_USER: process.env.AI_ANALYST_CLICKHOUSE_USER,
AI_ANALYST_CLICKHOUSE_PASSWORD: process.env.AI_ANALYST_CLICKHOUSE_PASSWORD,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
CLICKHOUSE_ADAPTER_DEBUG_LOGS: process.env.CLICKHOUSE_ADAPTER_DEBUG_LOGS,
MIN_PASSWORD_SCORE: process.env.MIN_PASSWORD_SCORE,
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
MAX_LOGIN_ATTEMPTS: process.env.MAX_LOGIN_ATTEMPTS,
INITIAL_USER_EMAIL: process.env.INITIAL_USER_EMAIL,
INITIAL_USER_PASSWORD: process.env.INITIAL_USER_PASSWORD,
INITIAL_USER_NAME: process.env.INITIAL_USER_NAME,
NODE_ENV: process.env.NODE_ENV,
LOG_LEVEL: process.env.LOG_LEVEL,
SLACK_WEBHOOK_URL: process.env.SLACK_WEBHOOK_URL,
TEAMS_WEBHOOK_URL: process.env.TEAMS_WEBHOOK_URL,
},
});
57 changes: 57 additions & 0 deletions apps/anomaly-worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import pino from 'pino';
import cron from 'node-cron';
import { env } from "./env";

const logger = pino({ name: 'anomaly-worker', level: env.LOG_LEVEL });

let isRunning = false;
let isShuttingDown = false;

async function runDetectionCycle() {
if (isRunning || isShuttingDown) {
return;
}

isRunning = true;
logger.info("Starting anomaly detection cycle...");

try {
const { NotificationsService } = await import('@monitor-app/app/server/domain/notifications/service');
const service = new NotificationsService();

await service.notifyNewAnomalies();
logger.info("Anomaly detection cycle finished.");
} catch (error) {
logger.error({ err: error }, "Error during detection cycle.");
} finally {
isRunning = false;
}
}

const task = cron.schedule('30 * * * *', runDetectionCycle);
logger.info("Anomaly worker scheduled (30 * * * *).");

runDetectionCycle();

process.on('SIGTERM', async () => {
isShuttingDown = true;
task.stop();

if (isRunning) {
logger.info("Waiting for the current detection cycle to complete...");

const shutdownTimeout = setTimeout(() => {
logger.error("Shutdown timed out; forcing exit.");
process.exit(1);
}, 20000);

while (isRunning) {
await new Promise(resolve => setTimeout(resolve, 500));
}

clearTimeout(shutdownTimeout);
}

logger.info("Shutdown complete.");
process.exit(0);
});
18 changes: 18 additions & 0 deletions apps/anomaly-worker/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": ["./src/*", "../../apps/monitor-app/src/*"],
"@monitor-app/*": ["../monitor-app/src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
7 changes: 7 additions & 0 deletions apps/monitor-app/.env.ci
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ CLICKHOUSE_PORT=18123
CLICKHOUSE_DB=cwv_monitor_test
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=secret
AI_ANALYST_CLICKHOUSE_USER=ai_analyst_user
AI_ANALYST_CLICKHOUSE_PASSWORD=ai_analyst_password

# Auth (required by `src/env.ts`)
BETTER_AUTH_SECRET=ci-test-secret-that-is-at-least-32-chars-long
Expand All @@ -16,5 +18,10 @@ INITIAL_USER_EMAIL=test@example.com
INITIAL_USER_PASSWORD=testpassword123
INITIAL_USER_NAME=Test User

# AI Configuration (required by `src/env.ts`)
AI_API_KEY=api_key
AI_PROVIDER=ai_provider
AI_MODEL=ai_model

# Logging (keep CI output quieter)
LOG_LEVEL=silent
11 changes: 11 additions & 0 deletions apps/monitor-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ CLICKHOUSE_PORT=8123
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=secret
CLICKHOUSE_DB=cwv_monitor
AI_ANALYST_CLICKHOUSE_USER=ai_analyst_user
AI_ANALYST_CLICKHOUSE_PASSWORD=ai_analyst_password

# Optional overrides for the clickhouse-migrations CLI
CH_MIGRATIONS_HOST=${CLICKHOUSE_HOST}
Expand All @@ -23,3 +25,12 @@ INITIAL_USER_PASSWORD=password
INITIAL_USER_NAME=User

MIN_PASSWORD_SCORE=2

# AI Configuration (Optional)
AI_API_KEY=
AI_PROVIDER=
AI_MODEL=

# Anomaly Detection Webhooks (Optional)
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
TEAMS_WEBHOOK_URL=https://outlook.office.com/webhook/...
3 changes: 3 additions & 0 deletions apps/monitor-app/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ CLICKHOUSE_PORT=18123
CLICKHOUSE_DB=cwv_monitor_test
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=secret
AI_ANALYST_CLICKHOUSE_USER=ai_analyst_user
AI_ANALYST_CLICKHOUSE_PASSWORD=ai_analyst_password

CH_MIGRATIONS_HOST=localhost
CH_MIGRATIONS_PORT=8123
Expand All @@ -17,5 +19,6 @@ INITIAL_USER_EMAIL=initial@example.com
INITIAL_USER_PASSWORD=password1234
INITIAL_USER_NAME=Initial User


# Logging
LOG_LEVEL=debug
Loading