Files
sencho/backend/src/services/StackOpMetricsService.ts
T
Anso 7ec6fe05bb feat(stacks): in-process per-(nodeId, action) metrics + admin endpoint (#1196)
Sencho exports no telemetry by design (privacy-first posture). That left
operators with no answer for "why is this remote node slow today?"
except scrolling logs. The audit log records mutations but has no
latency information.

Adds StackOpMetricsService - a tiny singleton holding per-(nodeId, action)
counters and a 1000-sample ring buffer of latencies for p50/p95.
Exposed through GET /api/stack-metrics (admin-only) so operators can
pull the snapshot when debugging without touching disk or scrolling
journalctl. No external export; the data never leaves the process.

Wiring: deploy, down, restart, stop, start, update routes each capture
t0 at entry, set ok=true after the success path, and record() in a
finally block so failures count too. The record() call is cheap (one
Map lookup, one push, occasional shift on the bounded ring buffer)
and bounded in memory regardless of throughput.

Resolves M-4 from the stack-management audit.

API:
  GET /api/stack-metrics  (admin-only)
  Response: { entries: [{ nodeId, action, count, successCount,
              errorCount, avgMs, p50Ms, p95Ms }, ...] }
  Ordering: nodeId ascending, then action ascending.

Note on route mounting: /api/stack-metrics rather than the audit doc's
suggested /api/meta/stack-metrics because metaRouter is intentionally
mounted before authGate (public /api/health and /api/meta endpoints);
adding an admin-only route to that group would either bypass auth or
need a special inline gate that fights the existing structure. A
dedicated /api/stack-metrics router after authGate is cleaner.

Tests:
  - 9 unit tests in stack-op-metrics-service.test.ts: singleton,
    keyed-by-nodeId-action, p50/p95 math, ring-buffer cap at 1000,
    NaN/negative/Infinity rejection, ordering, reset.
  - 3 integration tests in stack-metrics-route.test.ts: 401 without
    auth, empty on fresh process, shape after recording.
2026-05-24 16:07:54 -04:00

116 lines
3.6 KiB
TypeScript

/**
* In-memory counters + latency samples for stack lifecycle operations.
* Internal-only - never exported to any external system. Surfaced to
* admins via GET /api/stack-metrics so operators have a way to debug
* "why is this remote node slow today?" without scrolling logs.
*
* State is process-local on purpose. A Sencho restart clears all metrics;
* the alternative (persisting to SQLite) would add write amplification
* to every lifecycle op for very little operator value.
*/
export type StackOpAction = 'deploy' | 'down' | 'restart' | 'stop' | 'start' | 'update';
interface StackOpStats {
count: number;
successCount: number;
errorCount: number;
totalMs: number;
/**
* Ring buffer of recent latencies (newest at the end). Capped at
* MAX_SAMPLES to bound memory regardless of throughput. p50/p95 are
* computed from this window on demand.
*/
recentSamples: number[];
}
export interface StackOpSnapshotEntry {
nodeId: number;
action: StackOpAction;
count: number;
successCount: number;
errorCount: number;
avgMs: number;
p50Ms: number;
p95Ms: number;
}
const MAX_SAMPLES = 1000;
function percentile(sorted: readonly number[], p: number): number {
if (sorted.length === 0) return 0;
const idx = Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p));
return sorted[idx];
}
export class StackOpMetricsService {
private static instance: StackOpMetricsService;
private readonly stats = new Map<string, StackOpStats>();
public static getInstance(): StackOpMetricsService {
if (!StackOpMetricsService.instance) {
StackOpMetricsService.instance = new StackOpMetricsService();
}
return StackOpMetricsService.instance;
}
public static resetForTests(): void {
this.instance = new StackOpMetricsService();
}
private key(nodeId: number, action: StackOpAction): string {
return `${nodeId}:${action}`;
}
/**
* Record one completed op. Call from the route layer after the lifecycle
* call resolves or rejects; `ok=false` for the rejection path.
*/
public record(nodeId: number, action: StackOpAction, durationMs: number, ok: boolean): void {
if (!Number.isFinite(durationMs) || durationMs < 0) return;
const k = this.key(nodeId, action);
let s = this.stats.get(k);
if (!s) {
s = { count: 0, successCount: 0, errorCount: 0, totalMs: 0, recentSamples: [] };
this.stats.set(k, s);
}
s.count += 1;
if (ok) s.successCount += 1;
else s.errorCount += 1;
s.totalMs += durationMs;
s.recentSamples.push(durationMs);
if (s.recentSamples.length > MAX_SAMPLES) {
// Drop oldest. Array.shift is O(n) but n is bounded to MAX_SAMPLES
// and this path is once-per-stack-op (low cadence).
s.recentSamples.shift();
}
}
public snapshot(): StackOpSnapshotEntry[] {
const out: StackOpSnapshotEntry[] = [];
for (const [key, s] of this.stats.entries()) {
const [nodeIdStr, action] = key.split(':');
const nodeId = Number(nodeIdStr);
if (!Number.isFinite(nodeId)) continue;
const sorted = [...s.recentSamples].sort((a, b) => a - b);
out.push({
nodeId,
action: action as StackOpAction,
count: s.count,
successCount: s.successCount,
errorCount: s.errorCount,
avgMs: s.count === 0 ? 0 : Math.round(s.totalMs / s.count),
p50Ms: percentile(sorted, 0.5),
p95Ms: percentile(sorted, 0.95),
});
}
// Stable ordering: nodeId asc, then action asc.
out.sort((a, b) => a.nodeId - b.nodeId || a.action.localeCompare(b.action));
return out;
}
public size(): number {
return this.stats.size;
}
}