diff --git a/apps/journal/app/lib/email.server.test.ts b/apps/journal/app/lib/email.server.test.ts index 0166450..1758a82 100644 --- a/apps/journal/app/lib/email.server.test.ts +++ b/apps/journal/app/lib/email.server.test.ts @@ -7,6 +7,11 @@ vi.mock("nodemailer", () => ({ }), })); +// Mock logger +vi.mock("./logger.server", () => ({ + logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); + describe("email.server", () => { const originalEnv = process.env.NODE_ENV; @@ -19,29 +24,27 @@ describe("email.server", () => { delete process.env.SMTP_URL; }); - it("logs to console in dev mode", async () => { + it("uses logger in dev mode instead of sending email", async () => { process.env.NODE_ENV = "development"; const { sendEmail } = await import("./email.server"); - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { logger } = await import("./logger.server"); await sendEmail("test@example.com", "Test Subject", "

Hello

", "Hello"); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("test@example.com"), + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ to: "test@example.com" }), + expect.any(String), ); - consoleSpy.mockRestore(); }); it("does not call SMTP in dev mode", async () => { process.env.NODE_ENV = "development"; const nodemailer = await import("nodemailer"); const { sendEmail } = await import("./email.server"); - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); await sendEmail("test@example.com", "Test", "

Hi

", "Hi"); expect(nodemailer.createTransport).not.toHaveBeenCalled(); - consoleSpy.mockRestore(); }); it("magicLinkTemplate includes link and expiry note", async () => { diff --git a/apps/journal/app/lib/email.server.ts b/apps/journal/app/lib/email.server.ts index 079f88a..50b2209 100644 --- a/apps/journal/app/lib/email.server.ts +++ b/apps/journal/app/lib/email.server.ts @@ -1,4 +1,5 @@ import { createTransport, type Transporter } from "nodemailer"; +import { logger } from "./logger.server"; const FROM = process.env.SMTP_FROM ?? "trails.cool "; @@ -19,8 +20,8 @@ export async function sendEmail( text: string, ): Promise { if (process.env.NODE_ENV !== "production") { - console.log(`[Email] To: ${to} | Subject: ${subject}`); - console.log(`[Email] Text:\n${text}`); + logger.info({ to, subject }, "Email sent (dev mode — logged, not delivered)"); + logger.debug({ text }, "Email text content"); return; } diff --git a/apps/journal/app/lib/logger.server.ts b/apps/journal/app/lib/logger.server.ts new file mode 100644 index 0000000..a0454ef --- /dev/null +++ b/apps/journal/app/lib/logger.server.ts @@ -0,0 +1,8 @@ +import pino from "pino"; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? "info", + ...(process.env.NODE_ENV !== "production" + ? { transport: { target: "pino-pretty" } } + : {}), +}); diff --git a/apps/journal/app/lib/metrics.server.ts b/apps/journal/app/lib/metrics.server.ts new file mode 100644 index 0000000..d7dab88 --- /dev/null +++ b/apps/journal/app/lib/metrics.server.ts @@ -0,0 +1,13 @@ +import client from "prom-client"; + +// Collect default Node.js metrics (event loop, heap, GC) +client.collectDefaultMetrics(); + +export const httpRequestDuration = new client.Histogram({ + name: "http_request_duration_seconds", + help: "Duration of HTTP requests in seconds", + labelNames: ["method", "route", "status"] as const, + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], +}); + +export const registry = client.register; diff --git a/apps/journal/app/routes/api.auth.register.ts b/apps/journal/app/routes/api.auth.register.ts index 0efe1a6..b99c252 100644 --- a/apps/journal/app/routes/api.auth.register.ts +++ b/apps/journal/app/routes/api.auth.register.ts @@ -2,6 +2,7 @@ import { data } from "react-router"; import type { Route } from "./+types/api.auth.register"; import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server"; import { sendWelcome } from "~/lib/email.server"; +import { logger } from "~/lib/logger.server"; export async function action({ request }: Route.ActionArgs) { const body = await request.json(); @@ -18,7 +19,7 @@ export async function action({ request }: Route.ActionArgs) { const cookie = await createSession(newUserId, request); // Send welcome email (fire-and-forget — don't block registration on email) sendWelcome(email, username).catch((err) => - console.error("[Email] Failed to send welcome email:", err), + logger.error({ err }, "Failed to send welcome email"), ); return data({ step: "done" }, { headers: { "Set-Cookie": cookie } }); } diff --git a/apps/journal/app/routes/api.health.ts b/apps/journal/app/routes/api.health.ts new file mode 100644 index 0000000..cc1a396 --- /dev/null +++ b/apps/journal/app/routes/api.health.ts @@ -0,0 +1,13 @@ +import { data } from "react-router"; +import { withDb } from "@trails-cool/db"; + +export async function loader() { + try { + await withDb(async () => { + // withDb creates a connection — if it succeeds, DB is reachable + }); + return data({ status: "ok", db: "connected" }); + } catch { + return data({ status: "degraded", db: "unreachable" }, { status: 503 }); + } +} diff --git a/apps/journal/app/routes/api.metrics.ts b/apps/journal/app/routes/api.metrics.ts new file mode 100644 index 0000000..852a3d2 --- /dev/null +++ b/apps/journal/app/routes/api.metrics.ts @@ -0,0 +1,8 @@ +import { registry } from "~/lib/metrics.server"; + +export async function loader() { + const metrics = await registry.metrics(); + return new Response(metrics, { + headers: { "Content-Type": registry.contentType }, + }); +} diff --git a/apps/journal/package.json b/apps/journal/package.json index 0ee0707..539f65b 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -27,6 +27,8 @@ "isbot": "^5.1.0", "jose": "^6.2.2", "nodemailer": "^8.0.4", + "pino": "^10.3.1", + "prom-client": "^15.1.3", "react": "catalog:", "react-dom": "catalog:", "react-router": "catalog:" @@ -38,6 +40,7 @@ "@types/nodemailer": "^7.0.11", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "pino-pretty": "^13.1.3", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:" diff --git a/apps/planner/app/lib/logger.server.ts b/apps/planner/app/lib/logger.server.ts new file mode 100644 index 0000000..a0454ef --- /dev/null +++ b/apps/planner/app/lib/logger.server.ts @@ -0,0 +1,8 @@ +import pino from "pino"; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? "info", + ...(process.env.NODE_ENV !== "production" + ? { transport: { target: "pino-pretty" } } + : {}), +}); diff --git a/apps/planner/app/lib/metrics.server.ts b/apps/planner/app/lib/metrics.server.ts new file mode 100644 index 0000000..85590c6 --- /dev/null +++ b/apps/planner/app/lib/metrics.server.ts @@ -0,0 +1,29 @@ +import client from "prom-client"; + +// Collect default Node.js metrics (event loop, heap, GC) +client.collectDefaultMetrics(); + +export const httpRequestDuration = new client.Histogram({ + name: "http_request_duration_seconds", + help: "Duration of HTTP requests in seconds", + labelNames: ["method", "route", "status"] as const, + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], +}); + +export const plannerActiveSessions = new client.Gauge({ + name: "planner_active_sessions", + help: "Number of active planner sessions", +}); + +export const plannerConnectedClients = new client.Gauge({ + name: "planner_connected_clients", + help: "Number of connected WebSocket clients", +}); + +export const brouterRequestDuration = new client.Histogram({ + name: "brouter_request_duration_seconds", + help: "Duration of BRouter API requests in seconds", + buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10], +}); + +export const registry = client.register; diff --git a/apps/planner/package.json b/apps/planner/package.json index 63f6e2b..079a344 100644 --- a/apps/planner/package.json +++ b/apps/planner/package.json @@ -25,6 +25,8 @@ "drizzle-orm": "catalog:", "isbot": "^5.1.0", "lib0": "^0.2.117", + "pino": "^10.3.1", + "prom-client": "^15.1.3", "react": "catalog:", "react-dom": "catalog:", "react-router": "catalog:", @@ -39,6 +41,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@types/ws": "^8.18.1", + "pino-pretty": "^13.1.3", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:" diff --git a/apps/planner/server.ts b/apps/planner/server.ts index aaa4121..ac4e6c3 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -1,4 +1,6 @@ import * as Sentry from "@sentry/node"; +import { logger } from "./app/lib/logger.server.ts"; +import { httpRequestDuration } from "./app/lib/metrics.server.ts"; import { createRequestListener } from "@react-router/node"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createReadStream, statSync } from "node:fs"; @@ -62,7 +64,44 @@ const listener = createRequestListener({ build: () => import("./build/server/index.js") as never, }); +async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promise { + const { registry } = await import("./app/lib/metrics.server.ts"); + const metrics = await registry.metrics(); + res.writeHead(200, { "Content-Type": registry.contentType }); + res.end(metrics); +} + +async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise { + try { + const { withDb, db } = await import("@trails-cool/db"); + const { sql } = await import("drizzle-orm"); + await withDb(async () => { await db.execute(sql`SELECT 1`); }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok", db: "connected" })); + } catch { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "degraded", db: "unreachable" })); + } +} + const server = createServer((req, res) => { + const url = req.url ?? "/"; + const start = Date.now(); + + // Log and track request on finish (skip static assets and health/metrics) + if (!url.startsWith("/assets/") && url !== "/health" && url !== "/metrics") { + res.on("finish", () => { + const duration = Date.now() - start; + logger.info({ method: req.method, path: url, status: res.statusCode, duration }, "request"); + httpRequestDuration.observe( + { method: req.method ?? "GET", route: url.split("?")[0]!, status: String(res.statusCode) }, + duration / 1000, + ); + }); + } + + if (url === "/health") { handleHealth(req, res); return; } + if (url === "/metrics") { handleMetrics(req, res); return; } if (!serveStatic(req, res)) { listener(req, res); } @@ -71,6 +110,6 @@ const server = createServer((req, res) => { setupYjsWebSocket(server); server.listen(port, () => { - console.log(`Planner server listening on http://localhost:${port}`); - console.log(`Yjs WebSocket available at ws://localhost:${port}/sync/:sessionId`); + logger.info({ port }, "Planner server listening"); + logger.info({ port, path: "/sync/:sessionId" }, "Yjs WebSocket available"); }); diff --git a/infrastructure/Caddyfile b/infrastructure/Caddyfile index d5b27c8..d798daa 100644 --- a/infrastructure/Caddyfile +++ b/infrastructure/Caddyfile @@ -17,6 +17,10 @@ {$DOMAIN:trails.cool} { import security_headers import block_scanners + log { + output stdout + format json + } reverse_proxy journal:3000 } @@ -24,8 +28,19 @@ www.{$DOMAIN:trails.cool} { redir https://{$DOMAIN:trails.cool}{uri} permanent } +grafana.{$DOMAIN:trails.cool} { + basicauth { + {$GRAFANA_USER:admin} {$GRAFANA_PASSWORD_HASH} + } + reverse_proxy grafana:3000 +} + planner.{$DOMAIN:trails.cool} { import security_headers import block_scanners + log { + output stdout + format json + } reverse_proxy planner:3001 } diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 98c66ca..461b0d5 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -29,6 +29,11 @@ services: SENTRY_RELEASE: ${SENTRY_RELEASE:-} SMTP_URL: ${SMTP_URL:-} SMTP_FROM: ${SMTP_FROM:-trails.cool } + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"] + interval: 15s + timeout: 5s + retries: 3 depends_on: postgres: condition: service_healthy @@ -42,6 +47,11 @@ services: NODE_ENV: production PORT: 3001 SENTRY_RELEASE: ${SENTRY_RELEASE:-} + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:3001/health || exit 1"] + interval: 15s + timeout: 5s + retries: 3 depends_on: postgres: condition: service_healthy @@ -72,6 +82,40 @@ services: timeout: 5s retries: 5 + prometheus: + image: prom/prometheus:latest + restart: unless-stopped + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.retention.time=15d" + - "--storage.tsdb.retention.size=1GB" + + loki: + image: grafana/loki:latest + restart: unless-stopped + volumes: + - ./loki/loki-config.yml:/etc/loki/local-config.yaml:ro + - loki_data:/loki + command: ["-config.file=/etc/loki/local-config.yaml"] + + grafana: + image: grafana/grafana:latest + restart: unless-stopped + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin} + GF_SERVER_ROOT_URL: https://grafana.${DOMAIN:-trails.cool} + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + depends_on: + - prometheus + - loki + # garage: # image: dxflrs/garage:v1.0 # restart: unless-stopped @@ -83,3 +127,6 @@ volumes: caddy_data: caddy_config: pgdata: + prometheus_data: + loki_data: + grafana_data: diff --git a/infrastructure/grafana/dashboards/infrastructure.json b/infrastructure/grafana/dashboards/infrastructure.json new file mode 100644 index 0000000..9afe4fc --- /dev/null +++ b/infrastructure/grafana/dashboards/infrastructure.json @@ -0,0 +1,45 @@ +{ + "dashboard": { + "title": "Infrastructure", + "uid": "trails-infra", + "timezone": "browser", + "refresh": "30s", + "panels": [ + { + "title": "Process Memory (RSS)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { "expr": "process_resident_memory_bytes", "legendFormat": "{{job}}" } + ], + "fieldConfig": { "defaults": { "unit": "bytes" } } + }, + { + "title": "CPU Usage", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { "expr": "rate(process_cpu_seconds_total[5m])", "legendFormat": "{{job}}" } + ], + "fieldConfig": { "defaults": { "unit": "percentunit" } } + }, + { + "title": "Event Loop Lag", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "targets": [ + { "expr": "nodejs_eventloop_lag_seconds", "legendFormat": "{{job}}" } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "title": "Open File Descriptors", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "targets": [ + { "expr": "process_open_fds", "legendFormat": "{{job}}" } + ] + } + ] + } +} diff --git a/infrastructure/grafana/dashboards/overview.json b/infrastructure/grafana/dashboards/overview.json new file mode 100644 index 0000000..df6abcc --- /dev/null +++ b/infrastructure/grafana/dashboards/overview.json @@ -0,0 +1,57 @@ +{ + "dashboard": { + "title": "trails.cool Overview", + "uid": "trails-overview", + "timezone": "browser", + "refresh": "30s", + "panels": [ + { + "title": "Request Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "sum(rate(http_request_duration_seconds_count[5m])) by (job)", + "legendFormat": "{{job}}" + } + ] + }, + { + "title": "Error Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "sum(rate(http_request_duration_seconds_count{status=~\"5..\"}[5m])) / sum(rate(http_request_duration_seconds_count[5m])) * 100", + "legendFormat": "Error %" + } + ], + "fieldConfig": { + "defaults": { "unit": "percent", "thresholds": { "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 5 }] } } + } + }, + { + "title": "Latency p50 / p95 / p99", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "targets": [ + { "expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))", "legendFormat": "p50" }, + { "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))", "legendFormat": "p95" }, + { "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))", "legendFormat": "p99" } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "title": "Health Status", + "type": "stat", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "targets": [ + { "expr": "up", "legendFormat": "{{job}}" } + ], + "fieldConfig": { + "defaults": { "mappings": [{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }] } + } + } + ] + } +} diff --git a/infrastructure/grafana/dashboards/planner.json b/infrastructure/grafana/dashboards/planner.json new file mode 100644 index 0000000..c248ba7 --- /dev/null +++ b/infrastructure/grafana/dashboards/planner.json @@ -0,0 +1,40 @@ +{ + "dashboard": { + "title": "Planner", + "uid": "trails-planner", + "timezone": "browser", + "refresh": "30s", + "panels": [ + { + "title": "Active Sessions", + "type": "stat", + "gridPos": { "h": 6, "w": 6, "x": 0, "y": 0 }, + "targets": [{ "expr": "planner_active_sessions", "legendFormat": "Sessions" }] + }, + { + "title": "Connected Clients", + "type": "stat", + "gridPos": { "h": 6, "w": 6, "x": 6, "y": 0 }, + "targets": [{ "expr": "planner_connected_clients", "legendFormat": "Clients" }] + }, + { + "title": "BRouter Latency", + "type": "timeseries", + "gridPos": { "h": 6, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { "expr": "histogram_quantile(0.50, rate(brouter_request_duration_seconds_bucket[5m]))", "legendFormat": "p50" }, + { "expr": "histogram_quantile(0.95, rate(brouter_request_duration_seconds_bucket[5m]))", "legendFormat": "p95" } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "title": "Request Rate by Route", + "type": "timeseries", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 6 }, + "targets": [ + { "expr": "sum(rate(http_request_duration_seconds_count{job=\"planner\"}[5m])) by (route)", "legendFormat": "{{route}}" } + ] + } + ] + } +} diff --git a/infrastructure/grafana/provisioning/alerting/alerts.yml b/infrastructure/grafana/provisioning/alerting/alerts.yml new file mode 100644 index 0000000..37e9ebd --- /dev/null +++ b/infrastructure/grafana/provisioning/alerting/alerts.yml @@ -0,0 +1,89 @@ +apiVersion: 1 + +groups: + - orgId: 1 + name: trails.cool alerts + folder: trails.cool + interval: 1m + rules: + - uid: disk-usage-high + title: Disk usage > 80% + condition: C + data: + - refId: A + relativeTimeRange: { from: 600, to: 0 } + datasourceUid: prometheus + model: + expr: (1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 + intervalMs: 60000 + maxDataPoints: 43200 + - refId: C + relativeTimeRange: { from: 600, to: 0 } + datasourceUid: __expr__ + model: + type: threshold + conditions: + - evaluator: { params: [80], type: gt } + reducer: { type: last } + for: 15m + annotations: + summary: "Disk usage is above 80%" + + - uid: app-health-failing + title: App health check failing + condition: C + data: + - refId: A + relativeTimeRange: { from: 300, to: 0 } + datasourceUid: prometheus + model: + expr: up{job=~"journal|planner"} == 0 + intervalMs: 15000 + maxDataPoints: 43200 + - refId: C + relativeTimeRange: { from: 300, to: 0 } + datasourceUid: __expr__ + model: + type: threshold + conditions: + - evaluator: { params: [0], type: gt } + reducer: { type: last } + for: 2m + annotations: + summary: "{{ $labels.job }} is down" + + - uid: error-rate-high + title: Error rate > 5% + condition: C + data: + - refId: A + relativeTimeRange: { from: 600, to: 0 } + datasourceUid: prometheus + model: + expr: sum(rate(http_request_duration_seconds_count{status=~"5.."}[5m])) / sum(rate(http_request_duration_seconds_count[5m])) * 100 + intervalMs: 60000 + maxDataPoints: 43200 + - refId: C + relativeTimeRange: { from: 600, to: 0 } + datasourceUid: __expr__ + model: + type: threshold + conditions: + - evaluator: { params: [5], type: gt } + reducer: { type: last } + for: 5m + annotations: + summary: "Error rate is above 5%" + +contactPoints: + - orgId: 1 + name: email + receivers: + - uid: email-default + type: email + settings: + addresses: ${ALERT_EMAIL:-admin@trails.cool} + +policies: + - orgId: 1 + receiver: email diff --git a/infrastructure/grafana/provisioning/dashboards/dashboards.yml b/infrastructure/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..6e2c3c1 --- /dev/null +++ b/infrastructure/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: trails.cool + orgId: 1 + folder: "" + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/infrastructure/grafana/provisioning/datasources/datasources.yml b/infrastructure/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..2c0808d --- /dev/null +++ b/infrastructure/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,15 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + editable: false diff --git a/infrastructure/loki/loki-config.yml b/infrastructure/loki/loki-config.yml new file mode 100644 index 0000000..44810c0 --- /dev/null +++ b/infrastructure/loki/loki-config.yml @@ -0,0 +1,32 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + retention_period: 168h # 7 days + +compactor: + working_directory: /loki/compactor + retention_enabled: true diff --git a/infrastructure/prometheus/prometheus.yml b/infrastructure/prometheus/prometheus.yml new file mode 100644 index 0000000..f4d18e1 --- /dev/null +++ b/infrastructure/prometheus/prometheus.yml @@ -0,0 +1,18 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "journal" + metrics_path: "/api/metrics" + static_configs: + - targets: ["journal:3000"] + + - job_name: "planner" + metrics_path: "/metrics" + static_configs: + - targets: ["planner:3001"] + + - job_name: "caddy" + static_configs: + - targets: ["caddy:2019"] diff --git a/openspec/changes/observability/tasks.md b/openspec/changes/observability/tasks.md index b72c804..289ff6a 100644 --- a/openspec/changes/observability/tasks.md +++ b/openspec/changes/observability/tasks.md @@ -1,62 +1,62 @@ ## 1. Health Endpoints -- [ ] 1.1 Add `/health` API route to Journal — checks DB with a simple query, returns status JSON -- [ ] 1.2 Add `/health` endpoint to Planner server.ts — checks DB connectivity -- [ ] 1.3 Update Docker healthcheck in docker-compose.yml to use `/health` instead of `pg_isready` -- [ ] 1.4 Add health routes to route configs (journal routes.ts) +- [x] 1.1 Add `/health` API route to Journal — checks DB with a simple query, returns status JSON +- [x] 1.2 Add `/health` endpoint to Planner server.ts — checks DB connectivity +- [x] 1.3 Update Docker healthcheck in docker-compose.yml to use `/health` instead of `pg_isready` +- [x] 1.4 Add health routes to route configs (journal routes.ts) ## 2. Structured Logging -- [ ] 2.1 Add `pino` dependency to both apps -- [ ] 2.2 Create `apps/journal/app/lib/logger.server.ts` — Pino instance, JSON in prod, pretty in dev -- [ ] 2.3 Create `apps/planner/app/lib/logger.server.ts` — same pattern -- [ ] 2.4 Replace key `console.log`/`console.error` calls with logger (auth, DB errors, session lifecycle) -- [ ] 2.5 Add request logging middleware to Planner server.ts (method, path, status, duration) +- [x] 2.1 Add `pino` dependency to both apps +- [x] 2.2 Create `apps/journal/app/lib/logger.server.ts` — Pino instance, JSON in prod, pretty in dev +- [x] 2.3 Create `apps/planner/app/lib/logger.server.ts` — same pattern +- [x] 2.4 Replace key `console.log`/`console.error` calls with logger (auth, DB errors, session lifecycle) +- [x] 2.5 Add request logging middleware to Planner server.ts (method, path, status, duration) ## 3. Prometheus Metrics -- [ ] 3.1 Add `prom-client` dependency to both apps -- [ ] 3.2 Create metrics utility for Journal — default metrics + http_request_duration_seconds histogram -- [ ] 3.3 Create metrics utility for Planner — default metrics + http histogram + planner_active_sessions + planner_connected_clients gauges + brouter_request_duration_seconds histogram -- [ ] 3.4 Add `/metrics` endpoint to Journal (API route) -- [ ] 3.5 Add `/metrics` endpoint to Planner server.ts -- [ ] 3.6 Wire request duration tracking into request handlers +- [x] 3.1 Add `prom-client` dependency to both apps +- [x] 3.2 Create metrics utility for Journal — default metrics + http_request_duration_seconds histogram +- [x] 3.3 Create metrics utility for Planner — default metrics + http histogram + planner_active_sessions + planner_connected_clients gauges + brouter_request_duration_seconds histogram +- [x] 3.4 Add `/metrics` endpoint to Journal (API route) +- [x] 3.5 Add `/metrics` endpoint to Planner server.ts +- [x] 3.6 Wire request duration tracking into request handlers ## 4. Caddy Access Logs -- [ ] 4.1 Enable Caddy structured JSON access logging in Caddyfile (log directive) +- [x] 4.1 Enable Caddy structured JSON access logging in Caddyfile (log directive) ## 5. Monitoring Stack (Docker Compose) -- [ ] 5.1 Add Prometheus container to docker-compose.yml with scrape config for journal:3000 and planner:3001 -- [ ] 5.2 Add Loki container to docker-compose.yml with Docker logging driver config -- [ ] 5.3 Add Grafana container to docker-compose.yml with provisioned data sources (Prometheus + Loki) -- [ ] 5.4 Create `infrastructure/prometheus/prometheus.yml` — scrape config -- [ ] 5.5 Create `infrastructure/loki/loki-config.yml` — retention, storage -- [ ] 5.6 Create `infrastructure/grafana/provisioning/` — data sources + dashboard provider config -- [ ] 5.7 Expose Grafana via Caddy on grafana.trails.cool with basic auth +- [x] 5.1 Add Prometheus container to docker-compose.yml with scrape config for journal:3000 and planner:3001 +- [x] 5.2 Add Loki container to docker-compose.yml with Docker logging driver config +- [x] 5.3 Add Grafana container to docker-compose.yml with provisioned data sources (Prometheus + Loki) +- [x] 5.4 Create `infrastructure/prometheus/prometheus.yml` — scrape config +- [x] 5.5 Create `infrastructure/loki/loki-config.yml` — retention, storage +- [x] 5.6 Create `infrastructure/grafana/provisioning/` — data sources + dashboard provider config +- [x] 5.7 Expose Grafana via Caddy on grafana.trails.cool with basic auth ## 6. Dashboards -- [ ] 6.1 Create overview dashboard JSON — request rate, error rate, latency p50/p95/p99 -- [ ] 6.2 Create planner dashboard JSON — active sessions, connected clients, BRouter latency -- [ ] 6.3 Create infrastructure dashboard JSON — node_exporter or cAdvisor metrics (CPU, memory, disk) +- [x] 6.1 Create overview dashboard JSON — request rate, error rate, latency p50/p95/p99 +- [x] 6.2 Create planner dashboard JSON — active sessions, connected clients, BRouter latency +- [x] 6.3 Create infrastructure dashboard JSON — node_exporter or cAdvisor metrics (CPU, memory, disk) ## 7. Alerting -- [ ] 7.1 Configure Grafana alert rule: disk usage > 80% -- [ ] 7.2 Configure Grafana alert rule: app health check failing for 2 min -- [ ] 7.3 Configure Grafana alert rule: error rate > 5% for 5 min -- [ ] 7.4 Set up alert notification channel (email or webhook) +- [x] 7.1 Configure Grafana alert rule: disk usage > 80% +- [x] 7.2 Configure Grafana alert rule: app health check failing for 2 min +- [x] 7.3 Configure Grafana alert rule: error rate > 5% for 5 min +- [x] 7.4 Set up alert notification channel (email or webhook) ## 8. DNS & Terraform -- [ ] 8.1 Add grafana.trails.cool DNS record -- [ ] 8.2 Add Grafana basic auth credentials to deploy secrets +- [x] 8.1 Add grafana.trails.cool DNS record +- [x] 8.2 Add Grafana basic auth credentials to deploy secrets ## 9. Verify -- [ ] 9.1 Test /health endpoints locally — verify OK and degraded responses -- [ ] 9.2 Test /metrics endpoints — verify Prometheus format output -- [ ] 9.3 Test Grafana dashboards load with data after deploy -- [ ] 9.4 Test alert fires when simulating disk full condition +- [x] 9.1 Test /health endpoints locally — verify OK and degraded responses +- [x] 9.2 Test /metrics endpoints — verify Prometheus format output +- [x] 9.3 Test Grafana dashboards load with data after deploy +- [x] 9.4 Test alert fires when simulating disk full condition diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0d31b8..d19e0cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,12 @@ importers: nodemailer: specifier: ^8.0.4 version: 8.0.4 + pino: + specifier: ^10.3.1 + version: 10.3.1 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 react: specifier: 'catalog:' version: 19.2.4 @@ -243,6 +249,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 tailwindcss: specifier: 'catalog:' version: 4.2.2 @@ -297,6 +306,12 @@ importers: lib0: specifier: ^0.2.117 version: 0.2.117 + pino: + specifier: ^10.3.1 + version: 10.3.1 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 react: specifier: 'catalog:' version: 19.2.4 @@ -334,6 +349,9 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 tailwindcss: specifier: 'catalog:' version: 4.2.2 @@ -1375,6 +1393,9 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -2114,6 +2135,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -2136,6 +2161,9 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + body-parser@1.20.4: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2185,6 +2213,9 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -2246,6 +2277,9 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -2521,6 +2555,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} @@ -2651,6 +2688,9 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-copy@4.0.2: + resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2660,6 +2700,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2754,6 +2797,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2848,6 +2894,10 @@ packages: jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3037,6 +3087,9 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -3096,6 +3149,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -3108,6 +3165,9 @@ packages: resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3170,6 +3230,20 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} @@ -3226,10 +3300,17 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -3237,6 +3318,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3252,6 +3336,9 @@ packages: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quickselect@2.0.0: resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} @@ -3319,6 +3406,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -3351,6 +3442,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3361,6 +3456,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3411,6 +3509,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3425,6 +3526,10 @@ packages: splaytree-ts@1.0.2: resolution: {integrity: sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3439,6 +3544,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + sweepline-intersections@1.5.0: resolution: {integrity: sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ==} @@ -3452,6 +3561,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3744,6 +3860,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -4713,6 +4832,8 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -5556,6 +5677,8 @@ snapshots: assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.0 @@ -5579,6 +5702,8 @@ snapshots: bignumber.js@9.3.1: {} + bintrees@1.0.2: {} + body-parser@1.20.4: dependencies: bytes: 3.1.2 @@ -5636,6 +5761,8 @@ snapshots: cjs-module-lexer@2.2.0: {} + colorette@2.0.20: {} + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -5702,6 +5829,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + dateformat@4.6.3: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -5783,6 +5912,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 @@ -6013,12 +6146,16 @@ snapshots: exsolve@1.0.8: {} + fast-copy@4.0.2: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -6111,6 +6248,8 @@ snapshots: dependencies: function-bind: 1.1.2 + help-me@5.0.0: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.0 @@ -6203,6 +6342,8 @@ snapshots: jose@6.2.2: {} + joycon@3.1.1: {} + js-tokens@4.0.0: {} jsdom@29.0.1: @@ -6357,6 +6498,8 @@ snapshots: dependencies: brace-expansion: 5.0.4 + minimist@1.2.8: {} + minipass@7.1.3: {} module-details-from-path@1.0.4: {} @@ -6399,6 +6542,8 @@ snapshots: obug@2.1.1: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -6409,6 +6554,10 @@ snapshots: on-headers@1.1.0: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6465,6 +6614,42 @@ snapshots: picomatch@4.0.4: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + pkg-types@2.3.0: dependencies: confbox: 0.2.4 @@ -6516,8 +6701,15 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + process-warning@5.0.0: {} + progress@2.0.3: {} + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.0 + tdigest: 0.1.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -6525,6 +6717,11 @@ snapshots: proxy-from-env@1.1.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} pvtsutils@1.3.6: @@ -6537,6 +6734,8 @@ snapshots: dependencies: side-channel: 1.1.0 + quick-format-unescaped@4.0.4: {} + quickselect@2.0.0: {} range-parser@1.2.1: {} @@ -6591,6 +6790,8 @@ snapshots: readdirp@4.1.2: {} + real-require@0.2.0: {} + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -6646,6 +6847,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -6654,6 +6857,8 @@ snapshots: scheduler@0.27.0: {} + secure-json-parse@4.1.0: {} + semver@6.3.1: {} semver@7.7.4: {} @@ -6725,6 +6930,10 @@ snapshots: siginfo@2.0.0: {} + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -6736,6 +6945,8 @@ snapshots: splaytree-ts@1.0.2: {} + split2@4.2.0: {} + stackback@0.0.2: {} statuses@2.0.2: {} @@ -6746,6 +6957,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@5.0.3: {} + sweepline-intersections@1.5.0: dependencies: tinyqueue: 2.0.3 @@ -6756,6 +6969,14 @@ snapshots: tapable@2.3.0: {} + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + tinybench@2.9.0: {} tinyexec@1.0.4: {} @@ -6987,6 +7208,8 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + ws@8.20.0: {} xml-name-validator@5.0.0: {}