Files
sencho/backend/src/services/StackOpMetricsService.ts
T
Anso 63213c0960 feat: add service-scoped Compose update and restore (#1648)
* feat: add service-scoped Compose update and restore

Allow updating or rebuilding one declared Compose service on multi-service
stacks without recreating siblings, with recovery snapshots, health-gate
observation, and prune holds for rollback images. Full-stack update paths
and single-service UX stay unchanged.

* fix: sanitize service-scoped update log messages for CodeQL

* fix: address service-scoped update audit findings B-01 through B-07

* fix: complete service-scoped update audit metadata and surfaces

* test: wrap Updates readiness tests for deploy-feedback context

* fix: keep service recovery reachable without Deploy Progress

Make failed service-gate recovery discoverable when Deploy Progress is
disabled or dismissed, suppress stale image-scan notification side
effects, normalize ComposeService line endings, and add focused
regression coverage.

* fix: resurface ContainersHealth density and expand on multi-service stacks

Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
2026-07-19 02:42:29 -04:00

149 lines
4.5 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';
export interface StackOpRecordMeta {
targetScope?: 'stack' | 'service';
serviceName?: string | null;
}
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[];
/** Most recent target scope recorded for this bucket (additive; stack default). */
lastTargetScope: 'stack' | 'service';
/** Most recent service name when lastTargetScope is service; otherwise null. */
lastServiceName: string | null;
}
export interface StackOpSnapshotEntry {
nodeId: number;
action: StackOpAction;
count: number;
successCount: number;
errorCount: number;
avgMs: number;
p50Ms: number;
p95Ms: number;
targetScope: 'stack' | 'service';
serviceName: string | null;
}
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,
meta?: StackOpRecordMeta,
): 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: [],
lastTargetScope: 'stack',
lastServiceName: null,
};
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();
}
if (meta?.targetScope) s.lastTargetScope = meta.targetScope;
if (meta?.targetScope === 'service') {
s.lastServiceName = meta.serviceName ?? null;
} else if (meta?.targetScope === 'stack') {
s.lastServiceName = null;
}
}
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),
targetScope: s.lastTargetScope,
serviceName: s.lastServiceName,
});
}
// 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;
}
}