Files
sencho/backend/src/services/StackActivityMetricsService.ts
T
Anso 80499ee18d feat(stack-activity): in-process metrics, structured diagnostic logs, docs (#1229)
Phase 3 + Phase 6 of the Stack Activity audit (PR 2 of 2):

- StackActivityMetricsService: in-process counters and ring-buffered
  latency histogram (1000 samples per nodeId/op pair). Mirrors the
  FileExplorerMetricsService pattern shipped in #1216. No external
  export. Records (nodeId, op) where op is read or write, with
  success/error counts and p50/p95 latency on demand.

- Admin endpoint GET /api/stack-activity-metrics returns the snapshot.
  Admin-only via requireAdmin, mounted next to the file-explorer
  metrics route. An operator debugging "why is the activity tab slow
  on this node?" can pull per-(nodeId, op) counts and latencies
  without scrolling logs.

- Diagnostic logs: route handler emits a structured [StackActivity:diag]
  read entry per request (stackName, nodeId, limit, before, beforeId,
  returned, elapsedMs); dispatchAlert emits a [StackActivity:diag] write
  entry per persisted notification (category, stackName, nodeId, actor,
  messageLen). Both gated on developer_mode via isDebugEnabled. Same
  namespace so a single grep covers reads and writes on the timeline
  path. Per-request and per-event, never inside a poll loop.

- Metric record points: the route's try/finally records a read metric
  with the outcome of the DB call; dispatchAlert records a write metric
  on both the success path and (before re-throwing) the failure path,
  so error rates from the insert path stay visible.

- docs/features/stack-activity.mdx: refreshed to reflect PR 1's
  retention behavior (30 days plus per-(node, stack) 500-row cap, 1000
  per-node unattached), composite (timestamp, id) cursor, error-vs-
  empty UI distinction, and "by username" vs "via Subsystem" actor
  rendering. Adds Troubleshooting entries for "Activity unavailable"
  (node disconnect or fetch failure), "expected event missing"
  (retention windows), and "same restart shows twice" (manual click
  vs Auto-Heal redeploy are distinct events).

No tier, role, or capability gate touched. The admin metrics endpoint
inherits the standard requireAdmin gate already used by /api/file-
explorer-metrics and /api/stack-metrics.
2026-05-25 21:25:59 -04:00

112 lines
3.4 KiB
TypeScript

/**
* In-memory counters + latency samples for the per-stack activity timeline.
* Internal-only; never exported to any external system. Surfaced to admins
* via GET /api/stack-activity-metrics so operators can answer "why is the
* activity tab slow?" or "is the timeline dropping events?" without
* scrolling logs.
*
* State is process-local. A Sencho restart clears everything; persisting to
* SQLite would add write amplification to the hot dispatchAlert path for
* very little operator value. Each node tracks its own ops because the
* remote-node HTTP proxy (proxy/remoteNodeProxy.ts) short-circuits
* cross-node requests to the target's own router before this service sees
* them.
*/
export type StackActivityOp = 'read' | 'write';
interface StackActivityOpStats {
count: number;
successCount: number;
errorCount: number;
totalMs: number;
recentSamples: number[];
}
export interface StackActivitySnapshotEntry {
nodeId: number;
op: StackActivityOp;
count: number;
successCount: number;
errorCount: number;
avgMs: number;
p50Ms: number;
p95Ms: number;
}
export interface StackActivitySnapshot {
entries: StackActivitySnapshotEntry[];
}
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 StackActivityMetricsService {
private static instance: StackActivityMetricsService;
private readonly stats = new Map<string, StackActivityOpStats>();
public static getInstance(): StackActivityMetricsService {
if (!StackActivityMetricsService.instance) {
StackActivityMetricsService.instance = new StackActivityMetricsService();
}
return StackActivityMetricsService.instance;
}
public static resetForTests(): void {
this.instance = new StackActivityMetricsService();
}
private key(nodeId: number, op: StackActivityOp): string {
return `${nodeId}:${op}`;
}
public record(nodeId: number, op: StackActivityOp, durationMs: number, ok: boolean): void {
if (!Number.isFinite(durationMs) || durationMs < 0) return;
const k = this.key(nodeId, op);
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) {
s.recentSamples.shift();
}
}
public snapshot(): StackActivitySnapshot {
const entries: StackActivitySnapshotEntry[] = [];
for (const [key, s] of this.stats.entries()) {
const [nodeIdStr, op] = key.split(':');
const nodeId = Number(nodeIdStr);
if (!Number.isFinite(nodeId)) continue;
const sorted = [...s.recentSamples].sort((a, b) => a - b);
entries.push({
nodeId,
op: op as StackActivityOp,
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),
});
}
entries.sort((a, b) => a.nodeId - b.nodeId || a.op.localeCompare(b.op));
return { entries };
}
public size(): number {
return this.stats.size;
}
}