From 9785e13c8291c4b07e3a4c2c7565f9592c51a077 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Sat, 18 Jul 2026 09:25:56 +0700 Subject: [PATCH] feat: export aggregate Prometheus metrics --- docs/plans/CASAN_BACKLOG_STATUS.md | 2 +- packages/casan-control-panel/README.md | 6 +++ .../src/telemetry/telemetry.controller.ts | 10 ++++- .../src/telemetry/telemetry.service.ts | 45 +++++++++++++++++++ .../backend/test/telemetry.test.ts | 9 ++++ 5 files changed, 70 insertions(+), 2 deletions(-) diff --git a/docs/plans/CASAN_BACKLOG_STATUS.md b/docs/plans/CASAN_BACKLOG_STATUS.md index b9b1c23..dbe67ce 100644 --- a/docs/plans/CASAN_BACKLOG_STATUS.md +++ b/docs/plans/CASAN_BACKLOG_STATUS.md @@ -39,7 +39,7 @@ | T2.1 | KMS mặc định + HSM | 🟡 production fail-closed + Docker lab ✅ | `infra/local-prod` dựng Vault dev @ `:18200`; `infra-lab verify` chứng minh sign+verify. `sign-audit-head.sh` nay từ chối local-key fallback khi `CASAN_PROFILE=prod` (SEC-02 test). Còn: Vault/AWS-KMS/CloudHSM thật + token ngắn hạn qua IdP và Transit encryption cho dữ liệu tenant. | | T2.2 | WORM store thật (S3 Object Lock) | 🟡 Docker lab ✅ (MinIO Object Lock) | MinIO bucket `casan-worm` bật Object Lock COMPLIANCE 1d. Prod: S3 Object Lock/QLDB + trusted timestamp; cần AWS creds + bucket Object-Lock. | | T2.3 | Dashboard deploy + auth | 🟡 Docker lab ✅ (nginx basic auth + /healthz) | Dashboard container + nginx reverse-proxy @ `:18080`, user/pass lab `casan/casan`; `/healthz` exposed. Prod: host/TLS/OIDC or enterprise auth. | -| T2.4 | Kênh alert managed + on-call | 🟡 Docker lab ✅ (webhook emulator) | `alert-webhook` @ `:19092` nhận live POST. Prod: trỏ `CASAN_ALERT_WEBHOOK` tới Slack/PagerDuty + rota/on-call thật. | +| T2.4 | Kênh alert managed + on-call | 🟡 Docker lab + metrics export ✅ | `alert-webhook` @ `:19092` nhận live POST; Control Panel exports aggregate-only Prometheus text at `/api/v1/metrics` (no trace/tenant/prompt labels). Prod: restrict scrape endpoint at the reverse proxy, trỏ `CASAN_ALERT_WEBHOOK` tới Slack/PagerDuty và thiết lập rota/on-call thật. | | T2.5 | Billing-API telemetry thật | 🟡 Docker lab ✅ (billing API mock) | `billing-api` @ `:19093/usage`; `provider-usage-fetch.sh` import được provider telemetry. Prod: OpenAI/Anthropic usage API thật + key. | | T2.6 | Sandbox: rootless/nsjail + base image CI | 🟡 hardened Docker runner + test | `sandbox-container.sh` forces a non-root workload user and production refuses a rootful daemon; network, root filesystem, capabilities and quotas remain locked down. Remaining: provision rootless Docker on the customer runner, image allowlist/signature policy, and optional nsjail/bubblewrap profile for daemonless Linux CI. | | T2.7 | Backup/restore + restore drill | 🟡 automated + test | `state-backup.sh` creates manifest/hash-bound state snapshots, requires encryption key in prod, restores only to an empty explicit directory; `phase-state-backup` is in CI. Remaining: customer object-store replication, retention/RPO/RTO, and a retained production restore-drill record. | diff --git a/packages/casan-control-panel/README.md b/packages/casan-control-panel/README.md index d294dd1..d461d8b 100644 --- a/packages/casan-control-panel/README.md +++ b/packages/casan-control-panel/README.md @@ -23,6 +23,12 @@ Open http://127.0.0.1:5174 — panels show REAL metrics from `.specify/logs/**`. `incidents` · `tools` · `traceability` · `drift` · `cost` · `GET /healthz` (200 fresh / 503 stale — fail-loud, mirrors `dashboard-server.py`). +Metrics export: `GET /api/v1/metrics` provides Prometheus text exposition for +aggregate freshness, run/failure/cost/token and H4/H5/action/incident counters. +It intentionally contains no tenant, actor, trace, prompt or Evidence Pack +labels. In production restrict the path at the authenticated reverse proxy to +the monitoring network or service account. + Command Center: - `GET /api/v1/command` — Plan-13 §8.6 read-only executive surface. Returns eight diff --git a/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts b/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts index c5abace..e9326de 100644 --- a/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts +++ b/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts @@ -1,6 +1,6 @@ // Read-only Ops Console API. Every handler returns the standard ok() envelope. No writes, // no auth (loopback-bound, "Đọc ≠ Ghi"); management/RBAC is Plan-13 Track 2/3 (future). -import { Controller, Get, Inject, Param, Query, Req, Res } from '@nestjs/common'; +import { Controller, Get, Header, Inject, Param, Query, Req, Res } from '@nestjs/common'; import type { Request, Response } from 'express'; import { ok } from '../common/api-response.js'; import { TelemetryService } from './telemetry.service.js'; @@ -16,6 +16,14 @@ export class TelemetryController { return ok(this.svc.overview()); } + // Production reverse proxy policy must restrict this aggregate-only endpoint + // to the monitoring network / service account. + @Get('metrics') + @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + metrics() { + return this.svc.prometheusMetrics(); + } + @Get('runs') runs(@Query('limit') limit?: string) { const n = Math.min(Math.max(Number(limit) || 50, 1), 500); diff --git a/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts b/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts index 250309e..5934401 100644 --- a/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts +++ b/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts @@ -263,6 +263,51 @@ export class TelemetryService { }; } + // Prometheus exposition is deliberately aggregate-only. Do not add trace IDs, + // actor/tenant identifiers, prompts, filenames or evidence content as labels: + // those would turn an operations endpoint into a data-exfiltration path. + prometheusMetrics() { + const overview = this.overview(); + const totals = overview.totals; + const lines = [ + '# HELP casan_telemetry_stale Whether the primary telemetry feed is stale (1=true).', + '# TYPE casan_telemetry_stale gauge', + `casan_telemetry_stale ${overview.stale ? 1 : 0}`, + '# HELP casan_telemetry_age_seconds Age of the primary telemetry feed in seconds.', + '# TYPE casan_telemetry_age_seconds gauge', + `casan_telemetry_age_seconds ${overview.age_s ?? -1}`, + '# HELP casan_runs_total Number of telemetry records retained by CASAN.', + '# TYPE casan_runs_total gauge', + `casan_runs_total ${totals.runs}`, + '# HELP casan_failures_total Number of retained failed telemetry records.', + '# TYPE casan_failures_total gauge', + `casan_failures_total ${totals.failures}`, + '# HELP casan_cost_usd_total Aggregate estimated CASAN execution cost in USD.', + '# TYPE casan_cost_usd_total gauge', + `casan_cost_usd_total ${totals.total_cost}`, + '# HELP casan_provider_tokens_total Aggregate provider token usage.', + '# TYPE casan_provider_tokens_total gauge', + `casan_provider_tokens_total ${totals.provider_tokens}`, + '# HELP casan_security_blocks_total H4 security verdicts blocked.', + '# TYPE casan_security_blocks_total gauge', + `casan_security_blocks_total ${overview.harness_signals['H4-security'].blocked}`, + '# HELP casan_governance_denials_total H5 governance decisions denied.', + '# TYPE casan_governance_denials_total gauge', + `casan_governance_denials_total ${overview.harness_signals['H5-governance'].denied}`, + '# HELP casan_tool_denials_total Tool registry decisions denied.', + '# TYPE casan_tool_denials_total gauge', + `casan_tool_denials_total ${totals.tool_denies}`, + '# HELP casan_action_blocks_total Governed action gate block outcomes.', + '# TYPE casan_action_blocks_total gauge', + `casan_action_blocks_total ${totals.action_blocks}`, + '# HELP casan_critical_incidents_total Retained critical incident records.', + '# TYPE casan_critical_incidents_total gauge', + `casan_critical_incidents_total ${overview.harness_signals.incidents.critical}`, + '', + ]; + return lines.join('\n'); + } + runs(limit = 50) { const metrics = readJsonl(PATHS.metrics); return { ...this.freshness(), count: metrics.length, runs: recent(metrics, limit) }; diff --git a/packages/casan-control-panel/backend/test/telemetry.test.ts b/packages/casan-control-panel/backend/test/telemetry.test.ts index a6b6fc3..752fae8 100644 --- a/packages/casan-control-panel/backend/test/telemetry.test.ts +++ b/packages/casan-control-panel/backend/test/telemetry.test.ts @@ -40,6 +40,15 @@ test('overview() returns real aggregated shape, never throws on the repo state', assert.ok(o.audit_chain.records >= 0); }); +test('prometheusMetrics() exports aggregate-safe operational metrics only', () => { + const metrics = new TelemetryService().prometheusMetrics(); + assert.match(metrics, /^# HELP casan_telemetry_stale/m); + assert.match(metrics, /^casan_runs_total \d+/m); + assert.match(metrics, /^casan_security_blocks_total \d+/m); + assert.match(metrics, /^casan_governance_denials_total \d+/m); + assert.doesNotMatch(metrics, /trace_id|tenant|prompt|evidence/i); +}); + test('security()/governance()/cost() return objects with expected keys', () => { const svc = new TelemetryService(); assert.ok(typeof (svc.security() as any).by_status === 'object');