feat: export aggregate Prometheus metrics

This commit is contained in:
thanhnv
2026-07-18 09:25:56 +07:00
parent 083a1de875
commit 9785e13c82
5 changed files with 70 additions and 2 deletions
@@ -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);
@@ -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) };
@@ -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');