Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
# @suaveplan/network-plugin-monitor - Production monitoring + distributed tracing for the Suaveplan mesh: metric collector, alert engine, network tracer, historical store, adaptive sampler
## Summary
Production monitoring + distributed tracing for the Suaveplan mesh. Collects metrics across four categories (node / connection / message / hierarchy), supports the four standard metric types (Counter / Gauge / Histogram / Summary) with both push and pull collection, raises threshold-based `monitor:alert` bus events with cooldown suppression, propagates OpenTelemetry-compatible trace ids across mesh hops with four sampling strategies (probabilistic / rate-limited / error-based / priority-based) plus clock-skew detection and correction, retains rolled-up history per metric, and adapts sampling rate downward under high metric volume so monitoring overhead never degrades the network it observes. Wrapped in a single `MonitorPlugin` satisfying the `NetworkPlugin` lifecycle.
<!-- begin:auto-docs -->
## Documentation tree
- [alert](./src/alert/alert-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [alert-engine](./src/alert/alert-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [collector](./src/collector/metric-collector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metric-collector](./src/collector/metric-collector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [grafana](./src/grafana/dashboard-generator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [dashboard-generator](./src/grafana/dashboard-generator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [history](./src/history/historical-data-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [historical-data-store](./src/history/historical-data-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [monitor](./src/monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [network-monitor](./src/monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/monitor-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [monitor-plugin](./src/plugin/monitor-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [prometheus](./src/prometheus/prometheus-exposition.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [prometheus-exposition](./src/prometheus/prometheus-exposition.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bandwidth-monitor](./src/quality/bandwidth-monitor.md) — `BandwidthMonitor` records per-link bandwidth / RTT / packet-loss measurements into a bounded ring buffer and exposes s…
- [degradation-manager](./src/quality/degradation-manager.md) — `DegradationManager` is a 4-rung quality classifier that decides the current `QualityLevel` (`HIGH`/`MEDIUM`/`LOW`/`MIN…
- [quality-middleware](./src/quality/quality-middleware.md) — `createQualityMiddleware` builds an outgoing-message gate that drops or forwards each message based on the current `Qua…
- [quality-types](./src/quality/quality-types.md) — Shared type definitions for the quality-class classification + adaptive degradation feature group. These types underpin…
- [sampler](./src/sampler/adaptive-sampler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [adaptive-sampler](./src/sampler/adaptive-sampler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [tracer](./src/tracer/network-tracer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [network-tracer](./src/tracer/network-tracer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the monitor package. Codes are namespaced under `NET_MON` and registered…
<!-- end:auto-docs --> - [alert-engine](./src/alert/alert-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [collector](./src/collector/metric-collector.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-collector](./src/collector/metric-collector.md) — <!-- begin:auto-docs --> _(no submodules)_- [grafana](./src/grafana/dashboard-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [dashboard-generator](./src/grafana/dashboard-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/historical-data-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [historical-data-store](./src/history/historical-data-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/monitor-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor-plugin](./src/plugin/monitor-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [prometheus](./src/prometheus/prometheus-exposition.md) — <!-- begin:auto-docs --> _(no submodules)_- [prometheus-exposition](./src/prometheus/prometheus-exposition.md) — <!-- begin:auto-docs --> _(no submodules)_- [bandwidth-monitor](./src/quality/bandwidth-monitor.md) — `BandwidthMonitor` records per-link bandwidth / RTT / packet-loss measurements into a bounded ring buffer and exposes s…
- [degradation-manager](./src/quality/degradation-manager.md) — `DegradationManager` is a 4-rung quality classifier that decides the current `QualityLevel` (`HIGH`/`MEDIUM`/`LOW`/`MIN…
- [quality-middleware](./src/quality/quality-middleware.md) — `createQualityMiddleware` builds an outgoing-message gate that drops or forwards each message based on the current `Qua…
- [quality-types](./src/quality/quality-types.md) — Shared type definitions for the quality-class classification + adaptive degradation feature group. These types underpin…
- [sampler](./src/sampler/adaptive-sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [adaptive-sampler](./src/sampler/adaptive-sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracer](./src/tracer/network-tracer.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-tracer](./src/tracer/network-tracer.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the monitor package. Codes are namespaced under `NET_MON` and registered…
- [alert-engine](./src/alert/alert-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [collector](./src/collector/metric-collector.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-collector](./src/collector/metric-collector.md) — <!-- begin:auto-docs --> _(no submodules)_- [grafana](./src/grafana/dashboard-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [dashboard-generator](./src/grafana/dashboard-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/historical-data-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [historical-data-store](./src/history/historical-data-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/monitor-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor-plugin](./src/plugin/monitor-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [prometheus](./src/prometheus/prometheus-exposition.md) — <!-- begin:auto-docs --> _(no submodules)_- [prometheus-exposition](./src/prometheus/prometheus-exposition.md) — <!-- begin:auto-docs --> _(no submodules)_- [bandwidth-monitor](./src/quality/bandwidth-monitor.md) — `BandwidthMonitor` records per-link bandwidth / RTT / packet-loss measurements into a bounded ring buffer and exposes s…
- [degradation-manager](./src/quality/degradation-manager.md) — `DegradationManager` is a 4-rung quality classifier that decides the current `QualityLevel` (`HIGH`/`MEDIUM`/`LOW`/`MIN…
- [quality-middleware](./src/quality/quality-middleware.md) — `createQualityMiddleware` builds an outgoing-message gate that drops or forwards each message based on the current `Qua…
- [quality-types](./src/quality/quality-types.md) — Shared type definitions for the quality-class classification + adaptive degradation feature group. These types underpin…
- [sampler](./src/sampler/adaptive-sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [adaptive-sampler](./src/sampler/adaptive-sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracer](./src/tracer/network-tracer.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-tracer](./src/tracer/network-tracer.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the monitor package. Codes are namespaced under `NET_MON` and registered…
- [alert-engine](./src/alert/alert-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [collector](./src/collector/metric-collector.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-collector](./src/collector/metric-collector.md) — <!-- begin:auto-docs --> _(no submodules)_- [grafana](./src/grafana/dashboard-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [dashboard-generator](./src/grafana/dashboard-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/historical-data-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [historical-data-store](./src/history/historical-data-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/monitor-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor-plugin](./src/plugin/monitor-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [prometheus](./src/prometheus/prometheus-exposition.md) — <!-- begin:auto-docs --> _(no submodules)_- [prometheus-exposition](./src/prometheus/prometheus-exposition.md) — <!-- begin:auto-docs --> _(no submodules)_- [bandwidth-monitor](./src/quality/bandwidth-monitor.md) — `BandwidthMonitor` records per-link bandwidth / RTT / packet-loss measurements into a bounded ring buffer and exposes s…
- [degradation-manager](./src/quality/degradation-manager.md) — `DegradationManager` is a 4-rung quality classifier that decides the current `QualityLevel` (`HIGH`/`MEDIUM`/`LOW`/`MIN…
- [quality-middleware](./src/quality/quality-middleware.md) — `createQualityMiddleware` builds an outgoing-message gate that drops or forwards each message based on the current `Qua…
- [quality-types](./src/quality/quality-types.md) — Shared type definitions for the quality-class classification + adaptive degradation feature group. These types underpin…
- [sampler](./src/sampler/adaptive-sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [adaptive-sampler](./src/sampler/adaptive-sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracer](./src/tracer/network-tracer.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-tracer](./src/tracer/network-tracer.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the monitor package. Codes are namespaced under `NET_MON` and registered…
- [alert-engine](./src/alert/alert-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [collector](./src/collector/metric-collector.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metric-collector](./src/collector/metric-collector.md) — <!-- begin:auto-docs --> _(no submodules)_- [grafana](./src/grafana/dashboard-generator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dashboard-generator](./src/grafana/dashboard-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/historical-data-store.md) — <!-- begin:auto-docs --> _(no submodules)_ - [historical-data-store](./src/history/historical-data-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-monitor](./src/monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/monitor-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [monitor-plugin](./src/plugin/monitor-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [prometheus](./src/prometheus/prometheus-exposition.md) — <!-- begin:auto-docs --> _(no submodules)_ - [prometheus-exposition](./src/prometheus/prometheus-exposition.md) — <!-- begin:auto-docs --> _(no submodules)_- [bandwidth-monitor](./src/quality/bandwidth-monitor.md) — `BandwidthMonitor` records per-link bandwidth / RTT / packet-loss measurements into a bounded ring buffer and exposes s…
- [degradation-manager](./src/quality/degradation-manager.md) — `DegradationManager` is a 4-rung quality classifier that decides the current `QualityLevel` (`HIGH`/`MEDIUM`/`LOW`/`MIN…
- [quality-middleware](./src/quality/quality-middleware.md) — `createQualityMiddleware` builds an outgoing-message gate that drops or forwards each message based on the current `Qua…
- [quality-types](./src/quality/quality-types.md) — Shared type definitions for the quality-class classification + adaptive degradation feature group. These types underpin…
- [sampler](./src/sampler/adaptive-sampler.md) — <!-- begin:auto-docs --> _(no submodules)_ - [adaptive-sampler](./src/sampler/adaptive-sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracer](./src/tracer/network-tracer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-tracer](./src/tracer/network-tracer.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the monitor package. Codes are namespaced under `NET_MON` and registered…

View File

@@ -0,0 +1,49 @@
{
"name": "@suaveplan/network-plugin-monitor",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Production monitoring + distributed tracing for the Suaveplan mesh: metric collector, alert engine, network tracer, historical store, adaptive sampler",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,86 @@
# `AlertEngine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Evaluates `AlertRule` definitions against `MetricCollector` snapshots and emits `monitor:alert` bus events when thresholds are tripped. Suppresses storm-firings via per-rule cooldown so a single sustained spike does not flood subscribers.
## Features
- Full closed-union `AlertComparison` set (`>`, `>=`, `<`, `<=`, `==`, `!=`) and `AlertStatistic` set (`latest`, `min`, `max`, `avg`, `p50`, `p95`, `p99`, `count`).
- Cooldown suppression returns `state: "suppressed"` and skips the bus emit; consumers see only "first firing" + "recovered" by default.
- `evaluateAll` returns the per-rule outcome so callers can render dashboards directly without bus subscriptions.
- Injectable clock + bus for deterministic tests.
## Usage: Basic Example
```ts
import {
AlertEngine,
MetricCollector,
} from "@suaveplan/network-plugin-monitor";
const collector = new MetricCollector();
collector.define({ name: "node.cpu", category: "node", type: "gauge" });
collector.record("node.cpu", 0.91);
const engine = new AlertEngine({ collector });
engine.addRule({
id: "cpu-warn",
metricName: "node.cpu",
statistic: "latest",
comparison: ">",
threshold: 0.9,
severity: "warning",
});
engine.evaluateAll(); // [{ evaluation, state: "firing" }]
```
## Usage: Advanced Example
```ts
import {
AlertEngine,
type AlertEvaluation,
MetricCollector,
} from "@suaveplan/network-plugin-monitor";
const events: AlertEvaluation[] = [];
const engine = new AlertEngine({
collector: new MetricCollector(),
bus: {
emit: (_event, payload) => events.push(payload.evaluation),
},
defaultCooldownMs: 30_000,
clock: () => 1_700_000_000_000,
});
engine.addRule({
id: "p95-warn",
metricName: "connection.latency",
statistic: "p95",
comparison: ">",
threshold: 250,
severity: "warning",
cooldownMs: 5_000,
});
```
## API Reference
- `class AlertEngine(config: { collector, bus?, clock?, defaultCooldownMs? })`
- `addRule(rule: AlertRule): void` — throws `ALERT_RULE_DUPLICATE` on duplicate ids.
- `removeRule(id: string): void` — throws `ALERT_RULE_NOT_FOUND`.
- `list(): ReadonlyArray<AlertRule>`.
- `evaluate(rule): { evaluation, state }`.
- `evaluateAll(): ReadonlyArray<{ evaluation, state }>`.
- `reset(): void` — clears every cooldown so the next evaluation can fire immediately.
## Implementation Notes
- The engine reads stats from the collector exactly once per `evaluate` call so two rules pointing at the same metric do not race against each other if records arrive concurrently.
- `latest` reads the most-recent sample directly from the snapshot rather than from `stats` because `MetricStats` does not retain ordering — empty buffers fall back to `0` with `count === 0`.
- Cooldown is enforced per-rule by `id`; rules sharing a metric name still suppress independently. Resetting an engine clears every cooldown without touching the rules themselves.
- The bus is optional so the engine is fully usable in unit tests via `evaluate(...)` return values; production consumers wire the bus and ignore the return value.

View File

@@ -0,0 +1,170 @@
import type { MetricCollector } from "../collector/metric-collector.js";
import { NETWORK_MON_CODES, NetworkMonError } from "../error-codes.js";
import type {
AlertEvaluation,
AlertRule,
AlertState,
} from "../types/alert.types.js";
export interface AlertBus {
emit(
event: "monitor:alert",
payload: { evaluation: AlertEvaluation; state: AlertState }
): void;
}
export interface AlertEngineConfig {
readonly collector: MetricCollector;
readonly bus?: AlertBus;
readonly clock?: () => number;
/** Default cooldown, in ms, when a rule does not specify one. */
readonly defaultCooldownMs?: number;
}
const DEFAULT_COOLDOWN_MS = 60_000;
/**
* Threshold-based alert evaluator. Rules read either the latest sample
* or one of the percentile / aggregate fields produced by
* `MetricCollector.stats`. Firings emit `monitor:alert` on the bus
* with a per-rule cooldown to suppress storms.
*/
export class AlertEngine {
private readonly rules = new Map<string, AlertRule>();
private readonly lastFiredAt = new Map<string, number>();
private readonly collector: MetricCollector;
private readonly bus: AlertBus | undefined;
private readonly clock: () => number;
private readonly defaultCooldownMs: number;
public constructor(config: AlertEngineConfig) {
this.collector = config.collector;
this.bus = config.bus;
this.clock = config.clock ?? (() => Date.now());
this.defaultCooldownMs =
config.defaultCooldownMs ?? DEFAULT_COOLDOWN_MS;
}
public addRule(rule: AlertRule): void {
if (this.rules.has(rule.id)) {
throw new NetworkMonError(
NETWORK_MON_CODES.ALERT_RULE_DUPLICATE,
"alert.duplicate",
{ context: { id: rule.id } }
);
}
this.rules.set(rule.id, rule);
}
public removeRule(id: string): void {
if (!this.rules.delete(id)) {
throw new NetworkMonError(
NETWORK_MON_CODES.ALERT_RULE_NOT_FOUND,
"alert.missing",
{ context: { id } }
);
}
this.lastFiredAt.delete(id);
}
public list(): ReadonlyArray<AlertRule> {
return Array.from(this.rules.values());
}
/**
* Evaluate every rule once. Returns the per-rule outcome so callers
* can render dashboards / write tests without hooking the bus.
*/
public evaluateAll(): ReadonlyArray<{
evaluation: AlertEvaluation;
state: AlertState;
}> {
const out: { evaluation: AlertEvaluation; state: AlertState }[] = [];
for (const rule of this.rules.values()) {
out.push(this.evaluate(rule));
}
return out;
}
public evaluate(rule: AlertRule): {
evaluation: AlertEvaluation;
state: AlertState;
} {
const stats = this.collector.stats(rule.metricName);
const observed = this.observed(rule, stats);
const evaluation: AlertEvaluation = {
rule,
observed,
stats,
evaluatedAt: this.clock(),
};
const tripped = this.compare(observed, rule.comparison, rule.threshold);
if (!tripped) {
return { evaluation, state: "ok" };
}
const cooldown = rule.cooldownMs ?? this.defaultCooldownMs;
const last = this.lastFiredAt.get(rule.id);
const state: AlertState =
last !== undefined && evaluation.evaluatedAt - last < cooldown
? "suppressed"
: "firing";
if (state === "firing") {
this.lastFiredAt.set(rule.id, evaluation.evaluatedAt);
}
this.bus?.emit("monitor:alert", { evaluation, state });
return { evaluation, state };
}
public reset(): void {
this.lastFiredAt.clear();
}
private observed(
rule: AlertRule,
stats: ReturnType<MetricCollector["stats"]>
): number {
switch (rule.statistic) {
case "latest": {
const samples = this.collector.snapshot(
rule.metricName
).samples;
return samples[samples.length - 1]?.value ?? 0;
}
case "min":
return stats.min;
case "max":
return stats.max;
case "avg":
return stats.avg;
case "p50":
return stats.p50;
case "p95":
return stats.p95;
case "p99":
return stats.p99;
case "count":
return stats.count;
}
}
private compare(
observed: number,
comparison: AlertRule["comparison"],
threshold: number
): boolean {
switch (comparison) {
case ">":
return observed > threshold;
case ">=":
return observed >= threshold;
case "<":
return observed < threshold;
case "<=":
return observed <= threshold;
case "==":
return observed === threshold;
case "!=":
return observed !== threshold;
}
}
}

View File

@@ -0,0 +1,195 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MetricCollector } from "../collector/metric-collector.js";
import { NETWORK_MON_CODES, type NetworkMonError } from "../error-codes.js";
import type {
AlertEvaluation,
AlertRule,
AlertState,
} from "../types/alert.types.js";
import { AlertEngine } from "./alert-engine.js";
const setup = (clockFn: () => number = () => 1_000) => {
const collector = new MetricCollector({ clock: clockFn });
collector.define({ name: "x", category: "node", type: "gauge" });
const events: { evaluation: AlertEvaluation; state: AlertState }[] = [];
const engine = new AlertEngine({
collector,
bus: { emit: (_event, payload) => events.push(payload) },
clock: clockFn,
defaultCooldownMs: 5_000,
});
return { collector, engine, events };
};
const rule = (overrides: Partial<AlertRule> = {}): AlertRule => ({
id: "r1",
metricName: "x",
statistic: "latest",
comparison: ">",
threshold: 10,
severity: "warning",
...overrides,
});
describe("AlertEngine: thresholds, bus emission, suppression", () => {
it("addRule rejects duplicates", () => {
const { engine } = setup();
engine.addRule(rule());
try {
engine.addRule(rule());
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMonError).code).toBe(
NETWORK_MON_CODES.ALERT_RULE_DUPLICATE
);
}
});
it("removeRule fails on missing id", () => {
const { engine } = setup();
try {
engine.removeRule("nope");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMonError).code).toBe(
NETWORK_MON_CODES.ALERT_RULE_NOT_FOUND
);
}
});
it("evaluates the comparison operators", () => {
for (const [comparison, threshold, value, expected] of [
[">", 10, 11, true],
[">=", 10, 10, true],
["<", 10, 9, true],
["<=", 10, 10, true],
["==", 10, 10, true],
["!=", 10, 11, true],
[">", 10, 5, false],
] as const) {
const { collector, engine } = setup();
collector.record("x", value);
engine.addRule(rule({ comparison, threshold }));
const state = engine.evaluateAll()[0]?.state;
expect(state).toBe(expected ? "firing" : "ok");
}
});
it("emits monitor:alert and tracks cooldown", () => {
let now = 1_000;
const { collector, engine, events } = setup(() => now);
engine.addRule(rule({ cooldownMs: 1_000 }));
collector.record("x", 100);
engine.evaluateAll(); // firing
engine.evaluateAll(); // suppressed (within cooldown)
now += 1_500;
engine.evaluateAll(); // firing again
const states = events.map((e) => e.state);
expect(states).toEqual(["firing", "suppressed", "firing"]);
});
it("uses default cooldown when rule omits it", () => {
let now = 1_000;
const { collector, engine, events } = setup(() => now);
engine.addRule(rule());
collector.record("x", 100);
engine.evaluateAll(); // firing
now += 1_000;
engine.evaluateAll(); // suppressed (within 5s default)
expect(events.map((e) => e.state)).toEqual(["firing", "suppressed"]);
});
it("reset clears every cooldown", () => {
const now = 1_000;
const { collector, engine, events } = setup(() => now);
engine.addRule(rule({ cooldownMs: 100_000 }));
collector.record("x", 100);
engine.evaluateAll(); // firing
engine.reset();
engine.evaluateAll(); // firing again
expect(events.length).toBe(2);
expect(events[1]?.state).toBe("firing");
});
it("reads percentile / aggregate statistics", () => {
const { collector, engine } = setup();
for (const v of [10, 20, 30, 40, 50]) collector.record("x", v);
for (const stat of [
"min",
"max",
"avg",
"p50",
"p95",
"p99",
"count",
] as const) {
engine.addRule(
rule({
id: stat,
statistic: stat,
comparison: ">",
threshold: 0,
})
);
}
const fires = engine.evaluateAll().filter((r) => r.state === "firing");
expect(fires.length).toBe(7);
});
it("latest with no samples observes 0", () => {
const { engine } = setup();
engine.addRule(rule({ comparison: "<", threshold: 1 }));
expect(engine.evaluateAll()[0]?.state).toBe("firing");
});
it("list reflects every registered rule", () => {
const { engine } = setup();
engine.addRule(rule({ id: "a" }));
engine.addRule(rule({ id: "b" }));
expect(
engine
.list()
.map((r) => r.id)
.sort()
).toEqual(["a", "b"]);
});
it("default-config (no clock) uses Date.now and DEFAULT_COOLDOWN", () => {
const collector = new MetricCollector();
collector.define({ name: "y", category: "node", type: "gauge" });
collector.record("y", 100);
const engine = new AlertEngine({ collector });
engine.addRule({
id: "default",
metricName: "y",
statistic: "latest",
comparison: ">",
threshold: 1,
severity: "warning",
});
const out = engine.evaluate(engine.list()[0] as AlertRule);
expect(out.state).toBe("firing");
});
it("evaluate(rule) returns the same outcome as evaluateAll for that rule", () => {
const { collector, engine } = setup();
collector.record("x", 100);
const r = rule({ comparison: ">", threshold: 1 });
const out = engine.evaluate(r);
expect(out.state).toBe("firing");
expect(out.evaluation.observed).toBe(100);
});
it("removeRule clears the cooldown for that id", () => {
const now = 1_000;
const { collector, engine, events } = setup(() => now);
engine.addRule(rule({ cooldownMs: 100_000 }));
collector.record("x", 100);
engine.evaluateAll(); // firing
engine.removeRule("r1");
// Re-add and re-evaluate — should fire again because cooldown is gone
engine.addRule(rule({ cooldownMs: 100_000 }));
engine.evaluateAll(); // firing
expect(events.map((e) => e.state)).toEqual(["firing", "firing"]);
});
});

View File

@@ -0,0 +1,157 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_MON_CODES, type NetworkMonError } from "../error-codes.js";
import { MetricCollector } from "./metric-collector.js";
describe("MetricCollector: 4 metric types, sampling, push/pull", () => {
it("define + record + snapshot", () => {
const c = new MetricCollector();
c.define({ name: "n", category: "node", type: "gauge" });
c.record("n", 1);
c.record("n", 2);
const snap = c.snapshot("n");
expect(snap.samples.map((s) => s.value)).toEqual([1, 2]);
});
it("increment requires counter type", () => {
const c = new MetricCollector();
c.define({ name: "g", category: "node", type: "gauge" });
try {
c.increment("g");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMonError).code).toBe(
NETWORK_MON_CODES.METRIC_TYPE_MISMATCH
);
}
});
it("increment accumulates against the previous value", () => {
const c = new MetricCollector();
c.define({ name: "ctr", category: "message", type: "counter" });
c.increment("ctr");
c.increment("ctr", 4);
expect(c.snapshot("ctr").samples.map((s) => s.value)).toEqual([1, 5]);
});
it("rolling window evicts oldest past windowSize", () => {
const c = new MetricCollector();
c.define({
name: "h",
category: "connection",
type: "histogram",
windowSize: 3,
});
for (let i = 0; i < 5; i += 1) c.record("h", i);
expect(c.snapshot("h").samples.map((s) => s.value)).toEqual([2, 3, 4]);
});
it("samplingRate < 1 skips with deterministic rng", () => {
const c = new MetricCollector({ rng: () => 0.99 });
c.define({
name: "s",
category: "node",
type: "gauge",
samplingRate: 0.1,
});
expect(c.record("s", 1)).toBeNull();
});
it("stats() reports min/max/avg/p50/p95/p99", () => {
const c = new MetricCollector();
c.define({ name: "x", category: "node", type: "histogram" });
for (const v of [10, 20, 30, 40, 50]) c.record("x", v);
const stats = c.stats("x");
expect(stats.count).toBe(5);
expect(stats.min).toBe(10);
expect(stats.max).toBe(50);
expect(stats.avg).toBe(30);
expect(stats.p50).toBe(30);
});
it("stats() returns zeroed snapshot on empty buffer", () => {
const c = new MetricCollector();
c.define({ name: "empty", category: "node", type: "gauge" });
const stats = c.stats("empty");
expect(stats.count).toBe(0);
expect(stats.avg).toBe(0);
});
it("scrape returns every defined metric (pull mode)", () => {
const c = new MetricCollector();
c.define({ name: "a", category: "node", type: "gauge" });
c.define({ name: "b", category: "message", type: "counter" });
c.record("a", 1);
c.increment("b");
const scraped = c.scrape();
expect(scraped.map((m) => m.name).sort()).toEqual(["a", "b"]);
});
it("reset clears all or one metric buffer", () => {
const c = new MetricCollector();
c.define({ name: "a", category: "node", type: "gauge" });
c.define({ name: "b", category: "node", type: "gauge" });
c.record("a", 1);
c.record("b", 2);
c.reset("a");
expect(c.snapshot("a").samples.length).toBe(0);
expect(c.snapshot("b").samples.length).toBe(1);
c.reset();
expect(c.snapshot("b").samples.length).toBe(0);
});
it("reset(name) on missing id is a no-op", () => {
const c = new MetricCollector();
expect(() => c.reset("nope")).not.toThrow();
});
it("snapshot/stats/record on undefined metric throws METRIC_NOT_FOUND", () => {
const c = new MetricCollector();
for (const fn of [
() => c.snapshot("nope"),
() => c.stats("nope"),
() => c.record("nope", 0),
() => c.increment("nope"),
]) {
try {
fn();
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMonError).code).toBe(
NETWORK_MON_CODES.METRIC_NOT_FOUND
);
}
}
});
it("record stores label dimensions on the sample", () => {
const c = new MetricCollector();
c.define({ name: "labeled", category: "node", type: "gauge" });
const sample = c.record("labeled", 1, { peerId: "p1" });
expect(sample?.labels).toEqual({ peerId: "p1" });
});
it("samplingRate=1 keeps every sample", () => {
const c = new MetricCollector({ rng: () => 0.99 });
c.define({
name: "k",
category: "node",
type: "gauge",
samplingRate: 1,
});
expect(c.record("k", 1)).not.toBeNull();
});
it("uses Date.now / Math.random by default", () => {
const c = new MetricCollector();
c.define({ name: "default", category: "node", type: "gauge" });
const sample = c.record("default", 1);
expect(typeof sample?.recordedAt).toBe("number");
});
it("has() reflects defined metrics", () => {
const c = new MetricCollector();
c.define({ name: "x", category: "node", type: "gauge" });
expect(c.has("x")).toBe(true);
expect(c.has("nope")).toBe(false);
});
});

View File

@@ -0,0 +1,73 @@
# `MetricCollector`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stores metric definitions and rolling sample buffers, computes percentile statistics on demand, and supports both push (`record`/`increment`) and pull (`scrape`) collection modes. Knows nothing about transports or envelopes — the monitor wraps it for domain-specific recording.
## Features
- Define + record + snapshot per-metric API; rolling window per metric (default 256 samples).
- 4 metric types via the `MetricType` union; `increment` enforces `counter` semantics so callers cannot bump a histogram by mistake.
- Configurable per-metric sampling rate (`0..1`); samples that fail the rate gate are skipped without error.
- `stats(name)` returns an `MetricStats` snapshot (`count`, `min`, `max`, `avg`, `p50`, `p95`, `p99`).
- Injectable clock + RNG so deterministic tests can pin timing and sampling decisions.
## Usage: Basic Example
```ts
import { MetricCollector } from "@suaveplan/network-plugin-monitor";
const collector = new MetricCollector();
collector.define({ name: "node.cpu", category: "node", type: "gauge" });
collector.record("node.cpu", 0.42);
collector.stats("node.cpu").avg; // 0.42
```
## Usage: Advanced Example
```ts
import { MetricCollector } from "@suaveplan/network-plugin-monitor";
const collector = new MetricCollector({
clock: () => 1_700_000_000_000,
rng: () => 0,
});
collector.define({
name: "msg.in.count",
category: "message",
type: "counter",
windowSize: 1024,
samplingRate: 0.5,
});
collector.increment("msg.in.count", 1); // 1
collector.increment("msg.in.count", 5); // 6
collector.increment("msg.in.count", 2, { type: "registry:add" });
const m = collector.snapshot("msg.in.count");
const all = collector.scrape();
collector.reset("msg.in.count");
```
## API Reference
- `class MetricCollector(config?)`
- `define(definition: MetricDefinition): void`.
- `record(name, value, labels?): MetricValue | null` — `null` only when the sample is skipped by the per-metric rate gate.
- `increment(name, delta?, labels?): MetricValue | null` — throws `METRIC_TYPE_MISMATCH` when applied to non-counter metrics.
- `snapshot(name): Metric` — frozen-style copy of the rolling buffer.
- `stats(name): MetricStats` — count / min / max / avg / p50 / p95 / p99 (all zero when empty).
- `scrape(): ReadonlyArray<Metric>` — pull-mode snapshot of every registered metric.
- `reset(name?): void` — clears one or all sample buffers.
- `has(name): boolean`.
- `interface MetricDefinition` — `{ name, category, type, windowSize?, samplingRate? }`.
## Implementation Notes
- Every read-side method that takes a `name` throws `METRIC_NOT_FOUND` when the metric is not defined; this is loud-failure on caller bugs rather than silent success.
- Percentile computation uses the standard "nearest-rank" method (`ceil(n * p) - 1`) clamped to the buffer length so empty buffers produce `0` (with `count === 0` to disambiguate).
- The rolling buffer is a plain array with `shift()` once `windowSize` is exceeded — adequate for the default 256 samples; higher-volume metrics should set `samplingRate < 1` rather than ballooning the window.
- `snapshot` returns a copy of the samples so consumers can iterate without racing the collector's writes.
- `increment` reads the previous sample's `value` so counters accumulate correctly across the rolling window — even after eviction the next increment continues the run.

View File

@@ -0,0 +1,174 @@
import { NETWORK_MON_CODES, NetworkMonError } from "../error-codes.js";
import type {
Metric,
MetricCategory,
MetricStats,
MetricType,
MetricValue,
} from "../types/metric.types.js";
export interface MetricDefinition {
readonly name: string;
readonly category: MetricCategory;
readonly type: MetricType;
/** Maximum samples retained in the rolling buffer. Defaults to 256. */
readonly windowSize?: number;
/** 0..1 sample probability — collector skips samples past the gate. */
readonly samplingRate?: number;
}
export interface MetricCollectorConfig {
readonly clock?: () => number;
readonly rng?: () => number;
}
interface MetricEntry {
readonly definition: MetricDefinition;
samples: MetricValue[];
}
/**
* Pluggable metric collector. Knows nothing about transports or
* envelopes — consumers register definitions and call `record(...)`.
*/
export class MetricCollector {
private readonly entries = new Map<string, MetricEntry>();
private readonly clock: () => number;
private readonly rng: () => number;
public constructor(config: MetricCollectorConfig = {}) {
this.clock = config.clock ?? (() => Date.now());
this.rng = config.rng ?? Math.random;
}
public define(definition: MetricDefinition): void {
this.entries.set(definition.name, {
definition,
samples: [],
});
}
public record(
name: string,
value: number,
labels?: Readonly<Record<string, string>>
): MetricValue | null {
const entry = this.requireEntry(name);
const rate = entry.definition.samplingRate ?? 1;
if (rate < 1 && this.rng() >= rate) return null;
const sample: MetricValue = labels
? { value, recordedAt: this.clock(), labels }
: { value, recordedAt: this.clock() };
const window = entry.definition.windowSize ?? 256;
entry.samples.push(sample);
if (entry.samples.length > window) {
entry.samples.shift();
}
return sample;
}
public increment(
name: string,
delta = 1,
labels?: Readonly<Record<string, string>>
): MetricValue | null {
const entry = this.requireEntry(name);
if (entry.definition.type !== "counter") {
throw new NetworkMonError(
NETWORK_MON_CODES.METRIC_TYPE_MISMATCH,
"collector.increment-non-counter",
{
context: { name, type: entry.definition.type },
}
);
}
const previous = entry.samples[entry.samples.length - 1]?.value ?? 0;
return this.record(name, previous + delta, labels);
}
public snapshot(name: string): Metric {
const entry = this.requireEntry(name);
return {
name: entry.definition.name,
category: entry.definition.category,
type: entry.definition.type,
samples: [...entry.samples],
};
}
public stats(name: string): MetricStats {
const samples = this.requireEntry(name).samples.map((s) => s.value);
if (samples.length === 0) {
return {
count: 0,
min: 0,
max: 0,
avg: 0,
p50: 0,
p95: 0,
p99: 0,
};
}
const sorted = [...samples].sort((a, b) => a - b);
let total = 0;
for (const v of sorted) total += v;
const last = sorted[sorted.length - 1] as number;
const first = sorted[0] as number;
return {
count: sorted.length,
min: first,
max: last,
avg: total / sorted.length,
p50: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95),
p99: percentile(sorted, 0.99),
};
}
/** Pull every registered metric as a `Metric[]` snapshot. */
public scrape(): ReadonlyArray<Metric> {
const out: Metric[] = [];
for (const entry of this.entries.values()) {
out.push({
name: entry.definition.name,
category: entry.definition.category,
type: entry.definition.type,
samples: [...entry.samples],
});
}
return out;
}
public reset(name?: string): void {
if (name === undefined) {
for (const entry of this.entries.values()) entry.samples = [];
return;
}
const entry = this.entries.get(name);
if (entry !== undefined) entry.samples = [];
}
public has(name: string): boolean {
return this.entries.has(name);
}
private requireEntry(name: string): MetricEntry {
const entry = this.entries.get(name);
if (entry === undefined) {
throw new NetworkMonError(
NETWORK_MON_CODES.METRIC_NOT_FOUND,
"collector.unknown-metric",
{ context: { name } }
);
}
return entry;
}
}
function percentile(sortedAsc: ReadonlyArray<number>, p: number): number {
const idx = Math.min(
sortedAsc.length - 1,
Math.max(0, Math.ceil(sortedAsc.length * p) - 1)
);
return sortedAsc[idx] as number;
}

View File

@@ -0,0 +1,55 @@
# Error codes — `@suaveplan/network-plugin-monitor`
## Purpose
Frozen registry of every error code raised by the monitor package. Codes are namespaced under `NET_MON` and registered into the global error registry on first import via the side-effect import in `src/index.ts`. Each code maps to a single failure class so consumers can branch on `error.code` without parsing message text.
## Features
- Six codes covering metric registration, alert rule mutation, span lifecycle, and retention configuration.
- `NetworkMonError` extends `SuaveplanError<NetworkMonErrorCode>` for cause-chain traversal, redaction, and OTel adapter compatibility.
- Frozen registry — runtime mutation throws.
## Usage: Basic Example
```ts
import {
NETWORK_MON_CODES,
NetworkMonError,
} from "@suaveplan/network-plugin-monitor";
throw new NetworkMonError(
NETWORK_MON_CODES.METRIC_NOT_FOUND,
"monitor.metric-missing",
{ context: { name: "node.cpu" } }
);
```
## Usage: Advanced Example
```ts
try {
alertEngine.addRule(rule);
} catch (err) {
if (
err instanceof NetworkMonError &&
err.code === NETWORK_MON_CODES.ALERT_RULE_DUPLICATE
) {
return alertEngine.replaceRule(rule);
}
throw err;
}
```
## API Reference
- `NETWORK_MON_CODES` — frozen `KEY -> "NET_MON.KEY"` map.
- `NetworkMonErrorCode` — TypeScript union of every code value.
- `NetworkMonError` — `SuaveplanError<NetworkMonErrorCode>` subclass; `name === "NetworkMonError"`.
## Implementation Notes
- The registry is constructed via `defineCodes("NET_MON", […] as const)` so the type system enumerates the value union; adding a code requires bumping the `as const` tuple.
- Codes are deliberately coarse (one per failure class) so consumers build stable retry / observability policies without churn when implementations change.
- `RETENTION_INVALID` is the boundary code for both `HistoricalDataStore` and the `AdaptiveSampler` rate clamps — both consume retention semantics, so a single code keeps the consumer side simple.
- The side-effect import in `src/index.ts` registers the codes even when consumers tree-shake the named exports.

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_MON_CODES, NetworkMonError } from "./error-codes.js";
describe("NETWORK_MON_CODES", () => {
it("registers under NET_MON", () => {
for (const value of Object.values(NETWORK_MON_CODES)) {
expect(value).toMatch(/^NET_MON\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_MON_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"METRIC_NOT_FOUND",
"METRIC_TYPE_MISMATCH",
"ALERT_RULE_DUPLICATE",
"ALERT_RULE_NOT_FOUND",
"SPAN_NOT_OPEN",
"RETENTION_INVALID",
] as const) {
expect(NETWORK_MON_CODES).toHaveProperty(key);
}
});
});
describe("NetworkMonError", () => {
it("extends SuaveplanError", () => {
const err = new NetworkMonError(
NETWORK_MON_CODES.METRIC_NOT_FOUND,
"missing"
);
expect(err.name).toBe("NetworkMonError");
expect(err.code).toBe(NETWORK_MON_CODES.METRIC_NOT_FOUND);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkMonError(
NETWORK_MON_CODES.SPAN_NOT_OPEN,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,28 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const NETWORK_MON_CODES = defineCodes("NET_MON", [
"METRIC_NOT_FOUND",
"METRIC_TYPE_MISMATCH",
"ALERT_RULE_DUPLICATE",
"ALERT_RULE_NOT_FOUND",
"SPAN_NOT_OPEN",
"RETENTION_INVALID",
] as const);
export type NetworkMonErrorCode =
(typeof NETWORK_MON_CODES)[keyof typeof NETWORK_MON_CODES];
export class NetworkMonError extends SuaveplanError<NetworkMonErrorCode> {
public constructor(
code: NetworkMonErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkMonError";
}
}

View File

@@ -0,0 +1,145 @@
# `dashboard-generator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Universal Grafana dashboard JSON generator. Produces the exact JSON model
Grafana's import API consumes (`POST /api/dashboards/db`) — no HTTP client and
no Grafana SDK dependency. The generator is a pure value-builder so it runs
identically in browsers, Bun, Node, and Workers.
Lives in `@suaveplan/network-plugin-monitor` because the monitor plugin is the
canonical telemetry surface for the Suaveplan mesh; bundling the dashboard
builder here means consumers ship one package for both `/metrics` exposition
and `/dashboards/db` provisioning.
## Features
- Zod-validated `addPanel` / dashboard options — unknown keys rejected.
- Stable Prometheus datasource binding (`{type: "prometheus", uid: "prometheus"}`).
- Auto-layout (12-wide, 8-tall panels reflowing every other panel).
- Injectable RNG (`rng?: () => number`) for deterministic UID generation in
tests — defaults to `Math.random` for production.
- Stateless emission — `generate()` is referentially transparent for a given
call sequence.
- Schema-versioned (`schemaVersion: 38`, `version: 1`) so consumers know what
they're targeting.
## Usage: Basic Example
```ts
import { GrafanaDashboardGenerator } from "@suaveplan/network-plugin-monitor";
const d = new GrafanaDashboardGenerator({ title: "Suaveplan Network" });
d.addPanel({
title: "Request Rate",
type: "graph",
targets: [
{ expr: "rate(sp_requests_total[5m])", legendFormat: "{{peer}}" },
],
});
const json = d.toJSON();
```
## Usage: Advanced Example
```ts
import { GrafanaDashboardGenerator } from "@suaveplan/network-plugin-monitor";
let seed = 1;
const rng = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const d = new GrafanaDashboardGenerator({
title: "Network Mesh",
tags: ["mesh", "production"],
refresh: "10s",
timezone: "UTC",
rng,
});
d.addRow("Connections");
d.addPanel({
title: "Active Peers",
type: "stat",
targets: [{ expr: "sp_active_peers" }],
gridPos: { x: 0, y: 1, w: 6, h: 4 },
});
d.addPanel({
title: "RTT p95",
type: "graph",
targets: [
{
expr: 'histogram_quantile(0.95, rate(sp_rtt_ms_bucket[1m]))',
legendFormat: "p95",
},
],
});
d.addRow("Errors");
d.addPanel({
title: "Error Rate",
type: "graph",
targets: [{ expr: "rate(sp_errors_total[5m])", legendFormat: "{{code}}" }],
});
// Push to Grafana provisioning API
await fetch("https://grafana.example/api/dashboards/db", {
method: "POST",
headers: { "content-type": "application/json" },
body: d.toJSON(),
});
```
## API Reference
### `class GrafanaDashboardGenerator`
Constructor: `new GrafanaDashboardGenerator(options: GrafanaDashboardOptions)`.
Options:
| Field | Default | Description |
|---|---|---|
| `title` | (required) | Dashboard title. |
| `uid` | random | 9-char base36 UID (auto-derived from `rng()`). |
| `tags` | `[]` | Grafana tags. |
| `timezone` | `"browser"` | Grafana timezone string. |
| `refresh` | `"30s"` | Auto-refresh interval. |
| `editable` | `true` | Whether the dashboard is editable in the UI. |
| `rng` | `Math.random` | Pluggable RNG for deterministic UID generation. |
Instance methods:
- `addPanel(panel: PanelOptions): void` — append a panel. Throws on Zod
validation failure (unknown keys, empty targets, missing title).
- `addRow(title: string): void` — append a row separator. Throws if title is
empty.
- `generate(): DashboardJson` — emits the dashboard JSON model.
- `toJSON(): string` — pretty-printed (2-space indent) JSON string.
- `panelCount: number` — current panel + row count (read-only).
Standalone schemas exported for caller-side validation:
- `GrafanaDashboardOptionsSchema`
- `PanelOptionsSchema`
- `PromQLTargetSchema`
- `GridPosSchema`
## Implementation Notes
- Auto-layout uses `Math.floor(panelIndex/2)*8` — every two panels start a new
row at 8-unit vertical pitch (matches the upstream behaviour 1:1).
- The UID generator emits a base36 string padded to 9 characters; collisions
are statistically negligible (~36⁹ ≈ 10¹⁴ space) but callers who need
guaranteed uniqueness should pass an explicit `uid`.
- The Prometheus datasource is hard-coded to UID `"prometheus"`. Grafana
instances using a custom datasource UID must post-process the JSON or
configure their provisioning binding accordingly.
- `generate()` always returns `schemaVersion: 38` + `version: 1`. Future
Grafana schema bumps will land as a new generator class, not a mutation of
this one.

View File

@@ -0,0 +1,262 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GrafanaDashboardGenerator } from "./dashboard-generator.js";
const fixedRng = () => 0.5;
describe("GrafanaDashboardGenerator", () => {
it("emits a schema-versioned dashboard with required fields", () => {
const d = new GrafanaDashboardGenerator({
title: "Test",
rng: fixedRng,
});
const out = d.generate();
expect(out.dashboard.schemaVersion).toBe(38);
expect(out.dashboard.version).toBe(1);
expect(out.dashboard.title).toBe("Test");
expect(out.overwrite).toBe(true);
});
it("derives a deterministic 9-char UID from rng", () => {
const d1 = new GrafanaDashboardGenerator({ title: "T", rng: fixedRng });
const d2 = new GrafanaDashboardGenerator({ title: "T", rng: fixedRng });
expect(d1.generate().dashboard.uid).toBe(d2.generate().dashboard.uid);
expect(d1.generate().dashboard.uid.length).toBe(9);
});
it("uses Math.random when no rng provided", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
expect(d.generate().dashboard.uid.length).toBe(9);
});
it("explicit uid overrides generator", () => {
const d = new GrafanaDashboardGenerator({
title: "T",
uid: "explicit",
rng: fixedRng,
});
expect(d.generate().dashboard.uid).toBe("explicit");
});
it("defaults timezone / refresh / editable / tags", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
const out = d.generate();
expect(out.dashboard.timezone).toBe("browser");
expect(out.dashboard.refresh).toBe("30s");
expect(out.dashboard.editable).toBe(true);
expect(out.dashboard.tags).toEqual([]);
});
it("respects custom timezone / refresh / editable / tags", () => {
const d = new GrafanaDashboardGenerator({
title: "T",
timezone: "UTC",
refresh: "5s",
editable: false,
tags: ["a", "b"],
});
const out = d.generate();
expect(out.dashboard.timezone).toBe("UTC");
expect(out.dashboard.refresh).toBe("5s");
expect(out.dashboard.editable).toBe(false);
expect(out.dashboard.tags).toEqual(["a", "b"]);
});
it("addPanel appends with Prometheus datasource on every target", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
d.addPanel({
title: "P1",
type: "graph",
targets: [{ expr: "up", legendFormat: "x" }],
});
const panel = d.generate().dashboard.panels[0] as Record<
string,
unknown
>;
expect(panel.title).toBe("P1");
const targets = panel.targets as Array<{
expr: string;
datasource: { uid: string };
legendFormat: string;
refId: string;
}>;
expect(targets[0].datasource.uid).toBe("prometheus");
expect(targets[0].refId).toBe("A");
});
it("auto-assigns sequential refIds A,B,C when omitted", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
d.addPanel({
title: "P",
type: "graph",
targets: [{ expr: "a" }, { expr: "b" }, { expr: "c" }],
});
const targets = (
d.generate().dashboard.panels[0] as Record<string, unknown>
).targets as Array<{ refId: string }>;
expect(targets.map((t) => t.refId)).toEqual(["A", "B", "C"]);
});
it("explicit refId / legendFormat survive", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
d.addPanel({
title: "P",
type: "graph",
targets: [{ expr: "a", refId: "Z", legendFormat: "lg" }],
});
const targets = (
d.generate().dashboard.panels[0] as Record<string, unknown>
).targets as Array<{ refId: string; legendFormat: string }>;
expect(targets[0].refId).toBe("Z");
expect(targets[0].legendFormat).toBe("lg");
});
it("auto-layout reflows every 2 panels by 8 vertical units", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
for (let i = 0; i < 4; i++) {
d.addPanel({
title: `P${i}`,
type: "stat",
targets: [{ expr: "x" }],
});
}
const ys = d
.generate()
.dashboard.panels.map(
(p) => (p as { gridPos: { y: number } }).gridPos.y
);
expect(ys).toEqual([0, 0, 8, 8]);
});
it("explicit gridPos wins over auto-layout", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
d.addPanel({
title: "P",
type: "stat",
targets: [{ expr: "x" }],
gridPos: { x: 5, y: 10, w: 6, h: 3 },
});
const p = d.generate().dashboard.panels[0] as {
gridPos: { x: number; y: number; w: number; h: number };
};
expect(p.gridPos).toEqual({ x: 5, y: 10, w: 6, h: 3 });
});
it("preserves user-supplied options blob", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
d.addPanel({
title: "P",
type: "stat",
targets: [{ expr: "x" }],
options: { custom: 42 },
});
const p = d.generate().dashboard.panels[0] as {
options: Record<string, unknown>;
};
expect(p.options).toEqual({ custom: 42 });
});
it("addRow emits a row-type panel", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
d.addRow("section");
const p = d.generate().dashboard.panels[0] as { type: string };
expect(p.type).toBe("row");
});
it("addRow rejects empty title", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
expect(() => d.addRow("")).toThrow();
});
it("panelId increments monotonically across panels and rows", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
d.addPanel({ title: "P1", type: "stat", targets: [{ expr: "x" }] });
d.addRow("R");
d.addPanel({ title: "P2", type: "stat", targets: [{ expr: "y" }] });
const ids = d
.generate()
.dashboard.panels.map((p) => (p as { id: number }).id);
expect(ids).toEqual([1, 2, 3]);
});
it("toJSON emits parseable JSON", () => {
const d = new GrafanaDashboardGenerator({
title: "T",
rng: fixedRng,
});
d.addPanel({ title: "P", type: "stat", targets: [{ expr: "x" }] });
const obj = JSON.parse(d.toJSON());
expect(obj.dashboard.title).toBe("T");
expect(obj.dashboard.panels.length).toBe(1);
});
it("panelCount reflects current entries", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
expect(d.panelCount).toBe(0);
d.addRow("R");
d.addPanel({ title: "P", type: "stat", targets: [{ expr: "x" }] });
expect(d.panelCount).toBe(2);
});
it("schema rejects empty title", () => {
expect(() => new GrafanaDashboardGenerator({ title: "" })).toThrow();
});
it("schema rejects unknown option keys", () => {
expect(
() =>
new GrafanaDashboardGenerator({
title: "T",
// @ts-expect-error — testing schema rejection
nope: true,
})
).toThrow();
});
it("addPanel rejects empty targets array", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
expect(() =>
d.addPanel({
title: "P",
type: "stat",
// @ts-expect-error — testing schema rejection
targets: [],
})
).toThrow();
});
it("addPanel rejects invalid panel type", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
expect(() =>
d.addPanel({
title: "P",
// @ts-expect-error — testing schema rejection
type: "pie",
targets: [{ expr: "x" }],
})
).toThrow();
});
it("supports all 5 panel types end-to-end", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
for (const type of [
"graph",
"stat",
"gauge",
"table",
"heatmap",
] as const) {
d.addPanel({ title: type, type, targets: [{ expr: "x" }] });
}
const types = d
.generate()
.dashboard.panels.map((p) => (p as { type: string }).type);
expect(types).toEqual(["graph", "stat", "gauge", "table", "heatmap"]);
});
it("includes default timepicker intervals", () => {
const d = new GrafanaDashboardGenerator({ title: "T" });
const intervals = d.generate().dashboard.timepicker.refresh_intervals;
expect(intervals).toContain("5s");
expect(intervals).toContain("1d");
});
});

View File

@@ -0,0 +1,186 @@
/**
* Grafana dashboard JSON generator — universal.
*
* Pure data builder; emits the JSON model Grafana's import API accepts.
* No HTTP / no `Math.random` at module scope — UID generation is delegated
* to an injectable `Rng` so callers get deterministic UIDs in tests.
*/
import z from "zod";
export type GrafanaPanelType = "graph" | "stat" | "gauge" | "table" | "heatmap";
export const PromQLTargetSchema = z
.object({
expr: z.string().min(1),
legendFormat: z.string().optional(),
refId: z.string().optional(),
})
.strict();
export type PromQLTarget = z.infer<typeof PromQLTargetSchema>;
export const GridPosSchema = z
.object({
x: z.number().int().min(0),
y: z.number().int().min(0),
w: z.number().int().positive(),
h: z.number().int().positive(),
})
.strict();
export type GridPos = z.infer<typeof GridPosSchema>;
export const PanelOptionsSchema = z
.object({
title: z.string().min(1),
type: z.enum(["graph", "stat", "gauge", "table", "heatmap"]),
targets: z.array(PromQLTargetSchema).nonempty(),
gridPos: GridPosSchema.optional(),
options: z.record(z.string(), z.unknown()).optional(),
})
.strict();
export type PanelOptions = z.infer<typeof PanelOptionsSchema>;
export const GrafanaDashboardOptionsSchema = z
.object({
title: z.string().min(1),
uid: z.string().optional(),
tags: z.array(z.string()).optional(),
timezone: z.string().optional(),
refresh: z.string().optional(),
editable: z.boolean().optional(),
rng: z.custom<() => number>((v) => typeof v === "function").optional(),
})
.strict();
export type GrafanaDashboardOptions = z.infer<
typeof GrafanaDashboardOptionsSchema
>;
export interface DashboardJson {
readonly dashboard: {
readonly uid: string;
readonly title: string;
readonly tags: ReadonlyArray<string>;
readonly timezone: string;
readonly refresh: string;
readonly editable: boolean;
readonly schemaVersion: 38;
readonly version: 1;
readonly panels: ReadonlyArray<Record<string, unknown>>;
readonly time: { readonly from: string; readonly to: string };
readonly timepicker: {
readonly refresh_intervals: ReadonlyArray<string>;
};
};
readonly overwrite: true;
}
const DEFAULT_INTERVALS = [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d",
] as const;
const PROM_DATASOURCE = { type: "prometheus", uid: "prometheus" } as const;
export class GrafanaDashboardGenerator {
private readonly title: string;
private readonly uid: string;
private readonly tags: ReadonlyArray<string>;
private readonly timezone: string;
private readonly refresh: string;
private readonly editable: boolean;
private readonly rng: () => number;
private readonly panels: Record<string, unknown>[] = [];
private panelId = 1;
public constructor(options: GrafanaDashboardOptions) {
const parsed = GrafanaDashboardOptionsSchema.parse(options);
this.rng = parsed.rng ?? Math.random;
this.title = parsed.title;
this.uid = parsed.uid ?? this.generateUID();
this.tags = parsed.tags ?? [];
this.timezone = parsed.timezone ?? "browser";
this.refresh = parsed.refresh ?? "30s";
this.editable = parsed.editable ?? true;
}
public addPanel(panel: PanelOptions): void {
const parsed = PanelOptionsSchema.parse(panel);
const gridPos = parsed.gridPos ?? {
x: 0,
y: Math.floor(this.panels.length / 2) * 8,
w: 12,
h: 8,
};
this.panels.push({
id: this.panelId++,
title: parsed.title,
type: parsed.type,
gridPos,
targets: parsed.targets.map((t, i) => ({
expr: t.expr,
legendFormat: t.legendFormat ?? "",
refId: t.refId ?? String.fromCharCode(65 + i),
datasource: PROM_DATASOURCE,
})),
options: parsed.options ?? {},
});
}
public addRow(title: string): void {
if (title.length === 0)
throw new Error("addRow: title must be non-empty");
this.panels.push({
id: this.panelId++,
title,
type: "row",
gridPos: {
x: 0,
y: Math.floor(this.panels.length / 2) * 8,
w: 24,
h: 1,
},
});
}
public generate(): DashboardJson {
return {
dashboard: {
uid: this.uid,
title: this.title,
tags: this.tags,
timezone: this.timezone,
refresh: this.refresh,
editable: this.editable,
schemaVersion: 38,
version: 1,
panels: this.panels,
time: { from: "now-6h", to: "now" },
timepicker: { refresh_intervals: [...DEFAULT_INTERVALS] },
},
overwrite: true,
};
}
public toJSON(): string {
return JSON.stringify(this.generate(), null, 2);
}
public get panelCount(): number {
return this.panels.length;
}
private generateUID(): string {
return Math.floor(this.rng() * Number.MAX_SAFE_INTEGER)
.toString(36)
.padStart(9, "0")
.slice(0, 9);
}
}

View File

@@ -0,0 +1,78 @@
# `HistoricalDataStore`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Retains per-metric history with a configurable two-tier policy: a bounded raw-sample ring (for short-window analysis) plus rollup buckets (for long-window trend reads). The collector emits ephemeral samples; the store persists them so dashboards / capacity planners can ask "what did p95 look like an hour ago?".
## Features
- Two retention knobs — `maxRawSamples` (default 4096) and `maxRawAgeMs` (default 1 hour) — applied conjunctively on every ingest.
- `rollup(metricName, cutoffMs)` aggregates raw samples older than the cutoff into 1-minute (default) bucket-wide `count` / `sum` / `min` / `max` records.
- `forget` and `clear` give consumers explicit control over per-metric and global pruning.
- Throws `RETENTION_INVALID` at construction for non-positive retention parameters.
- Injectable clock for deterministic tests.
## Usage: Basic Example
```ts
import {
HistoricalDataStore,
MetricCollector,
} from "@suaveplan/network-plugin-monitor";
const collector = new MetricCollector();
collector.define({ name: "node.cpu", category: "node", type: "gauge" });
collector.record("node.cpu", 0.42);
const store = new HistoricalDataStore();
store.ingest(collector.snapshot("node.cpu"));
store.raws("node.cpu").length; // 1
```
## Usage: Advanced Example
```ts
import {
HistoricalDataStore,
type MetricValue,
} from "@suaveplan/network-plugin-monitor";
let now = 1_700_000_000_000;
const store = new HistoricalDataStore({
maxRawSamples: 1_000,
maxRawAgeMs: 5 * 60_000,
rollupBucketMs: 60_000,
clock: () => now,
});
const samples: MetricValue[] = Array.from({ length: 200 }, (_, i) => ({
value: i,
recordedAt: now - 6 * 60_000 + i * 100,
}));
store.ingest({ name: "x", category: "node", type: "gauge", samples });
now += 1_000;
const buckets = store.rollup("x", now - 60_000);
console.log(buckets.length, buckets[0]?.count);
```
## API Reference
- `class HistoricalDataStore(config?)`
- `ingest(metric: Metric): void` — appends new samples and trims by age + count.
- `raws(metricName): ReadonlyArray<MetricValue>` — non-destructive snapshot.
- `rollup(metricName, cutoffMs): ReadonlyArray<HistoricalRollup>` — compacts old samples into buckets and returns the buckets produced.
- `rollups_for(metricName): ReadonlyArray<HistoricalRollup>` — every bucket recorded so far.
- `forget(metricName): void`.
- `clear(): void`.
- `interface HistoricalRollup` — `{ bucketStart, count, sum, min, max }`.
## Implementation Notes
- Trimming uses age-then-count: samples are first dropped if they fall outside `maxRawAgeMs`, then the remainder is truncated to `maxRawSamples` (most recent kept). This keeps the store from accumulating samples from stale, no-longer-emitting metrics.
- Rollup buckets are aligned to wall-clock (`floor(recordedAt / bucketMs) * bucketMs`) so the same wall-clock minute always yields the same `bucketStart`, regardless of when `rollup` runs.
- Rollups for a metric accumulate in insertion order; consumers that need a stable identity per `(metricName, bucketStart)` should de-duplicate downstream.
- The store does not implement query / search — exporters and dashboards consume `raws` / `rollups_for` directly. Splitting that responsibility keeps the store focused on retention.

View File

@@ -0,0 +1,138 @@
import { NETWORK_MON_CODES, NetworkMonError } from "../error-codes.js";
import type { Metric, MetricValue } from "../types/metric.types.js";
export interface HistoricalRollup {
readonly bucketStart: number;
readonly count: number;
readonly sum: number;
readonly min: number;
readonly max: number;
}
export interface HistoricalDataStoreConfig {
/** Maximum number of raw samples retained per metric. Defaults to 4096. */
readonly maxRawSamples?: number;
/** Maximum time-window of raw samples retained, in ms. Defaults to 1 hour. */
readonly maxRawAgeMs?: number;
/** Rollup bucket width in ms. Defaults to 60_000 (1 minute). */
readonly rollupBucketMs?: number;
readonly clock?: () => number;
}
/**
* Persists per-metric history with a configurable retention policy.
* Raw samples are kept in a bounded ring; older samples are
* aggregated into 1-minute (default) rollup buckets retained
* indefinitely (until the consumer prunes).
*/
export class HistoricalDataStore {
private readonly raw = new Map<string, MetricValue[]>();
private readonly rollups = new Map<string, HistoricalRollup[]>();
private readonly maxRawSamples: number;
private readonly maxRawAgeMs: number;
private readonly rollupBucketMs: number;
private readonly clock: () => number;
public constructor(config: HistoricalDataStoreConfig = {}) {
const maxRawSamples = config.maxRawSamples ?? 4_096;
const maxRawAgeMs = config.maxRawAgeMs ?? 60 * 60 * 1_000;
const rollupBucketMs = config.rollupBucketMs ?? 60_000;
if (maxRawSamples <= 0 || maxRawAgeMs <= 0 || rollupBucketMs <= 0) {
throw new NetworkMonError(
NETWORK_MON_CODES.RETENTION_INVALID,
"history.invalid-retention",
{
context: {
maxRawSamples,
maxRawAgeMs,
rollupBucketMs,
},
}
);
}
this.maxRawSamples = maxRawSamples;
this.maxRawAgeMs = maxRawAgeMs;
this.rollupBucketMs = rollupBucketMs;
this.clock = config.clock ?? (() => Date.now());
}
public ingest(metric: Metric): void {
const buf = this.raw.get(metric.name) ?? [];
for (const sample of metric.samples) buf.push(sample);
const now = this.clock();
const cutoff = now - this.maxRawAgeMs;
const trimmedAge = buf.filter((s) => s.recordedAt >= cutoff);
const trimmed =
trimmedAge.length > this.maxRawSamples
? trimmedAge.slice(trimmedAge.length - this.maxRawSamples)
: trimmedAge;
this.raw.set(metric.name, trimmed);
}
public raws(metricName: string): ReadonlyArray<MetricValue> {
return [...(this.raw.get(metricName) ?? [])];
}
/**
* Compact raw samples into rollup buckets older than `cutoffMs`.
* Returns the rollups produced for the call (in insertion order).
*/
public rollup(
metricName: string,
cutoffMs: number
): ReadonlyArray<HistoricalRollup> {
const buf = this.raw.get(metricName);
if (buf === undefined || buf.length === 0) return [];
const remaining: MetricValue[] = [];
const buckets = new Map<number, HistoricalRollup>();
for (const sample of buf) {
if (sample.recordedAt >= cutoffMs) {
remaining.push(sample);
continue;
}
const bucketStart =
Math.floor(sample.recordedAt / this.rollupBucketMs) *
this.rollupBucketMs;
const existing = buckets.get(bucketStart);
if (existing === undefined) {
buckets.set(bucketStart, {
bucketStart,
count: 1,
sum: sample.value,
min: sample.value,
max: sample.value,
});
} else {
buckets.set(bucketStart, {
bucketStart,
count: existing.count + 1,
sum: existing.sum + sample.value,
min: Math.min(existing.min, sample.value),
max: Math.max(existing.max, sample.value),
});
}
}
this.raw.set(metricName, remaining);
const out = Array.from(buckets.values()).sort(
(a, b) => a.bucketStart - b.bucketStart
);
const existingRollups = this.rollups.get(metricName) ?? [];
this.rollups.set(metricName, [...existingRollups, ...out]);
return out;
}
public rollups_for(metricName: string): ReadonlyArray<HistoricalRollup> {
return [...(this.rollups.get(metricName) ?? [])];
}
/** Drop everything stored for `metricName` (raw + rollups). */
public forget(metricName: string): void {
this.raw.delete(metricName);
this.rollups.delete(metricName);
}
public clear(): void {
this.raw.clear();
this.rollups.clear();
}
}

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MetricCollector } from "../collector/metric-collector.js";
import { NETWORK_MON_CODES, type NetworkMonError } from "../error-codes.js";
import { HistoricalDataStore } from "./historical-data-store.js";
const seedMetric = (now: number) => {
const c = new MetricCollector({ clock: () => now });
c.define({ name: "h", category: "node", type: "gauge", windowSize: 1024 });
return c;
};
describe("HistoricalDataStore: retention + rollup + aggregation", () => {
it("ingest trims by maxRawSamples", () => {
const now = 1_000;
const store = new HistoricalDataStore({
maxRawSamples: 5,
clock: () => now,
});
const c = seedMetric(now);
for (let i = 0; i < 10; i += 1) c.record("h", i);
store.ingest(c.snapshot("h"));
expect(store.raws("h").length).toBe(5);
});
it("ingest trims by maxRawAgeMs", () => {
let now = 1_000;
const store = new HistoricalDataStore({
maxRawAgeMs: 500,
clock: () => now,
});
const c = seedMetric(now);
c.record("h", 1);
now += 600;
store.ingest(c.snapshot("h"));
expect(store.raws("h").length).toBe(0);
});
it("rollup compacts pre-cutoff samples into buckets", () => {
const start = 60_000; // bucket 0
const store = new HistoricalDataStore({
rollupBucketMs: 60_000,
clock: () => start + 120_000,
});
const c = seedMetric(start);
c.record("h", 10);
c.record("h", 30);
store.ingest(c.snapshot("h"));
const buckets = store.rollup("h", start + 60_000);
expect(buckets.length).toBe(1);
expect(buckets[0]?.count).toBe(2);
expect(buckets[0]?.sum).toBe(40);
expect(buckets[0]?.min).toBe(10);
expect(buckets[0]?.max).toBe(30);
// raws now empty
expect(store.raws("h").length).toBe(0);
expect(store.rollups_for("h").length).toBe(1);
});
it("rollup over multiple buckets produces sorted results", () => {
const store = new HistoricalDataStore({
rollupBucketMs: 60_000,
clock: () => 0,
});
const c = new MetricCollector({ clock: () => 0 });
c.define({ name: "h", category: "node", type: "gauge" });
c.record("h", 1);
c.record("h", 2);
// Mutate timestamps post-hoc for the test
const snapshot = c.snapshot("h");
const samples = snapshot.samples.map((s, i) => ({
...s,
recordedAt: i === 0 ? 30_000 : 90_000,
}));
store.ingest({ ...snapshot, samples });
const buckets = store.rollup("h", 1_000_000);
expect(buckets.map((b) => b.bucketStart)).toEqual([0, 60_000]);
});
it("rollup leaves post-cutoff samples in raw and compacts only pre-cutoff", () => {
const store = new HistoricalDataStore({
rollupBucketMs: 60_000,
clock: () => 0,
});
const c = new MetricCollector({ clock: () => 0 });
c.define({ name: "h", category: "node", type: "gauge" });
c.record("h", 1);
c.record("h", 2);
const snapshot = c.snapshot("h");
const samples = snapshot.samples.map((s, i) => ({
...s,
recordedAt: i === 0 ? 30_000 : 90_000,
}));
store.ingest({ ...snapshot, samples });
// Cutoff at 60_000 — only the first sample (30_000) is pre-cutoff.
const buckets = store.rollup("h", 60_000);
expect(buckets.length).toBe(1);
expect(buckets[0]?.count).toBe(1);
// The post-cutoff sample remains in raws.
expect(store.raws("h").length).toBe(1);
});
it("rollup on missing / empty metric is a no-op", () => {
const store = new HistoricalDataStore();
expect(store.rollup("nope", Date.now())).toEqual([]);
});
it("forget drops raws + rollups for a metric", () => {
const store = new HistoricalDataStore();
const c = seedMetric(0);
c.record("h", 1);
store.ingest(c.snapshot("h"));
store.rollup("h", 1_000_000);
store.forget("h");
expect(store.raws("h").length).toBe(0);
expect(store.rollups_for("h").length).toBe(0);
});
it("clear empties everything", () => {
const store = new HistoricalDataStore();
const c = seedMetric(0);
c.record("h", 1);
store.ingest(c.snapshot("h"));
store.clear();
expect(store.raws("h").length).toBe(0);
});
it("invalid retention throws RETENTION_INVALID", () => {
try {
new HistoricalDataStore({ maxRawSamples: 0 });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMonError).code).toBe(
NETWORK_MON_CODES.RETENTION_INVALID
);
}
});
it("uses Date.now by default for the clock", () => {
const store = new HistoricalDataStore();
expect(() => store.raws("missing")).not.toThrow();
});
});

View File

@@ -0,0 +1,119 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Alert
export {
type AlertBus,
AlertEngine,
type AlertEngineConfig,
} from "./alert/alert-engine.js";
// Collector
export {
MetricCollector,
type MetricCollectorConfig,
type MetricDefinition,
} from "./collector/metric-collector.js";
// Errors
export {
NETWORK_MON_CODES,
NetworkMonError,
type NetworkMonErrorCode,
} from "./error-codes.js";
// Grafana (folded from upstream @suaveplan/network-observability)
export {
type DashboardJson,
GrafanaDashboardGenerator,
type GrafanaDashboardOptions,
GrafanaDashboardOptionsSchema,
type GrafanaPanelType,
type GridPos,
GridPosSchema,
type PanelOptions,
PanelOptionsSchema,
type PromQLTarget,
PromQLTargetSchema,
} from "./grafana/dashboard-generator.js";
// History
export {
HistoricalDataStore,
type HistoricalDataStoreConfig,
type HistoricalRollup,
} from "./history/historical-data-store.js";
// Monitor
export {
NetworkMonitor,
type NetworkMonitorConfig,
} from "./monitor/network-monitor.js";
// Plugin
export {
MonitorPlugin,
type MonitorPluginConfig,
} from "./plugin/monitor-plugin.js";
// Prometheus exposition (folded from upstream @suaveplan/network-observability)
export {
createPrometheusExposition,
PROMETHEUS_TEXT_CONTENT_TYPE,
type PrometheusExposition,
type PrometheusExpositionOptions,
PrometheusExpositionOptionsSchema,
} from "./prometheus/prometheus-exposition.js";
// Quality classification + adaptive degradation (folded from upstream @suaveplan/network-quality)
export {
BandwidthMonitor,
type BandwidthMonitorConfig,
} from "./quality/bandwidth-monitor.js";
export {
DegradationManager,
type DegradationManagerConfig,
} from "./quality/degradation-manager.js";
export {
createQualityMiddleware,
DEFAULT_CRITICAL_TYPES,
DEFAULT_POSITION_UPDATE_TYPES,
type QualityMiddlewareHandlers,
type QualityMiddlewareMessage,
type QualityMiddlewareOptions,
} from "./quality/quality-middleware.js";
export {
type BandwidthSample,
BandwidthSampleSchema,
type LevelThreshold,
LevelThresholdSchema,
type QualityChangeEvent,
QualityLevel,
type QualityThresholds,
QualityThresholdsSchema,
} from "./quality/quality-types.js";
// Sampler
export {
AdaptiveSampler,
type AdaptiveSamplerConfig,
} from "./sampler/adaptive-sampler.js";
// Tracer
export {
NetworkTracer,
type NetworkTracerConfig,
} from "./tracer/network-tracer.js";
// Types
export type {
AlertComparison,
AlertEvaluation,
AlertRule,
AlertSeverity,
AlertState,
AlertStatistic,
} from "./types/alert.types.js";
export type {
Metric,
MetricCategory,
MetricStats,
MetricType,
MetricValue,
} from "./types/metric.types.js";
export type {
NetworkSpan,
NetworkSpanEvent,
NetworkTrace,
SamplingStrategy,
TraceContext,
} from "./types/trace.types.js";

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MetricCollector } from "../collector/metric-collector.js";
import { NetworkMonitor } from "./network-monitor.js";
describe("NetworkMonitor: domain-specific record helpers", () => {
const setup = () => {
const collector = new MetricCollector();
const monitor = new NetworkMonitor({ collector });
return { collector, monitor };
};
it("recordNodeMetric defines + records under node.<name>", () => {
const { collector, monitor } = setup();
monitor.recordNodeMetric("p1", "cpu", 0.42);
const snap = collector.snapshot("node.cpu");
expect(snap.category).toBe("node");
expect(snap.samples[0]?.labels).toEqual({ peerId: "p1" });
});
it("recordConnectionMetric defines as histogram", () => {
const { collector, monitor } = setup();
monitor.recordConnectionMetric("a", "b", "latency", 35);
expect(collector.snapshot("connection.latency").type).toBe("histogram");
});
it("recordMessageMetric defines as histogram", () => {
const { collector, monitor } = setup();
monitor.recordMessageMetric("registry:add", "processingTime", 5);
expect(collector.snapshot("message.processingTime").type).toBe(
"histogram"
);
});
it("recordHierarchyMetric defines as gauge", () => {
const { collector, monitor } = setup();
monitor.recordHierarchyMetric("tier0", "nodeCount", 3);
expect(collector.snapshot("hierarchy.nodeCount").type).toBe("gauge");
});
it("listByCategory returns only metrics for the requested category", () => {
const { monitor } = setup();
monitor.recordNodeMetric("p1", "cpu", 0.4);
monitor.recordMessageMetric("any", "size", 100);
monitor.recordHierarchyMetric("tier0", "nodeCount", 2);
const node = monitor.listByCategory("node");
expect(node.map((m) => m.name)).toEqual(["node.cpu"]);
});
it("does NOT redefine metrics that already exist", () => {
const { collector, monitor } = setup();
collector.define({
name: "node.cpu",
category: "node",
type: "histogram", // override the default
});
monitor.recordNodeMetric("p1", "cpu", 0.5);
expect(collector.snapshot("node.cpu").type).toBe("histogram");
});
it("connection / message / hierarchy reuse pre-existing definitions", () => {
const { collector, monitor } = setup();
collector.define({
name: "connection.latency",
category: "connection",
type: "gauge",
});
collector.define({
name: "message.size",
category: "message",
type: "counter",
});
collector.define({
name: "hierarchy.depth",
category: "hierarchy",
type: "histogram",
});
monitor.recordConnectionMetric("a", "b", "latency", 1);
monitor.recordMessageMetric("any", "size", 1);
monitor.recordHierarchyMetric("tier0", "depth", 1);
expect(collector.snapshot("connection.latency").type).toBe("gauge");
expect(collector.snapshot("message.size").type).toBe("counter");
expect(collector.snapshot("hierarchy.depth").type).toBe("histogram");
});
});

View File

@@ -0,0 +1,67 @@
# `NetworkMonitor`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Domain-specific facade around `MetricCollector` that pins canonical metric names per category. Call sites use `recordNodeMetric(peerId, "cpu", 0.4)` instead of building `"node.cpu"` strings by hand and re-defining the metric on every call. The monitor lazily defines metrics on first record so consumers do not need to coordinate a startup phase.
## Features
- Four record helpers — `recordNodeMetric`, `recordConnectionMetric`, `recordMessageMetric`, `recordHierarchyMetric` — corresponding to the `MetricCategory` union.
- Lazy metric definition: the first record per metric name auto-defines a `gauge` (node, hierarchy) or `histogram` (connection, message) using sensible defaults. Callers that need a different `MetricType` can `collector.define(...)` first.
- Each helper attaches the relevant id labels (peerId, sourceId/targetId, messageType, scope) so percentiles and group-bys downstream are well-typed.
- `listByCategory` returns the full snapshot for a category for dashboards / audit views.
## Usage: Basic Example
```ts
import {
MetricCollector,
NetworkMonitor,
} from "@suaveplan/network-plugin-monitor";
const collector = new MetricCollector();
const monitor = new NetworkMonitor({ collector });
monitor.recordNodeMetric("peer-1", "cpu", 0.42);
monitor.recordConnectionMetric("peer-1", "peer-2", "latency", 35);
```
## Usage: Advanced Example
```ts
import {
MetricCollector,
NetworkMonitor,
} from "@suaveplan/network-plugin-monitor";
const collector = new MetricCollector();
collector.define({
name: "message.processingTime",
category: "message",
type: "histogram",
windowSize: 4096,
});
const monitor = new NetworkMonitor({ collector });
monitor.recordMessageMetric("registry:update", "processingTime", 3.2);
const heap = monitor.listByCategory("message");
console.log(heap.length);
```
## API Reference
- `class NetworkMonitor(config: { collector })`
- `recordNodeMetric(peerId, name, value): void`.
- `recordConnectionMetric(sourceId, targetId, name, value): void`.
- `recordMessageMetric(messageType, name, value): void`.
- `recordHierarchyMetric(scope, name, value): void`.
- `listByCategory(category): ReadonlyArray<Metric>`.
## Implementation Notes
- The fully-qualified name is always `"<category>.<name>"`. Pre-defined metrics that follow this convention are reused; metrics defined under a different shape are NOT overridden, so callers can stage explicit definitions before calling the helpers.
- Defaults bias toward `gauge` for instantaneous values (CPU, node count) and `histogram` for distributions (latency, processing time). Counter-shaped metrics should be defined explicitly and recorded through `collector.increment(...)` rather than the monitor.
- `listByCategory` runs a single `scrape()` and filters by category — adequate for the typical 10s-of-metrics scale; high-cardinality deployments should index the result themselves.
- The monitor never throws on its own — only the collector raises (`METRIC_NOT_FOUND`, `METRIC_TYPE_MISMATCH`). The lazy-define behaviour is the single concession to ergonomics.

View File

@@ -0,0 +1,89 @@
import type { MetricCollector } from "../collector/metric-collector.js";
import type { Metric, MetricCategory } from "../types/metric.types.js";
export interface NetworkMonitorConfig {
readonly collector: MetricCollector;
}
const NODE_CATEGORY: MetricCategory = "node";
const CONNECTION_CATEGORY: MetricCategory = "connection";
const MESSAGE_CATEGORY: MetricCategory = "message";
const HIERARCHY_CATEGORY: MetricCategory = "hierarchy";
/**
* Domain-specific facade over `MetricCollector`. Exposes one record
* helper per metric category so call sites do not need to remember the
* canonical metric name format.
*/
export class NetworkMonitor {
private readonly collector: MetricCollector;
public constructor(config: NetworkMonitorConfig) {
this.collector = config.collector;
}
public recordNodeMetric(peerId: string, name: string, value: number): void {
const fqn = `node.${name}`;
if (!this.collector.has(fqn)) {
this.collector.define({
name: fqn,
category: NODE_CATEGORY,
type: "gauge",
});
}
this.collector.record(fqn, value, { peerId });
}
public recordConnectionMetric(
sourceId: string,
targetId: string,
name: string,
value: number
): void {
const fqn = `connection.${name}`;
if (!this.collector.has(fqn)) {
this.collector.define({
name: fqn,
category: CONNECTION_CATEGORY,
type: "histogram",
});
}
this.collector.record(fqn, value, { sourceId, targetId });
}
public recordMessageMetric(
messageType: string,
name: string,
value: number
): void {
const fqn = `message.${name}`;
if (!this.collector.has(fqn)) {
this.collector.define({
name: fqn,
category: MESSAGE_CATEGORY,
type: "histogram",
});
}
this.collector.record(fqn, value, { messageType });
}
public recordHierarchyMetric(
scope: string,
name: string,
value: number
): void {
const fqn = `hierarchy.${name}`;
if (!this.collector.has(fqn)) {
this.collector.define({
name: fqn,
category: HIERARCHY_CATEGORY,
type: "gauge",
});
}
this.collector.record(fqn, value, { scope });
}
public listByCategory(category: MetricCategory): ReadonlyArray<Metric> {
return this.collector.scrape().filter((m) => m.category === category);
}
}

View File

@@ -0,0 +1,75 @@
# `MonitorPlugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single `NetworkPlugin` that bundles every monitor subsystem (`MetricCollector`, `NetworkMonitor`, `AlertEngine`, `NetworkTracer`, `HistoricalDataStore`, optional `AdaptiveSampler`) and ties their lifecycle to a host `NetworkNode`. Each subsystem is a public field so consumers extend behaviour by reaching past the plugin facade.
## Features
- Optional sampler — `null` when no `sampler` config is provided so deployments without rate concerns pay nothing.
- `install(node)` constructs the alert engine bound to the node's bus so `monitor:alert` events flow without manual wiring.
- `dispose` clears every subsystem and detaches the node so a re-install starts clean.
- Public `collector` field is the substantive surface — define metrics, record samples, scrape pulls — without going through helper APIs.
## Usage: Basic Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { MonitorPlugin } from "@suaveplan/network-plugin-monitor";
const bus = new NetworkBus();
const node = new NetworkNode({ id: "node-1", networkBus: bus });
const plugin = new MonitorPlugin();
node.use(plugin);
await node.start();
plugin.monitor.recordNodeMetric("peer-1", "cpu", 0.42);
plugin.alert?.addRule({
id: "cpu-warn",
metricName: "node.cpu",
statistic: "latest",
comparison: ">",
threshold: 0.9,
severity: "warning",
});
plugin.alert?.evaluateAll();
```
## Usage: Advanced Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { MonitorPlugin } from "@suaveplan/network-plugin-monitor";
const plugin = new MonitorPlugin({
sampler: { highWatermark: 1_000, lowWatermark: 200 },
tracer: { strategy: "rate-limited", rateLimitPerSec: 100 },
history: { maxRawSamples: 16_384, rollupBucketMs: 30_000 },
alert: { defaultCooldownMs: 30_000 },
});
const node = new NetworkNode({ id: "n", networkBus: new NetworkBus() });
node.use(plugin);
await node.start();
```
## API Reference
- `class MonitorPlugin implements NetworkPlugin`
- `name: "monitor"`.
- `collector` / `monitor` / `tracer` / `history` — public fields.
- `sampler: AdaptiveSampler | null` — present only when configured.
- `alert: AlertEngine | null` — set on `install`.
- `constructor(config?: MonitorPluginConfig)`.
- `install(node): void` / `start(): void` / `stop(): void` / `dispose(): void`.
- `host(): NetworkNode | null` / `isStarted(): boolean`.
- `interface MonitorPluginConfig` — `{ collector?, alert?, tracer?, history?, sampler? }`.
## Implementation Notes
- `alert` is created in `install` rather than the constructor because it depends on the node bus. The other subsystems are eagerly constructed so unit tests can build the plugin without a node.
- `dispose` resets the collector + alert cooldowns + history but keeps the subsystem references so consumers' field reads stay non-null until the next construct cycle. Callers that want a hard reset should construct a new plugin instance.
- `sampler` is intentionally optional. Most deployments do not need adaptive sampling at the metric layer; constructing it eagerly would force every consumer to think about watermarks they will never tune.
- The plugin holds _references_ to subsystems rather than re-exporting their methods so refactors inside any subsystem do not reshape the plugin facade.

View File

@@ -0,0 +1,97 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import {
type AlertBus,
AlertEngine,
type AlertEngineConfig,
} from "../alert/alert-engine.js";
import {
MetricCollector,
type MetricCollectorConfig,
} from "../collector/metric-collector.js";
import {
HistoricalDataStore,
type HistoricalDataStoreConfig,
} from "../history/historical-data-store.js";
import { NetworkMonitor } from "../monitor/network-monitor.js";
import {
AdaptiveSampler,
type AdaptiveSamplerConfig,
} from "../sampler/adaptive-sampler.js";
import {
NetworkTracer,
type NetworkTracerConfig,
} from "../tracer/network-tracer.js";
export interface MonitorPluginConfig {
readonly collector?: MetricCollectorConfig;
readonly alert?: Omit<AlertEngineConfig, "collector" | "bus">;
readonly tracer?: NetworkTracerConfig;
readonly history?: HistoricalDataStoreConfig;
readonly sampler?: AdaptiveSamplerConfig;
}
/**
* `NetworkPlugin` that bundles every monitor subsystem behind one
* lifecycle. Every subsystem is exposed as a public field so consumers
* can inspect / extend without going through the plugin abstraction.
*/
export class MonitorPlugin implements NetworkPlugin {
public readonly name = "monitor";
public readonly collector: MetricCollector;
public readonly monitor: NetworkMonitor;
public readonly tracer: NetworkTracer;
public readonly history: HistoricalDataStore;
public readonly sampler: AdaptiveSampler | null;
public alert: AlertEngine | null = null;
private readonly alertConfig: Omit<AlertEngineConfig, "collector" | "bus">;
private node: NetworkNode | null = null;
private started = false;
public constructor(config: MonitorPluginConfig = {}) {
this.collector = new MetricCollector(config.collector);
this.monitor = new NetworkMonitor({ collector: this.collector });
this.tracer = new NetworkTracer(config.tracer);
this.history = new HistoricalDataStore(config.history);
this.sampler =
config.sampler === undefined
? null
: new AdaptiveSampler(config.sampler);
this.alertConfig = config.alert ?? {};
}
public install(node: NetworkNode): void {
this.node = node;
// `NetworkNode` always carries a `networkBus`, so the alert engine
// is unconditionally bus-bound.
const bus = node.networkBus as unknown as AlertBus;
this.alert = new AlertEngine({
...this.alertConfig,
collector: this.collector,
bus,
});
}
public start(): void {
this.started = true;
}
public stop(): void {
this.started = false;
}
public dispose(): void {
this.collector.reset();
this.alert?.reset();
this.history.clear();
this.alert = null;
this.node = null;
}
public host(): NetworkNode | null {
return this.node;
}
public isStarted(): boolean {
return this.started;
}
}

View File

@@ -0,0 +1,68 @@
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MonitorPlugin } from "./monitor-plugin.js";
const newNode = () =>
new NetworkNode({ id: "node-1", networkBus: new NetworkBus() });
describe("MonitorPlugin: NetworkPlugin lifecycle", () => {
it("constructs with sensible defaults", () => {
const plugin = new MonitorPlugin();
expect(plugin.name).toBe("monitor");
expect(plugin.collector).toBeDefined();
expect(plugin.monitor).toBeDefined();
expect(plugin.tracer).toBeDefined();
expect(plugin.history).toBeDefined();
expect(plugin.sampler).toBeNull();
expect(plugin.alert).toBeNull();
});
it("install wires the alert engine to the bus", async () => {
const plugin = new MonitorPlugin();
const node = newNode();
node.use(plugin);
await node.start();
expect(plugin.alert).not.toBeNull();
expect(plugin.host()).not.toBeNull();
expect(plugin.isStarted()).toBe(true);
await node.stop();
expect(plugin.isStarted()).toBe(false);
});
it("optional sampler is constructed when configured", () => {
const plugin = new MonitorPlugin({
sampler: { highWatermark: 10, lowWatermark: 1 },
});
expect(plugin.sampler).not.toBeNull();
});
it("dispose clears collector + history + alert + sampler ref", async () => {
const plugin = new MonitorPlugin({
sampler: { highWatermark: 10, lowWatermark: 1 },
});
const node = newNode();
node.use(plugin);
await node.start();
plugin.collector.define({
name: "z",
category: "node",
type: "gauge",
});
plugin.collector.record("z", 1);
plugin.history.ingest(plugin.collector.snapshot("z"));
plugin.alert?.addRule({
id: "t",
metricName: "z",
statistic: "latest",
comparison: ">",
threshold: 0,
severity: "warning",
});
await node.stop();
plugin.dispose();
expect(plugin.alert).toBeNull();
expect(plugin.host()).toBeNull();
expect(plugin.collector.snapshot("z").samples.length).toBe(0);
expect(plugin.history.raws("z").length).toBe(0);
});
});

View File

@@ -0,0 +1,120 @@
# `prometheus-exposition`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Universal Prometheus exposition-format emitter. Reads `Metric[]` snapshots
from `MetricCollector.scrape()` and writes the Prometheus text exposition
format directly — no `prom-client` runtime dependency, no Node-only surface,
and no external metrics framework.
The emitter exists because the upstream `@suaveplan/network-observability`
shipped a `prom-client`-based exporter that pinned the package to Node-tier
runtimes. This rewrite preserves the externally-observable contract (a
text-format string + a stable `Content-Type` header) while staying universal
so the browser-tier `@suaveplan/network-plugin-monitor` consumer can ship a
single `/metrics` endpoint across all targets.
## Features
- Pure-text exposition output (Prometheus `0.0.4` body, UTF-8).
- Conformant metric / label name + label value escaping (Prometheus grammar).
- Counter / gauge / histogram / summary support — histograms and summaries are
expanded into `_count`, `_sum`, `_min`, `_max`, `_mean`, and configurable
quantile lines (`quantile="0.5|0.95|0.99"` defaults).
- Optional millisecond-precision timestamps per the exposition spec.
- `prefix` + `defaultLabels` applied to every metric.
- Zod-validated options (strict object), default-fill on every field.
- Zero runtime deps beyond `zod` (already in the package).
## Usage: Basic Example
```ts
import {
MetricCollector,
createPrometheusExposition,
} from "@suaveplan/network-plugin-monitor";
const collector = new MetricCollector();
collector.define({ name: "requests", category: "message", type: "counter" });
collector.increment("requests");
const exposition = createPrometheusExposition();
console.log(exposition.format(collector.scrape()));
// # HELP requests message counter
// # TYPE requests counter
// requests 1
```
## Usage: Advanced Example
```ts
import {
MetricCollector,
createPrometheusExposition,
PROMETHEUS_TEXT_CONTENT_TYPE,
} from "@suaveplan/network-plugin-monitor";
const collector = new MetricCollector();
collector.define({ name: "rtt_ms", category: "connection", type: "histogram" });
for (const v of [5, 12, 18, 25, 88, 142]) collector.record("rtt_ms", v);
const exposition = createPrometheusExposition({
prefix: "suaveplan_",
defaultLabels: { tier: "edge", region: "us-east" },
includeTimestamps: true,
quantiles: [0.5, 0.9, 0.99],
});
// HTTP handler shape — runtime-agnostic Request/Response
export function metricsHandler(): Response {
return new Response(exposition.format(collector.scrape()), {
headers: { "content-type": PROMETHEUS_TEXT_CONTENT_TYPE },
});
}
```
## API Reference
### `createPrometheusExposition(options?: PrometheusExpositionOptions): PrometheusExposition`
Returns an emitter bound to the supplied options. Options are Zod-parsed —
unknown keys are rejected. All fields are optional; defaults are applied
field-by-field.
Options:
| Field | Default | Description |
|---|---|---|
| `prefix` | `""` | Prepended to every metric name. |
| `defaultLabels` | `{}` | Merged into every line (sample labels win on conflict). |
| `includeTimestamps` | `false` | Append `recordedAt` (ms) to each line. |
| `quantiles` | `[0.5, 0.95, 0.99]` | Quantile lines emitted for histograms / summaries. |
Return value:
- `contentType` — pinned `text/plain; version=0.0.4; charset=utf-8`.
- `format(snapshot)` — emits the exposition body. Always trailing-newline
terminated (Prometheus parsers require it). Empty snapshots return `"\n"`.
### `PROMETHEUS_TEXT_CONTENT_TYPE`
The exact `Content-Type` Prometheus servers and `promtool` expect. Exported
as a constant so consumers don't string-template it.
## Implementation Notes
- The emitter only reads the **last** sample of counter / gauge metrics
(Prometheus expects a single current value per series). Histogram and
summary metrics fold the full rolling-window buffer into summary stats.
- Metric / label name sanitisation follows the Prometheus grammar exactly —
characters outside `[a-zA-Z_:][a-zA-Z0-9_:]*` (metric) or
`[a-zA-Z_][a-zA-Z0-9_]*` (label key) are replaced with `_`, and label
values escape `"`, `\`, and `\n` per spec.
- Integer values emit without a decimal point (`1` not `1.0`); non-integers
use JavaScript's default `toString()` (matches Prometheus's `Inf`/`-Inf`/
`NaN` literals when produced).
- The emitter is stateless — safe to share one instance across many concurrent
`format(...)` calls.

View File

@@ -0,0 +1,194 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MetricCollector } from "../collector/metric-collector.js";
import {
createPrometheusExposition,
PROMETHEUS_TEXT_CONTENT_TYPE,
} from "./prometheus-exposition.js";
describe("prometheus exposition", () => {
it("emits counter HELP/TYPE/value lines", () => {
const c = new MetricCollector();
c.define({ name: "requests", category: "message", type: "counter" });
c.increment("requests");
c.increment("requests");
const text = createPrometheusExposition().format(c.scrape());
expect(text).toContain("# HELP requests message counter");
expect(text).toContain("# TYPE requests counter");
expect(text).toContain("requests 2");
expect(text.endsWith("\n")).toBe(true);
});
it("emits a gauge as type gauge with last value only", () => {
const c = new MetricCollector();
c.define({ name: "active", category: "node", type: "gauge" });
c.record("active", 7);
c.record("active", 12);
const text = createPrometheusExposition().format(c.scrape());
expect(text).toContain("# TYPE active gauge");
expect(text).toContain("active 12");
expect(text).not.toContain("active 7\n");
});
it("expands histograms into count/sum/min/max/mean + quantiles", () => {
const c = new MetricCollector();
c.define({ name: "rtt", category: "connection", type: "histogram" });
for (const v of [10, 20, 30, 40, 50]) c.record("rtt", v);
const text = createPrometheusExposition({
quantiles: [0.5, 0.9],
}).format(c.scrape());
expect(text).toContain("# TYPE rtt summary");
expect(text).toContain("rtt_count 5");
expect(text).toContain("rtt_sum 150");
expect(text).toContain("rtt_min 10");
expect(text).toContain("rtt_max 50");
expect(text).toContain("rtt_mean 30");
expect(text).toContain('rtt{quantile="0.5"} 30');
expect(text).toContain('rtt{quantile="0.9"} 50');
});
it("expands timers / summary metrics with empty buffer producing zeroes", () => {
const c = new MetricCollector();
c.define({ name: "latency", category: "message", type: "summary" });
const text = createPrometheusExposition().format(c.scrape());
expect(text).toContain("latency_count 0");
expect(text).toContain("latency_sum 0");
expect(text).toContain("latency_min 0");
expect(text).toContain("latency_max 0");
expect(text).toContain("latency_mean 0");
expect(text).toContain('latency{quantile="0.5"} 0');
});
it("applies prefix and merges defaultLabels with sample labels", () => {
const c = new MetricCollector();
c.define({ name: "events", category: "message", type: "counter" });
c.record("events", 1, { peer: "p1" });
const text = createPrometheusExposition({
prefix: "sp_",
defaultLabels: { tier: "edge" },
}).format(c.scrape());
expect(text).toContain("# TYPE sp_events counter");
expect(text).toContain('sp_events{tier="edge",peer="p1"} 1');
});
it("sample labels override defaultLabels on key collision", () => {
const c = new MetricCollector();
c.define({ name: "events", category: "message", type: "counter" });
c.record("events", 1, { tier: "core" });
const text = createPrometheusExposition({
defaultLabels: { tier: "edge" },
}).format(c.scrape());
expect(text).toContain('events{tier="core"} 1');
expect(text).not.toContain('events{tier="edge"}');
});
it("appends timestamps when includeTimestamps=true (gauge + histogram)", () => {
let now = 1_700_000_000_000;
const c = new MetricCollector({ clock: () => now });
c.define({ name: "g", category: "node", type: "gauge" });
c.record("g", 1);
now += 5;
c.define({ name: "h", category: "connection", type: "histogram" });
c.record("h", 10);
c.record("h", 20);
const text = createPrometheusExposition({
includeTimestamps: true,
}).format(c.scrape());
expect(text).toMatch(/g 1 \d+/);
expect(text).toMatch(/h_count 2 \d+/);
});
it("sanitises invalid metric and label names + escapes label values", () => {
const c = new MetricCollector();
c.define({ name: "weird-name", category: "node", type: "gauge" });
c.record("weird-name", 1, { "bad-key": 'val"with\\quote\nnl' });
const text = createPrometheusExposition().format(c.scrape());
expect(text).toContain("weird_name");
expect(text).toContain('bad_key="val\\"with\\\\quote\\nnl"');
});
it("sanitises numeric-leading metric and label names", () => {
const c = new MetricCollector();
c.define({ name: "9digit", category: "node", type: "gauge" });
c.record("9digit", 1, { "1bad": "x" });
const text = createPrometheusExposition().format(c.scrape());
expect(text).toMatch(/_digit\{_bad="x"\} 1/);
});
it("counter with no samples emits HELP+TYPE but no value line", () => {
const c = new MetricCollector();
c.define({ name: "ev", category: "message", type: "counter" });
const text = createPrometheusExposition().format(c.scrape());
expect(text).toContain("# TYPE ev counter");
const lines = text.trim().split("\n");
expect(lines.some((l) => /^ev\b/.test(l))).toBe(false);
});
it("formats integers without decimal and floats verbatim", () => {
const c = new MetricCollector();
c.define({ name: "ratio", category: "node", type: "gauge" });
c.record("ratio", 0.5);
const text = createPrometheusExposition().format(c.scrape());
expect(text).toContain("ratio 0.5");
});
it("formats NaN / +Inf / -Inf literals", () => {
const c = new MetricCollector();
c.define({ name: "f1", category: "node", type: "gauge" });
c.record("f1", Number.NaN);
c.define({ name: "f2", category: "node", type: "gauge" });
c.record("f2", Number.POSITIVE_INFINITY);
c.define({ name: "f3", category: "node", type: "gauge" });
c.record("f3", Number.NEGATIVE_INFINITY);
const text = createPrometheusExposition().format(c.scrape());
expect(text).toContain("f1 NaN");
expect(text).toContain("f2 +Inf");
expect(text).toContain("f3 -Inf");
});
it("pins Content-Type to the Prometheus 0.0.4 text format", () => {
const e = createPrometheusExposition();
expect(e.contentType).toBe(PROMETHEUS_TEXT_CONTENT_TYPE);
expect(PROMETHEUS_TEXT_CONTENT_TYPE).toBe(
"text/plain; version=0.0.4; charset=utf-8"
);
});
it("rejects unknown option keys at the Zod boundary", () => {
expect(() =>
// @ts-expect-error — testing schema rejection
createPrometheusExposition({ unexpected: true })
).toThrow();
});
it("rejects empty-quantile array at the Zod boundary", () => {
expect(() => createPrometheusExposition({ quantiles: [] })).toThrow();
});
it("rejects out-of-range quantile values", () => {
expect(() =>
createPrometheusExposition({ quantiles: [1.5] })
).toThrow();
});
it("empty snapshot returns a single newline", () => {
const text = createPrometheusExposition().format([]);
expect(text).toBe("\n");
});
it("sanitises a completely-empty metric name to '_'", () => {
const c = new MetricCollector();
c.define({ name: "", category: "node", type: "gauge" });
c.record("", 1);
const text = createPrometheusExposition().format(c.scrape());
expect(text).toContain("# TYPE _ gauge");
expect(text).toContain("_ 1");
});
it("sanitises an empty label key to '_'", () => {
const c = new MetricCollector();
c.define({ name: "g", category: "node", type: "gauge" });
c.record("g", 1, { "": "x" });
const text = createPrometheusExposition().format(c.scrape());
expect(text).toContain('_="x"');
});
});

View File

@@ -0,0 +1,195 @@
/**
* Universal Prometheus exposition-format emitter.
*
* Reads `Metric[]` snapshots from `MetricCollector.scrape()` and writes the
* Prometheus text exposition format directly — no `prom-client` runtime dep.
*
* Layout:
* counter / gauge → single `<name>{labels} <value> <ts_ms>` line
* histogram / summary → emits derived `<name>_count`, `<name>_sum`,
* `<name>_min`, `<name>_max`, `<name>_mean`, `<name>{quantile="0.5|0.95|0.99"}`
*
* Metric / label names are sanitised to the Prometheus grammar
* (`[a-zA-Z_:][a-zA-Z0-9_:]*` for names, `[a-zA-Z_][a-zA-Z0-9_]*` for labels).
* Label values are escaped for `"`, `\`, and newline per the exposition spec.
*/
import z from "zod";
import type { Metric, MetricValue } from "../types/metric.types.js";
const PROM_NAME_HEAD = /[^a-zA-Z_:]/g;
const PROM_NAME_TAIL = /[^a-zA-Z0-9_:]/g;
const PROM_LABEL_HEAD = /[^a-zA-Z_]/g;
const PROM_LABEL_TAIL = /[^a-zA-Z0-9_]/g;
export const PrometheusExpositionOptionsSchema = z
.object({
prefix: z.string().default(""),
defaultLabels: z.record(z.string(), z.string()).default({}),
includeTimestamps: z.boolean().default(false),
quantiles: z
.array(z.number().min(0).max(1))
.nonempty()
.default([0.5, 0.95, 0.99]),
})
.strict();
export type PrometheusExpositionOptions = z.input<
typeof PrometheusExpositionOptionsSchema
>;
type Resolved = Required<z.output<typeof PrometheusExpositionOptionsSchema>>;
/** Content-Type header callers should pin for an HTTP `/metrics` endpoint. */
export const PROMETHEUS_TEXT_CONTENT_TYPE =
"text/plain; version=0.0.4; charset=utf-8";
export interface PrometheusExposition {
readonly contentType: string;
/** Emits the full exposition text for the supplied snapshot. */
format(snapshot: ReadonlyArray<Metric>): string;
}
export function createPrometheusExposition(
options: PrometheusExpositionOptions = {}
): PrometheusExposition {
const opts: Resolved = PrometheusExpositionOptionsSchema.parse(
options
) as Resolved;
return {
contentType: PROMETHEUS_TEXT_CONTENT_TYPE,
format(snapshot) {
const out: string[] = [];
for (const m of snapshot) emitMetric(out, m, opts);
return `${out.join("\n")}\n`;
},
};
}
function emitMetric(out: string[], m: Metric, opts: Resolved): void {
const baseName = sanitizeName(opts.prefix + m.name);
const help = `# HELP ${baseName} ${m.category} ${m.type}`;
const last = m.samples[m.samples.length - 1];
if (m.type === "counter" || m.type === "gauge") {
out.push(help);
out.push(`# TYPE ${baseName} ${m.type}`);
if (last !== undefined) {
out.push(formatLine(baseName, last, opts));
}
return;
}
// histogram / summary — derive summary stats from samples
const stats = computeStats(m.samples);
out.push(help);
out.push(`# TYPE ${baseName} summary`);
const labels = mergeLabels(opts.defaultLabels, last?.labels);
const labelText = labelText0(labels);
const ts = opts.includeTimestamps && last ? ` ${last.recordedAt}` : "";
out.push(`${baseName}_count${labelText} ${stats.count}${ts}`);
out.push(`${baseName}_sum${labelText} ${formatFloat(stats.sum)}${ts}`);
out.push(`${baseName}_min${labelText} ${formatFloat(stats.min)}${ts}`);
out.push(`${baseName}_max${labelText} ${formatFloat(stats.max)}${ts}`);
out.push(`${baseName}_mean${labelText} ${formatFloat(stats.mean)}${ts}`);
for (const q of opts.quantiles) {
const quantileLabel = { ...labels, quantile: q.toString() };
const value = percentile(stats.sortedValues, q);
out.push(
`${baseName}${labelText0(quantileLabel)} ${formatFloat(value)}${ts}`
);
}
}
function formatLine(name: string, sample: MetricValue, opts: Resolved): string {
const labels = mergeLabels(opts.defaultLabels, sample.labels);
const ts = opts.includeTimestamps ? ` ${sample.recordedAt}` : "";
return `${name}${labelText0(labels)} ${formatFloat(sample.value)}${ts}`;
}
function mergeLabels(
defaults: Readonly<Record<string, string>>,
extra: Readonly<Record<string, string>> | undefined
): Record<string, string> {
if (extra === undefined) return { ...defaults };
return { ...defaults, ...extra };
}
function labelText0(labels: Readonly<Record<string, string>>): string {
const entries = Object.entries(labels);
if (entries.length === 0) return "";
const parts = entries.map(
([k, v]) => `${sanitizeLabelKey(k)}="${escapeLabelValue(v)}"`
);
return `{${parts.join(",")}}`;
}
function sanitizeName(name: string): string {
if (name.length === 0) return "_";
const head = name[0] as string;
const rest = name.slice(1);
return (
head.replace(PROM_NAME_HEAD, "_") + rest.replace(PROM_NAME_TAIL, "_")
);
}
function sanitizeLabelKey(key: string): string {
if (key.length === 0) return "_";
const head = key[0] as string;
const rest = key.slice(1);
return (
head.replace(PROM_LABEL_HEAD, "_") + rest.replace(PROM_LABEL_TAIL, "_")
);
}
function escapeLabelValue(v: string): string {
return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
}
function formatFloat(n: number): string {
if (Number.isNaN(n)) return "NaN";
if (n === Number.POSITIVE_INFINITY) return "+Inf";
if (n === Number.NEGATIVE_INFINITY) return "-Inf";
return Number.isInteger(n) ? n.toFixed(0) : n.toString();
}
interface InternalStats {
readonly count: number;
readonly sum: number;
readonly min: number;
readonly max: number;
readonly mean: number;
readonly sortedValues: ReadonlyArray<number>;
}
function computeStats(samples: ReadonlyArray<MetricValue>): InternalStats {
if (samples.length === 0) {
return {
count: 0,
sum: 0,
min: 0,
max: 0,
mean: 0,
sortedValues: [],
};
}
const values = samples.map((s) => s.value);
const sorted = [...values].sort((a, b) => a - b);
let total = 0;
for (const v of sorted) total += v;
return {
count: sorted.length,
sum: total,
min: sorted[0] as number,
max: sorted[sorted.length - 1] as number,
mean: total / sorted.length,
sortedValues: sorted,
};
}
function percentile(sortedAsc: ReadonlyArray<number>, p: number): number {
if (sortedAsc.length === 0) return 0;
const idx = Math.min(
sortedAsc.length - 1,
Math.max(0, Math.ceil(sortedAsc.length * p) - 1)
);
return sortedAsc[idx] as number;
}

View File

@@ -0,0 +1,79 @@
# bandwidth-monitor
## Purpose
`BandwidthMonitor` records per-link bandwidth / RTT / packet-loss measurements into a bounded ring buffer and exposes single-pole exponentially-smoothed readouts that the `DegradationManager` consumes to drive quality-level transitions. Raw samples are kept in chronological order so callers can compute their own statistics over a window (median, p99, etc.) while the EMA provides a fast, allocation-free running summary for hot paths.
## Features
- Bounded ring (`maxSamples`, default 60) — oldest sample is dropped on overflow.
- Single-pole EMA: `α·new + (1−α)·prev`; `smoothingFactor` configurable (default `0.3`).
- Injectable `clock` — every timestamp comes from the constructor argument, never from a module-scope `Date.now()` call, so tests are deterministic.
- `onSample(listener)` subscription returning an unsubscribe function — no global event bus required.
- Validation: `maxSamples ≥ 1` and `smoothingFactor ∈ (0, 1]`; out-of-range values throw `RangeError` at construction so misconfiguration fails fast.
- Tree-shakeable: no `@suaveplan/events` dependency at runtime; the optional listener set lives only when used.
## Usage: Basic Example
```ts
import { BandwidthMonitor } from "@suaveplan/network-plugin-monitor";
const monitor = new BandwidthMonitor();
monitor.recordSample({
bytesPerSecond: 250_000,
rttMs: 40,
packetLossRate: 0.005,
});
console.log(monitor.getSmoothedBandwidth());
console.log(monitor.getSmoothedRtt());
```
## Usage: Advanced Example
```ts
import { BandwidthMonitor } from "@suaveplan/network-plugin-monitor";
let now = 0;
const monitor = new BandwidthMonitor({
clock: () => now,
maxSamples: 120,
smoothingFactor: 0.1, // slow-responding, very stable EMA
});
const unsub = monitor.onSample((s) => {
console.log(`recorded at ${s.timestamp}: ${s.bytesPerSecond} B/s`);
});
for (let i = 0; i < 200; i += 1) {
now += 1_000;
monitor.recordSample({
bytesPerSecond: 100_000 + i * 1_000,
rttMs: 50,
packetLossRate: 0,
});
}
// Only the most-recent 120 samples survive the ring.
console.log(monitor.getSampleCount());
unsub();
```
## API Reference
- `new BandwidthMonitor(config?: BandwidthMonitorConfig)` — `config.clock` (default `Date.now`), `config.maxSamples` (default 60), `config.smoothingFactor` (default 0.3). Throws `RangeError` on out-of-range bounds.
- `recordSample(sample)` — `sample` is a `BandwidthSample` minus `timestamp`; `timestamp` is filled from the injected clock.
- `getSamples(count?)` → `readonly BandwidthSample[]`. Most-recent `count`; defaults to all. `count <= 0` returns an empty array.
- `getSmoothedBandwidth() / getSmoothedRtt() / getSmoothedPacketLoss()` → `number`. Zero before the first sample.
- `getSampleCount()` → `number`.
- `clear()` — wipes the ring + resets EMA state to zero / un-initialised.
- `onSample(listener)` → unsubscribe function. Listeners are notified after the ring + EMA update.
## Implementation Notes
- EMA seed is the **first** sample (not zero) so the smoothed readout starts at a meaningful value instead of biased-toward-zero. The `initialized` flag flips on the first `recordSample` and is reset by `clear()`.
- The ring is a plain array with `shift()` — `O(n)` per overflow, acceptable at the default `maxSamples = 60` and avoiding the allocation overhead of a deque. Callers expecting bursts of `maxSamples > 1024` should consider a wrapper that uses a typed-array ring.
- Listener exceptions propagate to the caller of `recordSample`; this is deliberate so the monitor never silently swallows downstream bugs.
- The `Set<SampleListener>` avoids duplicate registration of the same handler and provides O(1) unsubscribe.

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BandwidthMonitor } from "./bandwidth-monitor.js";
describe("BandwidthMonitor", () => {
it("records samples and timestamps from injected clock", () => {
let now = 1_000;
const m = new BandwidthMonitor({ clock: () => now });
m.recordSample({ bytesPerSecond: 100, rttMs: 10, packetLossRate: 0 });
now = 2_000;
m.recordSample({ bytesPerSecond: 200, rttMs: 20, packetLossRate: 0 });
const samples = m.getSamples();
expect(samples.length).toBe(2);
expect(samples[0]?.timestamp).toBe(1_000);
expect(samples[1]?.timestamp).toBe(2_000);
});
it("uses Date.now when no clock provided", () => {
const before = Date.now();
const m = new BandwidthMonitor();
m.recordSample({ bytesPerSecond: 1, rttMs: 1, packetLossRate: 0 });
const ts = m.getSamples()[0]?.timestamp ?? 0;
expect(ts).toBeGreaterThanOrEqual(before);
});
it("EMA seeds from the first sample (not zero)", () => {
const m = new BandwidthMonitor({ smoothingFactor: 0.5 });
m.recordSample({
bytesPerSecond: 1_000,
rttMs: 50,
packetLossRate: 0.1,
});
expect(m.getSmoothedBandwidth()).toBe(1_000);
expect(m.getSmoothedRtt()).toBe(50);
expect(m.getSmoothedPacketLoss()).toBe(0.1);
});
it("EMA blends per α formula on subsequent samples", () => {
const m = new BandwidthMonitor({ smoothingFactor: 0.5 });
m.recordSample({ bytesPerSecond: 100, rttMs: 10, packetLossRate: 0 });
m.recordSample({ bytesPerSecond: 200, rttMs: 20, packetLossRate: 0.1 });
// α=0.5 → (100 + 200) / 2 = 150
expect(m.getSmoothedBandwidth()).toBeCloseTo(150);
expect(m.getSmoothedRtt()).toBeCloseTo(15);
expect(m.getSmoothedPacketLoss()).toBeCloseTo(0.05);
});
it("respects maxSamples ring bound", () => {
const m = new BandwidthMonitor({ maxSamples: 3 });
for (let i = 0; i < 10; i += 1) {
m.recordSample({ bytesPerSecond: i, rttMs: 1, packetLossRate: 0 });
}
expect(m.getSampleCount()).toBe(3);
const last = m.getSamples();
expect(last[0]?.bytesPerSecond).toBe(7);
expect(last[2]?.bytesPerSecond).toBe(9);
});
it("getSamples(count) returns the most-recent N", () => {
const m = new BandwidthMonitor();
for (let i = 0; i < 5; i += 1) {
m.recordSample({ bytesPerSecond: i, rttMs: 1, packetLossRate: 0 });
}
expect(m.getSamples(2).map((s) => s.bytesPerSecond)).toEqual([3, 4]);
});
it("getSamples(0) returns an empty array", () => {
const m = new BandwidthMonitor();
m.recordSample({ bytesPerSecond: 1, rttMs: 1, packetLossRate: 0 });
expect(m.getSamples(0)).toEqual([]);
expect(m.getSamples(-1)).toEqual([]);
});
it("clear() wipes ring + EMA state", () => {
const m = new BandwidthMonitor();
m.recordSample({ bytesPerSecond: 100, rttMs: 10, packetLossRate: 0 });
m.clear();
expect(m.getSampleCount()).toBe(0);
expect(m.getSmoothedBandwidth()).toBe(0);
expect(m.getSmoothedRtt()).toBe(0);
expect(m.getSmoothedPacketLoss()).toBe(0);
m.recordSample({ bytesPerSecond: 500, rttMs: 5, packetLossRate: 0.5 });
// After clear, EMA reseeds from this sample.
expect(m.getSmoothedBandwidth()).toBe(500);
});
it("onSample listeners fire per recorded sample", () => {
const m = new BandwidthMonitor();
const seen: number[] = [];
const unsub = m.onSample((s) => seen.push(s.bytesPerSecond));
m.recordSample({ bytesPerSecond: 10, rttMs: 1, packetLossRate: 0 });
m.recordSample({ bytesPerSecond: 20, rttMs: 1, packetLossRate: 0 });
unsub();
m.recordSample({ bytesPerSecond: 30, rttMs: 1, packetLossRate: 0 });
expect(seen).toEqual([10, 20]);
});
it("throws on invalid maxSamples", () => {
expect(() => new BandwidthMonitor({ maxSamples: 0 })).toThrow(
RangeError
);
expect(() => new BandwidthMonitor({ maxSamples: -1 })).toThrow(
RangeError
);
expect(() => new BandwidthMonitor({ maxSamples: Number.NaN })).toThrow(
RangeError
);
});
it("throws on invalid smoothingFactor", () => {
expect(() => new BandwidthMonitor({ smoothingFactor: 0 })).toThrow(
RangeError
);
expect(() => new BandwidthMonitor({ smoothingFactor: 1.5 })).toThrow(
RangeError
);
expect(() => new BandwidthMonitor({ smoothingFactor: -0.1 })).toThrow(
RangeError
);
expect(
() => new BandwidthMonitor({ smoothingFactor: Number.NaN })
).toThrow(RangeError);
});
it("accepts smoothingFactor = 1 (full responsiveness)", () => {
const m = new BandwidthMonitor({ smoothingFactor: 1 });
m.recordSample({ bytesPerSecond: 100, rttMs: 10, packetLossRate: 0 });
m.recordSample({ bytesPerSecond: 999, rttMs: 99, packetLossRate: 0.5 });
expect(m.getSmoothedBandwidth()).toBe(999);
expect(m.getSmoothedRtt()).toBe(99);
expect(m.getSmoothedPacketLoss()).toBe(0.5);
});
});

View File

@@ -0,0 +1,131 @@
/**
* Exponentially-smoothed bandwidth / RTT / loss recorder.
*
* Records `BandwidthSample` measurements into a bounded ring and exposes
* smoothed values for downstream quality classification. Smoothing uses a
* single-pole EMA (`α·new + (1−α)·prev`) so callers control how responsive
* vs. how stable the readout is.
*
* No hidden globals: every wall-clock read goes through the injectable
* `clock` so tests are fully deterministic.
*/
import type { BandwidthSample } from "./quality-types.js";
export interface BandwidthMonitorConfig {
/** Wall-clock source for sample timestamps. Defaults to `Date.now`. */
readonly clock?: () => number;
/** Maximum samples retained in the ring. */
readonly maxSamples?: number;
/** EMA smoothing factor (0..1); higher = more responsive. */
readonly smoothingFactor?: number;
}
const DEFAULTS = {
maxSamples: 60,
smoothingFactor: 0.3,
} as const;
type SampleListener = (sample: BandwidthSample) => void;
/**
* Bounded-ring bandwidth sample buffer with EMA-smoothed readouts.
*/
export class BandwidthMonitor {
private readonly clock: () => number;
private readonly maxSamples: number;
private readonly alpha: number;
private readonly samples: BandwidthSample[] = [];
private readonly listeners = new Set<SampleListener>();
private smoothedBandwidth = 0;
private smoothedRtt = 0;
private smoothedPacketLoss = 0;
private initialized = false;
public constructor(config: BandwidthMonitorConfig = {}) {
this.clock = config.clock ?? (() => Date.now());
const max = config.maxSamples ?? DEFAULTS.maxSamples;
if (!Number.isFinite(max) || max < 1) {
throw new RangeError("maxSamples must be >= 1");
}
const alpha = config.smoothingFactor ?? DEFAULTS.smoothingFactor;
if (!Number.isFinite(alpha) || alpha <= 0 || alpha > 1) {
throw new RangeError("smoothingFactor must be in (0, 1]");
}
this.maxSamples = max;
this.alpha = alpha;
}
/** Append a sample; timestamp is filled from the injected clock. */
public recordSample(sample: Omit<BandwidthSample, "timestamp">): void {
const full: BandwidthSample = { ...sample, timestamp: this.clock() };
this.samples.push(full);
while (this.samples.length > this.maxSamples) {
this.samples.shift();
}
this.applyEma(full);
for (const listener of this.listeners) {
listener(full);
}
}
/** Return the most-recent `count` samples (or all if omitted). */
public getSamples(count?: number): readonly BandwidthSample[] {
if (count === undefined) {
return this.samples.slice();
}
if (count <= 0) {
return [];
}
return this.samples.slice(-count);
}
public getSmoothedBandwidth(): number {
return this.smoothedBandwidth;
}
public getSmoothedRtt(): number {
return this.smoothedRtt;
}
public getSmoothedPacketLoss(): number {
return this.smoothedPacketLoss;
}
public getSampleCount(): number {
return this.samples.length;
}
/** Wipe ring + reset EMA state. */
public clear(): void {
this.samples.length = 0;
this.smoothedBandwidth = 0;
this.smoothedRtt = 0;
this.smoothedPacketLoss = 0;
this.initialized = false;
}
/** Subscribe to each recorded sample; returns an unsubscribe fn. */
public onSample(listener: SampleListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private applyEma(s: BandwidthSample): void {
if (!this.initialized) {
this.smoothedBandwidth = s.bytesPerSecond;
this.smoothedRtt = s.rttMs;
this.smoothedPacketLoss = s.packetLossRate;
this.initialized = true;
return;
}
const a = this.alpha;
this.smoothedBandwidth =
a * s.bytesPerSecond + (1 - a) * this.smoothedBandwidth;
this.smoothedRtt = a * s.rttMs + (1 - a) * this.smoothedRtt;
this.smoothedPacketLoss =
a * s.packetLossRate + (1 - a) * this.smoothedPacketLoss;
}
}

View File

@@ -0,0 +1,86 @@
# degradation-manager
## Purpose
`DegradationManager` is a 4-rung quality classifier that decides the current `QualityLevel` (`HIGH`/`MEDIUM`/`LOW`/`MINIMAL`) from a moving window of bandwidth samples and exposes that decision to downstream throttling middleware. It enforces two structural properties that pure thresholding would not: **one-step transitions** (no `MINIMAL → HIGH` jumps that skip the intermediate states a UI/codec needs to recover through) and **hysteresis** (a candidate transition must persist for `hysteresisMs` before committing, so a single bad measurement does not collapse a video call to MINIMAL).
## Features
- Four-level ladder with arithmetic ordering — `MINIMAL=0 < LOW=1 < MEDIUM=2 < HIGH=3`.
- Configurable per-rung `QualityThresholds` (`minBandwidthKbps`, `maxRttMs`, `maxPacketLoss`).
- `hysteresisMs` debounce — a candidate level must persist for this duration before being committed.
- One-step transitions — `evaluate()` only ever moves `±1` rung per call, so consumers always see intermediate states.
- Window-bounded averaging — `windowSize` controls how many trailing samples feed into classification.
- `forceLevel()` / `reset()` for manual overrides during tests or admin failover.
- `onLevelChange(handler)` subscription returning an unsubscribe function.
- Injectable `clock` — every wall-clock read goes through it, so hysteresis timing is fully deterministic in tests.
## Usage: Basic Example
```ts
import {
BandwidthMonitor,
DegradationManager,
QualityLevel,
} from "@suaveplan/network-plugin-monitor";
const monitor = new BandwidthMonitor();
const dm = new DegradationManager();
monitor.recordSample({ bytesPerSecond: 1_000, rttMs: 300, packetLossRate: 0.2 });
const level = dm.evaluate(monitor.getSamples());
console.log(level === QualityLevel.MINIMAL); // true: very poor link
```
## Usage: Advanced Example
```ts
import {
DegradationManager,
QualityLevel,
type QualityThresholds,
} from "@suaveplan/network-plugin-monitor";
let now = 0;
const strict: QualityThresholds = {
high: { minBandwidthKbps: 1_000, maxRttMs: 30, maxPacketLoss: 0.001 },
medium: { minBandwidthKbps: 400, maxRttMs: 80, maxPacketLoss: 0.02 },
low: { minBandwidthKbps: 100, maxRttMs: 200, maxPacketLoss: 0.1 },
};
const dm = new DegradationManager({
thresholds: strict,
hysteresisMs: 2_000,
windowSize: 5,
clock: () => now,
});
const unsub = dm.onLevelChange((event) => {
console.log(`${event.previousLevel} → ${event.newLevel} (${event.reason})`);
});
// ... feed `dm.evaluate(samples)` from a periodic loop driven by `now`.
// Pin to MEDIUM during a maintenance window:
dm.forceLevel(QualityLevel.MEDIUM);
// Later: release the pin
dm.reset();
unsub();
```
## API Reference
- `new DegradationManager(config?: DegradationManagerConfig)` — `thresholds` (default ships HD/SD/LD-style cutoffs), `hysteresisMs` (default `3000`, must be `≥ 0`), `windowSize` (default `10`, must be `≥ 1`), `clock` (default `Date.now`).
- `evaluate(samples)` → `QualityLevel`. Computes window averages, picks the matching rung, applies one-step + hysteresis constraints, and may commit a level change.
- `getCurrentLevel()` → `QualityLevel`.
- `forceLevel(level)` — pin the level; automatic classification is suspended until `reset()`. Emits `"forced"` event if the level changed.
- `reset()` — drop the pin, return to `HIGH`. Emits `"reset"` event if the level changed.
- `onLevelChange(handler)` → unsubscribe function. Handler receives `{ previousLevel, newLevel, timestamp, reason }` where `reason ∈ "conditions-changed" | "forced" | "reset"`.
- `getThresholds()` → readonly `QualityThresholds`.
## Implementation Notes
- The classifier prefers explicit rungs over arithmetic-derived ones: each rung's three constraints must all hold simultaneously. This is intentionally conservative — a sample that is fast but lossy stays at the level its loss permits.
- Hysteresis state is held in (`pendingLevel`, `pendingSince`). Changing the target mid-debounce *resets* the timer; the pending level must be stable for the full `hysteresisMs` window. This is the same behaviour as the upstream reference and matches what a moving-average debouncer does.
- `windowSize` operates on the **trailing** N samples of the input array — callers pass `bandwidthMonitor.getSamples()` and the manager slices internally. Passing a pre-sliced array is also fine.
- `forceLevel()` is one-shot; calling it twice with the same level is a no-op (no spurious event), and calling it with a different level emits exactly one change event.
- Listener exceptions propagate; we never swallow downstream bugs.

View File

@@ -0,0 +1,291 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DegradationManager } from "./degradation-manager.js";
import {
type BandwidthSample,
QualityLevel,
type QualityThresholds,
} from "./quality-types.js";
const highSample = (n = 1): BandwidthSample[] =>
Array.from({ length: n }, (_, i) => ({
timestamp: i,
bytesPerSecond: 1_000_000, // ≈ 7813 kbps
rttMs: 10,
packetLossRate: 0,
}));
const minimalSample = (n = 1): BandwidthSample[] =>
Array.from({ length: n }, (_, i) => ({
timestamp: i,
bytesPerSecond: 1_000, // ≈ 7.8 kbps
rttMs: 500,
packetLossRate: 0.5,
}));
const lowSample = (n = 1): BandwidthSample[] =>
Array.from({ length: n }, (_, i) => ({
timestamp: i,
bytesPerSecond: 10_000, // ≈ 78 kbps
rttMs: 180,
packetLossRate: 0.1,
}));
const mediumSample = (n = 1): BandwidthSample[] =>
Array.from({ length: n }, (_, i) => ({
timestamp: i,
bytesPerSecond: 50_000, // ≈ 390 kbps
rttMs: 80,
packetLossRate: 0.02,
}));
describe("DegradationManager", () => {
it("defaults to HIGH", () => {
const dm = new DegradationManager();
expect(dm.getCurrentLevel()).toBe(QualityLevel.HIGH);
});
it("returns currentLevel when no samples provided", () => {
const dm = new DegradationManager();
expect(dm.evaluate([])).toBe(QualityLevel.HIGH);
});
it("classifies HIGH samples as HIGH (no change)", () => {
const now = 0;
const dm = new DegradationManager({
clock: () => now,
hysteresisMs: 0,
});
expect(dm.evaluate(highSample(5))).toBe(QualityLevel.HIGH);
});
it("steps one rung at a time toward target", () => {
const now = 0;
const dm = new DegradationManager({
clock: () => now,
hysteresisMs: 0, // commit immediately
windowSize: 5,
});
// Start HIGH, feed MINIMAL — should step to MEDIUM (one rung), not MINIMAL.
const after1 = dm.evaluate(minimalSample(5));
expect(after1).toBe(QualityLevel.MEDIUM);
// Another evaluate: MEDIUM → LOW
const after2 = dm.evaluate(minimalSample(5));
expect(after2).toBe(QualityLevel.LOW);
// Another: LOW → MINIMAL
const after3 = dm.evaluate(minimalSample(5));
expect(after3).toBe(QualityLevel.MINIMAL);
});
it("hysteresis: candidate must persist for hysteresisMs before commit", () => {
let now = 0;
const dm = new DegradationManager({
clock: () => now,
hysteresisMs: 1_000,
windowSize: 3,
});
// First eval: candidate MEDIUM (one step down from HIGH), pending starts.
dm.evaluate(minimalSample(3));
expect(dm.getCurrentLevel()).toBe(QualityLevel.HIGH);
// Time passes, still bad.
now = 500;
dm.evaluate(minimalSample(3));
expect(dm.getCurrentLevel()).toBe(QualityLevel.HIGH);
// Once hysteresis elapses, commit happens.
now = 1_500;
dm.evaluate(minimalSample(3));
expect(dm.getCurrentLevel()).toBe(QualityLevel.MEDIUM);
});
it("hysteresis: changing target resets the timer", () => {
let now = 0;
const dm = new DegradationManager({
clock: () => now,
hysteresisMs: 1_000,
windowSize: 3,
});
dm.evaluate(minimalSample(3)); // pending MEDIUM @ t=0
now = 800;
dm.evaluate(highSample(3)); // target now matches current → pending cleared
now = 1_500;
dm.evaluate(minimalSample(3)); // pending MEDIUM restarts here
expect(dm.getCurrentLevel()).toBe(QualityLevel.HIGH);
now = 2_400;
dm.evaluate(minimalSample(3)); // 900ms since pending start → still HIGH
expect(dm.getCurrentLevel()).toBe(QualityLevel.HIGH);
now = 2_600;
dm.evaluate(minimalSample(3)); // 1100ms since pending start → commit
expect(dm.getCurrentLevel()).toBe(QualityLevel.MEDIUM);
});
it("LOW threshold classification + upward step from MINIMAL → LOW", () => {
let now = 0;
const dm = new DegradationManager({
clock: () => now,
hysteresisMs: 0,
windowSize: 3,
});
// Drive down to MINIMAL first
dm.forceLevel(QualityLevel.MINIMAL);
dm.reset(); // reset clears forced; back to HIGH
// Better idea: step manually using minimalSample then improve
// Force directly to MINIMAL state for the upward-step assertion
dm.forceLevel(QualityLevel.MINIMAL);
// After force, calling reset re-snaps to HIGH; instead use a manager without force
const dm2 = new DegradationManager({
clock: () => now,
hysteresisMs: 0,
windowSize: 3,
});
// Walk down to LOW (HIGH → MEDIUM → LOW)
dm2.evaluate(lowSample(3)); // HIGH → MEDIUM (target LOW, stepped MEDIUM)
now = 100;
dm2.evaluate(lowSample(3)); // MEDIUM → LOW (target LOW, stepped LOW): also covers determineLevel returning LOW
expect(dm2.getCurrentLevel()).toBe(QualityLevel.LOW);
// Now feed HIGH samples → upward step LOW → MEDIUM
now = 200;
dm2.evaluate(highSample(3));
expect(dm2.getCurrentLevel()).toBe(QualityLevel.MEDIUM);
// And another upward step → MEDIUM → HIGH
now = 300;
dm2.evaluate(highSample(3));
expect(dm2.getCurrentLevel()).toBe(QualityLevel.HIGH);
});
it("MEDIUM threshold classification", () => {
const now = 0;
const dm = new DegradationManager({
clock: () => now,
hysteresisMs: 0,
windowSize: 3,
});
// Step HIGH → MEDIUM
const got = dm.evaluate(mediumSample(3));
expect(got).toBe(QualityLevel.MEDIUM);
});
it("forceLevel pins and bypasses evaluation", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.LOW);
expect(dm.getCurrentLevel()).toBe(QualityLevel.LOW);
// Even with HIGH samples, stays forced.
expect(dm.evaluate(highSample(10))).toBe(QualityLevel.LOW);
});
it("forceLevel emits change event once if level differs", () => {
const dm = new DegradationManager();
const events: string[] = [];
dm.onLevelChange((e) => events.push(e.reason));
dm.forceLevel(QualityLevel.LOW);
expect(events).toEqual(["forced"]);
// Same level: no spurious event
dm.forceLevel(QualityLevel.LOW);
expect(events).toEqual(["forced"]);
});
it("reset returns to HIGH and emits if needed", () => {
const dm = new DegradationManager();
const reasons: string[] = [];
dm.onLevelChange((e) => reasons.push(e.reason));
dm.forceLevel(QualityLevel.LOW);
dm.reset();
expect(dm.getCurrentLevel()).toBe(QualityLevel.HIGH);
expect(reasons).toEqual(["forced", "reset"]);
});
it("reset on already-HIGH manager does not emit", () => {
const dm = new DegradationManager();
const reasons: string[] = [];
dm.onLevelChange((e) => reasons.push(e.reason));
dm.reset();
expect(reasons).toEqual([]);
});
it("custom thresholds are honoured", () => {
const now = 0;
const tight: QualityThresholds = {
high: { minBandwidthKbps: 10_000, maxRttMs: 5, maxPacketLoss: 0 },
medium: {
minBandwidthKbps: 5_000,
maxRttMs: 20,
maxPacketLoss: 0.001,
},
low: { minBandwidthKbps: 1_000, maxRttMs: 50, maxPacketLoss: 0.01 },
};
const dm = new DegradationManager({
thresholds: tight,
clock: () => now,
hysteresisMs: 0,
windowSize: 3,
});
expect(dm.getThresholds()).toBe(tight);
// Samples that would be HIGH under defaults are LOW under tight rules.
const got = dm.evaluate(highSample(3));
// 1 MB/s ≈ 7813 kbps → fails high (need 10000), fails medium (need 5000? yes passes 7813>5000),
// medium reqs: rttMs <= 20 (have 10 ✓), packetLoss <= 0.001 (have 0 ✓) → MEDIUM target,
// one-step from HIGH = MEDIUM.
expect(got).toBe(QualityLevel.MEDIUM);
});
it("listener unsubscribe stops further events", () => {
const dm = new DegradationManager();
const seen: string[] = [];
const unsub = dm.onLevelChange((e) => seen.push(e.reason));
dm.forceLevel(QualityLevel.LOW);
unsub();
dm.reset();
expect(seen).toEqual(["forced"]);
});
it("throws on invalid hysteresisMs", () => {
expect(() => new DegradationManager({ hysteresisMs: -1 })).toThrow(
RangeError
);
expect(
() => new DegradationManager({ hysteresisMs: Number.NaN })
).toThrow(RangeError);
});
it("throws on invalid windowSize", () => {
expect(() => new DegradationManager({ windowSize: 0 })).toThrow(
RangeError
);
expect(() => new DegradationManager({ windowSize: -2 })).toThrow(
RangeError
);
expect(
() => new DegradationManager({ windowSize: Number.NaN })
).toThrow(RangeError);
});
it("uses Date.now when no clock provided", () => {
const dm = new DegradationManager({ hysteresisMs: 0, windowSize: 3 });
expect(dm.evaluate(minimalSample(3))).toBe(QualityLevel.MEDIUM);
});
it("change event carries previous + new + timestamp", () => {
const now = 1_234;
const dm = new DegradationManager({
clock: () => now,
hysteresisMs: 0,
windowSize: 3,
});
let event: {
previousLevel: number;
newLevel: number;
timestamp: number;
} | null = null;
dm.onLevelChange((e) => {
event = e;
});
dm.evaluate(minimalSample(3));
expect(event).not.toBeNull();
const captured = event as unknown as {
previousLevel: number;
newLevel: number;
timestamp: number;
};
expect(captured.previousLevel).toBe(QualityLevel.HIGH);
expect(captured.newLevel).toBe(QualityLevel.MEDIUM);
expect(captured.timestamp).toBe(1_234);
});
});

View File

@@ -0,0 +1,215 @@
/**
* 4-level quality classifier with hysteresis and one-step transitions.
*
* Consumes `BandwidthSample[]` windows (typically from `BandwidthMonitor`)
* and decides the current `QualityLevel`. Transitions:
* - only adjacent rungs change per evaluation (no MINIMAL → HIGH jumps);
* - candidate transition must hold for `hysteresisMs` before commit;
* - `forceLevel(...)` overrides automatic classification until `reset()`.
*/
import {
type BandwidthSample,
type QualityChangeEvent,
QualityLevel,
type QualityThresholds,
} from "./quality-types.js";
export interface DegradationManagerConfig {
readonly thresholds?: QualityThresholds;
readonly hysteresisMs?: number;
readonly windowSize?: number;
readonly clock?: () => number;
}
const DEFAULT_THRESHOLDS: QualityThresholds = {
high: { minBandwidthKbps: 500, maxRttMs: 50, maxPacketLoss: 0.01 },
medium: { minBandwidthKbps: 200, maxRttMs: 100, maxPacketLoss: 0.05 },
low: { minBandwidthKbps: 50, maxRttMs: 200, maxPacketLoss: 0.15 },
};
const DEFAULTS = { hysteresisMs: 3_000, windowSize: 10 } as const;
type LevelChangeHandler = (event: QualityChangeEvent) => void;
export class DegradationManager {
private readonly thresholds: QualityThresholds;
private readonly hysteresisMs: number;
private readonly windowSize: number;
private readonly clock: () => number;
private readonly handlers = new Set<LevelChangeHandler>();
private currentLevel: QualityLevel = QualityLevel.HIGH;
private pendingLevel: QualityLevel | null = null;
private pendingSince = 0;
private forced = false;
public constructor(config: DegradationManagerConfig = {}) {
this.thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
const hyst = config.hysteresisMs ?? DEFAULTS.hysteresisMs;
if (!Number.isFinite(hyst) || hyst < 0) {
throw new RangeError("hysteresisMs must be >= 0");
}
const win = config.windowSize ?? DEFAULTS.windowSize;
if (!Number.isFinite(win) || win < 1) {
throw new RangeError("windowSize must be >= 1");
}
this.hysteresisMs = hyst;
this.windowSize = win;
this.clock = config.clock ?? (() => Date.now());
}
/** Run classification against the latest window. */
public evaluate(samples: readonly BandwidthSample[]): QualityLevel {
if (this.forced) {
return this.currentLevel;
}
const window = samples.slice(-this.windowSize);
if (window.length === 0) {
return this.currentLevel;
}
const avg = this.averages(window);
const target = this.determineLevel(avg);
const stepped = this.stepTowards(target);
return this.applyHysteresis(stepped);
}
public getCurrentLevel(): QualityLevel {
return this.currentLevel;
}
/** Pin the level until `reset()`; automatic classification is bypassed. */
public forceLevel(level: QualityLevel): void {
const previous = this.currentLevel;
this.forced = true;
this.currentLevel = level;
this.pendingLevel = null;
if (previous !== level) {
this.emitChange(previous, level, "forced");
}
}
/** Drop the forced level and restart from `HIGH`. */
public reset(): void {
const previous = this.currentLevel;
this.currentLevel = QualityLevel.HIGH;
this.pendingLevel = null;
this.pendingSince = 0;
this.forced = false;
if (previous !== QualityLevel.HIGH) {
this.emitChange(previous, QualityLevel.HIGH, "reset");
}
}
public onLevelChange(handler: LevelChangeHandler): () => void {
this.handlers.add(handler);
return () => {
this.handlers.delete(handler);
};
}
public getThresholds(): QualityThresholds {
return this.thresholds;
}
private averages(samples: readonly BandwidthSample[]): {
bandwidthKbps: number;
rttMs: number;
packetLoss: number;
} {
const n = samples.length;
let bps = 0;
let rtt = 0;
let loss = 0;
for (const s of samples) {
bps += s.bytesPerSecond;
rtt += s.rttMs;
loss += s.packetLossRate;
}
return {
bandwidthKbps: (bps / n / 1024) * 8,
rttMs: rtt / n,
packetLoss: loss / n,
};
}
private determineLevel(avg: {
bandwidthKbps: number;
rttMs: number;
packetLoss: number;
}): QualityLevel {
const t = this.thresholds;
if (
avg.bandwidthKbps >= t.high.minBandwidthKbps &&
avg.rttMs <= t.high.maxRttMs &&
avg.packetLoss <= t.high.maxPacketLoss
) {
return QualityLevel.HIGH;
}
if (
avg.bandwidthKbps >= t.medium.minBandwidthKbps &&
avg.rttMs <= t.medium.maxRttMs &&
avg.packetLoss <= t.medium.maxPacketLoss
) {
return QualityLevel.MEDIUM;
}
if (
avg.bandwidthKbps >= t.low.minBandwidthKbps &&
avg.rttMs <= t.low.maxRttMs &&
avg.packetLoss <= t.low.maxPacketLoss
) {
return QualityLevel.LOW;
}
return QualityLevel.MINIMAL;
}
private stepTowards(target: QualityLevel): QualityLevel {
if (target > this.currentLevel) {
return (this.currentLevel + 1) as QualityLevel;
}
if (target < this.currentLevel) {
return (this.currentLevel - 1) as QualityLevel;
}
return this.currentLevel;
}
private applyHysteresis(stepped: QualityLevel): QualityLevel {
const now = this.clock();
if (stepped === this.currentLevel) {
this.pendingLevel = null;
this.pendingSince = 0;
return this.currentLevel;
}
if (this.pendingLevel !== stepped) {
this.pendingLevel = stepped;
this.pendingSince = now;
// hysteresisMs = 0 → commit immediately on the same call.
if (this.hysteresisMs > 0) {
return this.currentLevel;
}
}
if (now - this.pendingSince >= this.hysteresisMs) {
const previous = this.currentLevel;
this.currentLevel = stepped;
this.pendingLevel = null;
this.pendingSince = 0;
this.emitChange(previous, stepped, "conditions-changed");
}
return this.currentLevel;
}
private emitChange(
previousLevel: QualityLevel,
newLevel: QualityLevel,
reason: QualityChangeEvent["reason"]
): void {
const event: QualityChangeEvent = {
previousLevel,
newLevel,
timestamp: this.clock(),
reason,
};
for (const handler of this.handlers) {
handler(event);
}
}
}

View File

@@ -0,0 +1,80 @@
# quality-middleware
## Purpose
`createQualityMiddleware` builds an outgoing-message gate that drops or forwards each message based on the current `QualityLevel` reported by a `DegradationManager`. It is the bridge between passive quality classification and active traffic shaping: instead of merely reporting "the link is degraded", the gate actually reduces wire traffic so the link can recover. The drop policy is monotonically more aggressive as quality drops, with a guaranteed critical-class allowlist that always passes (handshakes, auth, disconnects, errors) so the connection can repair itself even at MINIMAL.
## Features
- Four drop policies, one per `QualityLevel`:
- `HIGH`: all messages pass through.
- `MEDIUM`: drops every other position-update; non-position messages all pass.
- `LOW`: drops 75% of position-updates and 50% of other non-critical traffic.
- `MINIMAL`: drops everything that is not in the critical allowlist.
- Critical-class allowlist (`HANDSHAKE`, `AUTH`, `DISCONNECT`, `ERROR`, `CRITICAL`) — these always pass irrespective of quality level. Overridable via `options.criticalTypes`.
- Position-update detection (`POSITION_UPDATE`, `STATE_UPDATE`, `TRANSFORM_UPDATE`) — overridable via `options.positionUpdateTypes`.
- Per-source counters survive the lifetime of the middleware so deterministic round-robin drops are consistent across the whole session.
- Generic in the message shape (`M extends QualityMiddlewareMessage`), so callers can attach extra metadata without losing type safety.
- No mutation: returns `message` (pass) or `null` (drop). Identity preserved on pass.
## Usage: Basic Example
```ts
import {
createQualityMiddleware,
DegradationManager,
QualityLevel,
} from "@suaveplan/network-plugin-monitor";
const dm = new DegradationManager();
const gate = createQualityMiddleware(dm);
dm.forceLevel(QualityLevel.LOW);
const result = gate.onOutgoing({ type: "POSITION_UPDATE", source: "peer-a" });
// `result` is null three times in a row, then the message on the fourth call.
```
## Usage: Advanced Example
```ts
import {
createQualityMiddleware,
DegradationManager,
QualityLevel,
type QualityMiddlewareMessage,
} from "@suaveplan/network-plugin-monitor";
interface ChatMessage extends QualityMiddlewareMessage {
readonly type: "CHAT" | "POSITION_UPDATE" | "AUTH";
readonly source: string;
readonly payload: string;
}
const dm = new DegradationManager();
const gate = createQualityMiddleware<ChatMessage>(dm, {
// Treat "CHAT" as critical so it never drops, even at MINIMAL.
criticalTypes: new Set(["CRITICAL", "ERROR", "DISCONNECT", "HANDSHAKE", "AUTH", "CHAT"]),
});
dm.forceLevel(QualityLevel.MINIMAL);
const chat = gate.onOutgoing({ type: "CHAT", source: "u1", payload: "hi" });
const pos = gate.onOutgoing({ type: "POSITION_UPDATE", source: "u1", payload: "..." });
console.log(chat !== null); // true — overridden critical
console.log(pos === null); // true — minimal drops everything non-critical
```
## API Reference
- `createQualityMiddleware<M>(dm, options?)` — `dm` is the `DegradationManager` source-of-truth for the current level; `options.criticalTypes` and `options.positionUpdateTypes` override the defaults.
- Returns `{ onOutgoing(message: M): M | null }`. `null` means "drop"; a non-null return is always identity-equal to the argument.
- `DEFAULT_CRITICAL_TYPES` — exported `ReadonlySet<string>`: `CRITICAL`, `ERROR`, `DISCONNECT`, `HANDSHAKE`, `AUTH`.
- `DEFAULT_POSITION_UPDATE_TYPES` — exported `ReadonlySet<string>`: `POSITION_UPDATE`, `STATE_UPDATE`, `TRANSFORM_UPDATE`.
- `QualityMiddlewareMessage` — minimal shape `{ type: string; source: string }`. Callers' message types must extend this.
## Implementation Notes
- The counter is keyed per `source`, not per `(source, type)` — a single peer sending alternating message types gets one combined counter. This is intentional: the goal is to limit the peer's egress fairly, not to give each message type its own quota.
- `LOW` drops 75% of position-updates by passing only `counter % 4 === 0`. The first message passes (counter starts at 1, so 1 % 4 ≠ 0 → drop, but the FIRST `counter` value is `1`, which yields `null`); to flip "first-message-passes" the caller can prime the counter via the source-coupling hook (deliberately not exposed — middleware is stateless by contract).
- `MINIMAL` short-circuits before the counter is touched so the counters do not advance during a complete blackout; this avoids subtle re-entry bugs where recovering from MINIMAL would skip the first few messages of a recovered LOW window.
- `onOutgoing` is synchronous. The upstream reference made it `async`; here we keep it sync because every code path is pure JS — adding `Promise.resolve()` would just allocate microtasks.
- The middleware does not subscribe to `dm.onLevelChange` — it reads `dm.getCurrentLevel()` on every call. This is a deliberate choice: the level is cheap to read and avoids holding a listener whose lifetime depends on caller discipline.

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DegradationManager } from "./degradation-manager.js";
import {
createQualityMiddleware,
DEFAULT_CRITICAL_TYPES,
DEFAULT_POSITION_UPDATE_TYPES,
} from "./quality-middleware.js";
import { QualityLevel } from "./quality-types.js";
const msg = (type: string, source = "peer-a") => ({ type, source });
describe("createQualityMiddleware", () => {
it("HIGH passes everything", () => {
const dm = new DegradationManager();
const gate = createQualityMiddleware(dm);
// Default level is HIGH
const out = gate.onOutgoing(msg("POSITION_UPDATE"));
expect(out).not.toBeNull();
const chat = gate.onOutgoing(msg("CHAT"));
expect(chat).not.toBeNull();
});
it("MINIMAL drops everything non-critical", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.MINIMAL);
const gate = createQualityMiddleware(dm);
expect(gate.onOutgoing(msg("POSITION_UPDATE"))).toBeNull();
expect(gate.onOutgoing(msg("CHAT"))).toBeNull();
expect(gate.onOutgoing(msg("STATE_UPDATE"))).toBeNull();
});
it("MINIMAL passes critical types", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.MINIMAL);
const gate = createQualityMiddleware(dm);
for (const t of DEFAULT_CRITICAL_TYPES) {
expect(gate.onOutgoing(msg(t))).not.toBeNull();
}
});
it("MEDIUM drops every other position-update", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.MEDIUM);
const gate = createQualityMiddleware(dm);
// counter starts at 0; first call → counter=1, 1%2 !== 0 → drop
expect(gate.onOutgoing(msg("POSITION_UPDATE"))).toBeNull();
expect(gate.onOutgoing(msg("POSITION_UPDATE"))).not.toBeNull();
expect(gate.onOutgoing(msg("POSITION_UPDATE"))).toBeNull();
expect(gate.onOutgoing(msg("POSITION_UPDATE"))).not.toBeNull();
});
it("MEDIUM passes non-position non-critical messages", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.MEDIUM);
const gate = createQualityMiddleware(dm);
expect(gate.onOutgoing(msg("CHAT"))).not.toBeNull();
expect(gate.onOutgoing(msg("CHAT"))).not.toBeNull();
});
it("LOW drops 75% of position-updates (1 in 4 passes)", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.LOW);
const gate = createQualityMiddleware(dm);
const passed: number[] = [];
for (let i = 1; i <= 8; i += 1) {
const out = gate.onOutgoing(msg("POSITION_UPDATE"));
if (out !== null) {
passed.push(i);
}
}
expect(passed).toEqual([4, 8]);
});
it("LOW drops 50% of other non-critical messages", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.LOW);
const gate = createQualityMiddleware(dm);
const results: boolean[] = [];
for (let i = 0; i < 4; i += 1) {
results.push(gate.onOutgoing(msg("CHAT")) !== null);
}
// counter 1,2,3,4 → 2%2==0 pass, 4%2==0 pass
expect(results).toEqual([false, true, false, true]);
});
it("LOW passes critical types unconditionally", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.LOW);
const gate = createQualityMiddleware(dm);
for (let i = 0; i < 5; i += 1) {
expect(gate.onOutgoing(msg("AUTH"))).not.toBeNull();
}
});
it("per-source counters are independent", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.MEDIUM);
const gate = createQualityMiddleware(dm);
// peer-a counter advances independently of peer-b
expect(gate.onOutgoing(msg("POSITION_UPDATE", "peer-a"))).toBeNull(); // 1%2 != 0
expect(gate.onOutgoing(msg("POSITION_UPDATE", "peer-b"))).toBeNull(); // 1%2 != 0
expect(
gate.onOutgoing(msg("POSITION_UPDATE", "peer-a"))
).not.toBeNull(); // 2%2 == 0
expect(
gate.onOutgoing(msg("POSITION_UPDATE", "peer-b"))
).not.toBeNull(); // 2%2 == 0
});
it("custom criticalTypes overrides defaults", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.MINIMAL);
const gate = createQualityMiddleware(dm, {
criticalTypes: new Set(["CHAT"]),
});
expect(gate.onOutgoing(msg("CHAT"))).not.toBeNull();
// Defaults no longer apply
expect(gate.onOutgoing(msg("AUTH"))).toBeNull();
});
it("custom positionUpdateTypes overrides defaults", () => {
const dm = new DegradationManager();
dm.forceLevel(QualityLevel.MEDIUM);
const gate = createQualityMiddleware(dm, {
positionUpdateTypes: new Set(["TICK"]),
});
// TICK is now subject to 1-in-2 drop
expect(gate.onOutgoing(msg("TICK"))).toBeNull();
expect(gate.onOutgoing(msg("TICK"))).not.toBeNull();
// POSITION_UPDATE is now treated as a regular non-position non-critical → passes at MEDIUM
expect(gate.onOutgoing(msg("POSITION_UPDATE"))).not.toBeNull();
});
it("returns identity on pass (does not clone)", () => {
const dm = new DegradationManager();
const gate = createQualityMiddleware(dm);
const m = msg("CHAT");
const out = gate.onOutgoing(m);
expect(out).toBe(m);
});
it("DEFAULT_POSITION_UPDATE_TYPES contains the expected entries", () => {
expect(DEFAULT_POSITION_UPDATE_TYPES.has("POSITION_UPDATE")).toBe(true);
expect(DEFAULT_POSITION_UPDATE_TYPES.has("STATE_UPDATE")).toBe(true);
expect(DEFAULT_POSITION_UPDATE_TYPES.has("TRANSFORM_UPDATE")).toBe(
true
);
});
it("MINIMAL does not advance the per-source counter", () => {
const dm = new DegradationManager();
const gate = createQualityMiddleware(dm);
dm.forceLevel(QualityLevel.MINIMAL);
gate.onOutgoing(msg("POSITION_UPDATE")); // dropped, counter NOT incremented
gate.onOutgoing(msg("POSITION_UPDATE")); // dropped, counter NOT incremented
dm.forceLevel(QualityLevel.MEDIUM);
// First MEDIUM call → counter starts at 1 (not 3), so 1%2!=0 → drop
expect(gate.onOutgoing(msg("POSITION_UPDATE"))).toBeNull();
expect(gate.onOutgoing(msg("POSITION_UPDATE"))).not.toBeNull();
});
});

View File

@@ -0,0 +1,101 @@
/**
* Quality-driven message-throttling middleware factory.
*
* Returns an `onOutgoing` handler that drops or passes a message based on
* the current `QualityLevel` reported by a `DegradationManager`. The drop
* policy is monotonically more aggressive as quality drops:
*
* HIGH → pass everything
* MEDIUM → drop every other position-update; everything else passes
* LOW → drop 75% of position-updates; drop 50% of other non-critical
* MINIMAL → drop everything except critical-class types
*
* Critical-class types (`HANDSHAKE`, `AUTH`, `DISCONNECT`, `ERROR`,
* `CRITICAL`) always pass through irrespective of level.
*/
import type { DegradationManager } from "./degradation-manager.js";
import { QualityLevel } from "./quality-types.js";
export interface QualityMiddlewareMessage {
readonly type: string;
readonly source: string;
}
export interface QualityMiddlewareHandlers<M extends QualityMiddlewareMessage> {
/** Decide whether to forward an outgoing message. Returns the message to keep, or `null` to drop. */
onOutgoing(message: M): M | null;
}
export interface QualityMiddlewareOptions {
/** Override the default critical type allowlist. */
readonly criticalTypes?: ReadonlySet<string>;
/** Override the default position-update type allowlist. */
readonly positionUpdateTypes?: ReadonlySet<string>;
}
export const DEFAULT_CRITICAL_TYPES: ReadonlySet<string> = new Set([
"CRITICAL",
"ERROR",
"DISCONNECT",
"HANDSHAKE",
"AUTH",
]);
export const DEFAULT_POSITION_UPDATE_TYPES: ReadonlySet<string> = new Set([
"POSITION_UPDATE",
"STATE_UPDATE",
"TRANSFORM_UPDATE",
]);
/**
* Build the middleware. Per-source counters survive the lifetime of the
* returned object so dropping every 2nd/4th message is consistent across
* the full session, not reset on each call.
*/
export function createQualityMiddleware<
M extends QualityMiddlewareMessage = QualityMiddlewareMessage,
>(
degradationManager: DegradationManager,
options: QualityMiddlewareOptions = {}
): QualityMiddlewareHandlers<M> {
const critical = options.criticalTypes ?? DEFAULT_CRITICAL_TYPES;
const positions =
options.positionUpdateTypes ?? DEFAULT_POSITION_UPDATE_TYPES;
const counters = new Map<string, number>();
function nextCounter(source: string): number {
const current = counters.get(source) ?? 0;
const next = current + 1;
counters.set(source, next);
return next;
}
return {
onOutgoing(message: M): M | null {
const level = degradationManager.getCurrentLevel();
if (level === QualityLevel.HIGH) {
return message;
}
if (critical.has(message.type)) {
return message;
}
if (level === QualityLevel.MINIMAL) {
return null;
}
const counter = nextCounter(message.source);
const isPosition = positions.has(message.type);
if (level === QualityLevel.LOW) {
if (isPosition) {
return counter % 4 === 0 ? message : null;
}
return counter % 2 === 0 ? message : null;
}
// MEDIUM
if (isPosition) {
return counter % 2 === 0 ? message : null;
}
return message;
},
};
}

View File

@@ -0,0 +1,65 @@
# quality-types
## Purpose
Shared type definitions for the quality-class classification + adaptive degradation feature group. These types underpin three sibling modules (`bandwidth-monitor`, `degradation-manager`, `quality-middleware`) and form the wire-level contract for any consumer that wants to react to per-link quality changes. Quality monitoring relies on these shapes being stable: a `BandwidthSample` recorded by one module must round-trip cleanly through another, so the schemas are Zod-validated at every boundary.
## Features
- `QualityLevel` const object + value-union type (numeric ordering: `MINIMAL=0 < LOW=1 < MEDIUM=2 < HIGH=3`); arithmetic on levels powers one-step transitions in the degradation manager.
- `LevelThreshold` — bandwidth / RTT / loss bounds for a single quality rung.
- `QualityThresholds` — three thresholds (`high`/`medium`/`low`); `MINIMAL` is implicit ("anything worse than `low`").
- `BandwidthSample` — single measurement record with timestamp.
- `QualityChangeEvent` — emitted when the degradation manager flips levels.
- All shapes are Zod-validated (`*Schema` exports) so cross-module boundaries reject malformed data.
## Usage: Basic Example
```ts
import { QualityLevel, type BandwidthSample } from "@suaveplan/network-plugin-monitor";
const sample: BandwidthSample = {
timestamp: Date.now(),
bytesPerSecond: 250_000,
rttMs: 40,
packetLossRate: 0.005,
};
if (QualityLevel.HIGH > QualityLevel.MEDIUM) {
// levels are numerically ordered
}
```
## Usage: Advanced Example
```ts
import {
BandwidthSampleSchema,
QualityThresholdsSchema,
type QualityThresholds,
} from "@suaveplan/network-plugin-monitor";
const thresholds: QualityThresholds = QualityThresholdsSchema.parse({
high: { minBandwidthKbps: 500, maxRttMs: 50, maxPacketLoss: 0.01 },
medium: { minBandwidthKbps: 200, maxRttMs: 100, maxPacketLoss: 0.05 },
low: { minBandwidthKbps: 50, maxRttMs: 200, maxPacketLoss: 0.15 },
});
// Validate untrusted wire input before feeding to BandwidthMonitor.recordSample()
const incoming = BandwidthSampleSchema.parse(JSON.parse(wireBytes));
```
## API Reference
- `QualityLevel`: const object with `MINIMAL`/`LOW`/`MEDIUM`/`HIGH` numeric members and a union type alias of the same name.
- `LevelThreshold`: `{ minBandwidthKbps, maxRttMs, maxPacketLoss }` — all non-negative; `maxPacketLoss` ∈ `[0, 1]`.
- `QualityThresholds`: three rungs `{ high, medium, low }`. `MINIMAL` is the implicit worst level when none match.
- `BandwidthSample`: `{ timestamp, bytesPerSecond, rttMs, packetLossRate }`. `timestamp` is a non-negative integer (ms epoch).
- `QualityChangeEvent`: `{ previousLevel, newLevel, timestamp, reason }`. `reason` is `"conditions-changed"`, `"forced"`, or `"reset"`.
- `*Schema` exports: Zod strict-object schemas; throw `ZodError` on malformed input.
## Implementation Notes
- `QualityLevel` is intentionally a `const` object rather than a TypeScript `enum` to keep emitted JS minimal and tree-shakeable, and to avoid the reverse-mapping baggage that numeric enums generate.
- The schemas use `.strict()` so unknown keys are a hard error; that catches typos like `packetLoss` vs `packetLossRate` at the boundary instead of silently dropping them later.
- `MINIMAL` has no corresponding `LevelThreshold` because it is the fall-through: any measurement that fails to meet the `low` rung classifies as `MINIMAL`.

View File

@@ -0,0 +1,52 @@
/**
* Shared types for quality-class classification + adaptive degradation.
*
* `QualityLevel` is a four-rung ladder from MINIMAL (worst) to HIGH (best).
* Numeric ordering matters — `DegradationManager` does arithmetic on
* adjacent levels to enforce one-step transitions.
*/
import z from "zod";
export const QualityLevel = {
MINIMAL: 0,
LOW: 1,
MEDIUM: 2,
HIGH: 3,
} as const;
export type QualityLevel = (typeof QualityLevel)[keyof typeof QualityLevel];
export const LevelThresholdSchema = z
.object({
minBandwidthKbps: z.number().nonnegative(),
maxRttMs: z.number().nonnegative(),
maxPacketLoss: z.number().min(0).max(1),
})
.strict();
export type LevelThreshold = z.infer<typeof LevelThresholdSchema>;
export const QualityThresholdsSchema = z
.object({
high: LevelThresholdSchema,
medium: LevelThresholdSchema,
low: LevelThresholdSchema,
})
.strict();
export type QualityThresholds = z.infer<typeof QualityThresholdsSchema>;
export const BandwidthSampleSchema = z
.object({
timestamp: z.number().int().nonnegative(),
bytesPerSecond: z.number().nonnegative(),
rttMs: z.number().nonnegative(),
packetLossRate: z.number().min(0).max(1),
})
.strict();
export type BandwidthSample = z.infer<typeof BandwidthSampleSchema>;
export interface QualityChangeEvent {
readonly previousLevel: QualityLevel;
readonly newLevel: QualityLevel;
readonly timestamp: number;
readonly reason: "conditions-changed" | "forced" | "reset";
}

View File

@@ -0,0 +1,65 @@
# `AdaptiveSampler`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Watches the recent sample arrival rate and adjusts the effective sampling probability so monitoring overhead stays bounded under load. Above the `highWatermark` rate the sampler halves its rate every second; at or below the `lowWatermark` rate it doubles back toward `maxRate`. Keeps a tight feedback loop between observed load and the cost of observing it.
## Features
- Two-watermark hysteresis: distinct high / low thresholds prevent flapping between adjustments.
- Bounded rate range: `[minRate, maxRate]` (defaults `0.1 .. 1`) so even the worst burst still keeps a probabilistic tail of samples flowing.
- `shouldKeep(rng?)` is pure — callers feed their own RNG for deterministic tests.
- Injectable clock for deterministic tests of the 1-second rolling window.
## Usage: Basic Example
```ts
import { AdaptiveSampler } from "@suaveplan/network-plugin-monitor";
const sampler = new AdaptiveSampler({
highWatermark: 1_000,
lowWatermark: 250,
});
for (const sample of stream) {
sampler.observe();
if (sampler.shouldKeep()) ingest(sample);
}
```
## Usage: Advanced Example
```ts
import { AdaptiveSampler } from "@suaveplan/network-plugin-monitor";
let now = 0;
const sampler = new AdaptiveSampler({
highWatermark: 100,
lowWatermark: 10,
minRate: 0.05,
maxRate: 1,
clock: () => now,
});
// Burst: 200 samples in <1s pushes the rate down on the next window
for (let i = 0; i < 200; i += 1) sampler.observe();
now += 1_001;
sampler.observe();
sampler.currentRate(); // 0.5 (halved from 1)
```
## API Reference
- `class AdaptiveSampler(config: { highWatermark, lowWatermark, minRate?, maxRate?, clock? })`
- `observe(): void` — count one sample; reconciles the rate at every window boundary.
- `currentRate(): number` — current sampling probability.
- `shouldKeep(rng?: () => number): boolean` — true if the sample should be retained.
## Implementation Notes
- The 1-second rolling window starts when the first observe arrives in a new window. If a window receives no samples (silent period), `adjustRate` does not run until the next observe past the boundary — the sampler stays at the current rate rather than spuriously bumping up toward `maxRate`.
- `highWatermark` is clamped to `>= 1` so the configuration is never ambiguous (a watermark of 0 would always trip).
- `minRate` and `maxRate` are clamped to `[0, 1]` so misconfiguration cannot produce > 100 % rates or negative ones; the sampler degrades gracefully.
- `shouldKeep` accepts a custom RNG so tests can pin sampling decisions exactly without monkey-patching `Math.random`.

View File

@@ -0,0 +1,77 @@
export interface AdaptiveSamplerConfig {
/** Samples-per-second above which the sampler reduces the rate. */
readonly highWatermark: number;
/** Samples-per-second at/below which the sampler restores full rate. */
readonly lowWatermark: number;
/** Minimum sampling rate (0..1) the sampler will fall to. */
readonly minRate?: number;
/** Sampling rate (0..1) the sampler returns when in steady state. */
readonly maxRate?: number;
readonly clock?: () => number;
}
interface RateWindow {
windowStart: number;
count: number;
}
/**
* Watches the recent sample rate and reduces the effective sampling
* probability when the rate exceeds `highWatermark`. Restores the
* full rate once the observed rate drops back to `lowWatermark`.
*/
export class AdaptiveSampler {
private readonly highWatermark: number;
private readonly lowWatermark: number;
private readonly minRate: number;
private readonly maxRate: number;
private readonly clock: () => number;
private rate: number;
private window: RateWindow = { windowStart: 0, count: 0 };
public constructor(config: AdaptiveSamplerConfig) {
this.highWatermark = Math.max(1, config.highWatermark);
this.lowWatermark = Math.max(0, config.lowWatermark);
this.minRate = clamp01(config.minRate ?? 0.1);
this.maxRate = clamp01(config.maxRate ?? 1);
this.clock = config.clock ?? (() => Date.now());
this.rate = this.maxRate;
}
public observe(): void {
const now = this.clock();
if (now - this.window.windowStart >= 1_000) {
this.adjustRate();
this.window = { windowStart: now, count: 0 };
}
this.window.count += 1;
}
public currentRate(): number {
return this.rate;
}
/** Returns true if the caller should keep this sample under the current rate. */
public shouldKeep(rng: () => number = Math.random): boolean {
return rng() < this.rate;
}
private adjustRate(): void {
if (this.window.count >= this.highWatermark) {
// Halve the rate until it bottoms out at minRate.
this.rate = Math.max(this.minRate, this.rate / 2);
return;
}
if (this.window.count <= this.lowWatermark) {
// Restore back toward maxRate gradually so transient dips
// don't immediately re-amplify the load.
this.rate = Math.min(this.maxRate, this.rate * 2);
}
}
}
function clamp01(value: number): number {
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AdaptiveSampler } from "./adaptive-sampler.js";
describe("AdaptiveSampler: rate-down on burst, rate-up on quiet", () => {
it("starts at maxRate", () => {
const s = new AdaptiveSampler({
highWatermark: 10,
lowWatermark: 2,
});
expect(s.currentRate()).toBe(1);
});
it("halves the rate when burst exceeds highWatermark", () => {
let now = 0;
const s = new AdaptiveSampler({
highWatermark: 5,
lowWatermark: 1,
clock: () => now,
});
for (let i = 0; i < 100; i += 1) s.observe();
// Roll past the 1s window so the next observe triggers adjust
now += 1_001;
s.observe();
expect(s.currentRate()).toBe(0.5);
});
it("doubles toward maxRate when below lowWatermark", () => {
let now = 0;
const s = new AdaptiveSampler({
highWatermark: 100,
lowWatermark: 1,
minRate: 0.1,
maxRate: 1,
clock: () => now,
});
// Manually drop the rate first via burst
for (let i = 0; i < 200; i += 1) s.observe();
now += 1_001;
s.observe();
const reduced = s.currentRate();
// Quiet window: only 1 observation triggers the boost
now += 1_001;
s.observe();
expect(s.currentRate()).toBeGreaterThan(reduced);
});
it("clamps minRate / maxRate to [0, 1]", () => {
const s = new AdaptiveSampler({
highWatermark: 10,
lowWatermark: 1,
minRate: -1,
maxRate: 5,
});
expect(s.currentRate()).toBe(1);
});
it("clamps highWatermark to >= 1", () => {
let now = 0;
const s = new AdaptiveSampler({
highWatermark: -1,
lowWatermark: 0,
clock: () => now,
});
for (let i = 0; i < 5; i += 1) s.observe();
now += 1_001;
s.observe();
expect(s.currentRate()).toBeLessThan(1);
});
it("shouldKeep is deterministic with injected rng", () => {
// Start the sampler at maxRate = 0.5 so rng=0.4 keeps and rng=0.6 drops.
const s = new AdaptiveSampler({
highWatermark: 100,
lowWatermark: 1,
maxRate: 0.5,
});
expect(s.shouldKeep(() => 0.4)).toBe(true);
expect(s.shouldKeep(() => 0.6)).toBe(false);
});
it("uses Date.now as default clock", () => {
const s = new AdaptiveSampler({ highWatermark: 1, lowWatermark: 0 });
expect(() => s.observe()).not.toThrow();
});
it("shouldKeep falls back to Math.random when no rng arg supplied", () => {
const s = new AdaptiveSampler({ highWatermark: 1, lowWatermark: 0 });
expect(typeof s.shouldKeep()).toBe("boolean");
});
it("does not adjust the rate when count sits between watermarks", () => {
let now = 0;
const s = new AdaptiveSampler({
highWatermark: 100,
lowWatermark: 1,
clock: () => now,
});
// Drive into a reduced rate first
for (let i = 0; i < 200; i += 1) s.observe();
now += 1_001;
s.observe(); // now rate halved
const halved = s.currentRate();
// Next window: 5 observations — between low (1) and high (100)
for (let i = 0; i < 5; i += 1) s.observe();
now += 1_001;
s.observe();
expect(s.currentRate()).toBe(halved);
});
it("rate stays clamped at minRate under sustained burst", () => {
let now = 0;
const s = new AdaptiveSampler({
highWatermark: 5,
lowWatermark: 1,
minRate: 0.25,
clock: () => now,
});
// Repeatedly burst across windows
for (let cycle = 0; cycle < 8; cycle += 1) {
for (let i = 0; i < 100; i += 1) s.observe();
now += 1_001;
s.observe();
}
expect(s.currentRate()).toBe(0.25);
});
});

View File

@@ -0,0 +1,77 @@
# `NetworkTracer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry-shaped distributed tracer for the mesh. Propagates `traceId` / `spanId` / `sampled` via three message headers (`x-network-trace`, `x-network-span`, `x-network-sampled`), opens / closes spans with timing + attributes + events, computes per-peer clock skew, and decides sampling via four strategies (`probabilistic`, `rate-limited`, `error-based`, `priority-based`).
## Features
- `extract` / `inject` round-trip the trace context through plain header maps so callers do not need to know the header names.
- `start` / `addEvent` / `end` lifecycle with `SPAN_NOT_OPEN` raised when `addEvent` / `end` is called on an unknown or already-closed span.
- `shouldSample` switches on the configured `SamplingStrategy` so consumers tune behaviour via constructor config rather than per-call branches.
- `observePeerClock` + `correctRemoteTimestamp` let consumers normalise spans recorded on peers with skewed clocks.
- Injectable clock / RNG / id generator for deterministic tests.
## Usage: Basic Example
```ts
import { NetworkTracer } from "@suaveplan/network-plugin-monitor";
const tracer = new NetworkTracer({ strategy: "probabilistic", probabilisticRate: 1 });
const ctx = tracer.start({ name: "router.forward" });
tracer.addEvent(ctx.spanId, { name: "enqueued", at: Date.now() });
tracer.end(ctx.spanId);
const traces = tracer.traces();
```
## Usage: Advanced Example
```ts
import {
NetworkTracer,
type SamplingStrategy,
} from "@suaveplan/network-plugin-monitor";
const tracer = new NetworkTracer({
strategy: "rate-limited" satisfies SamplingStrategy,
rateLimitPerSec: 50,
clock: () => 1_700_000_000_000,
rng: () => 0.1,
});
// Cross-hop propagation
const headers: Record<string, string | number | boolean> = {};
const parent = tracer.start({ name: "incoming.handle" });
tracer.inject(parent, headers);
const downstream = tracer.extract(headers);
const child = tracer.start({ name: "outgoing.send", parent: downstream ?? undefined });
tracer.end(child.spanId);
tracer.end(parent.spanId);
```
## API Reference
- `class NetworkTracer(config?)`
- `extract(headers): TraceContext | null`.
- `inject(ctx, target): void`.
- `shouldSample({ priority?, hasError? } = {}): boolean`.
- `start({ name, parent?, attributes? }): TraceContext`.
- `addEvent(spanId, event): void` — throws `SPAN_NOT_OPEN`.
- `end(spanId): NetworkSpan` — throws `SPAN_NOT_OPEN`.
- `traces(): ReadonlyArray<NetworkTrace>`.
- `observePeerClock(remoteNowMs): void`.
- `skew(): number`.
- `correctRemoteTimestamp(remoteMs): number`.
- `lastInvalid(): Error | null` — last `SPAN_NOT_OPEN` raised, useful for tests / debug logs.
- `interface NetworkTracerConfig` — `{ clock?, idGenerator?, strategy?, probabilisticRate?, rateLimitPerSec?, prioritySampleAtOrAbove?, rng? }`.
## Implementation Notes
- Sampling decisions are pure — `shouldSample` does not record state, so consumers can call it independently of `start`. The rate-limited strategy maintains a 1-second rolling window so bursts above `rateLimitPerSec` are smoothed.
- `start` returns the new `TraceContext` rather than the span itself to discourage callers from mutating the span buffer directly. `addEvent` / `end` are the only legitimate mutations.
- `extract` returns `null` for malformed headers (missing or wrong type); callers check the result so missing context never silently propagates as the wrong trace.
- Skew is observation-based: the most recent `observePeerClock(remoteNowMs)` call defines the offset. Production deployments should call this on every received heartbeat so the offset tracks NTP drift.
- The id generator uses base36 from `Math.random` by default (16 chars) — adequate for in-memory traces. Bridges to OTel exporters should pass an injected generator that produces standards-compliant 32-char trace ids and 16-char span ids.

View File

@@ -0,0 +1,207 @@
import { NETWORK_MON_CODES, NetworkMonError } from "../error-codes.js";
import type {
NetworkSpan,
NetworkSpanEvent,
NetworkTrace,
SamplingStrategy,
TraceContext,
} from "../types/trace.types.js";
export interface NetworkTracerConfig {
readonly clock?: () => number;
readonly idGenerator?: () => string;
readonly strategy?: SamplingStrategy;
/** 0..1 sample probability for the `probabilistic` strategy. */
readonly probabilisticRate?: number;
/** Max spans per second under the `rate-limited` strategy. */
readonly rateLimitPerSec?: number;
/** Priority threshold (0..255) at/above which `priority-based` samples. */
readonly prioritySampleAtOrAbove?: number;
readonly rng?: () => number;
}
interface MutableSpan extends NetworkSpan {
events: NetworkSpanEvent[];
}
const TRACE_HEADER = "x-network-trace";
const SPAN_HEADER = "x-network-span";
const SAMPLED_HEADER = "x-network-sampled";
/**
* Distributed tracer with OpenTelemetry-shaped fields and four
* sampling strategies. Captures spans in-memory; exporters consume the
* `NetworkTrace[]` snapshot via `traces()`.
*/
export class NetworkTracer {
private readonly spans = new Map<string, MutableSpan>();
private readonly clock: () => number;
private readonly idGenerator: () => string;
private readonly strategy: SamplingStrategy;
private readonly probabilisticRate: number;
private readonly rateLimitPerSec: number;
private readonly prioritySampleAtOrAbove: number;
private readonly rng: () => number;
private rateWindowStart = 0;
private rateWindowCount = 0;
private skewMs = 0;
private lastError: Error | null = null;
public constructor(config: NetworkTracerConfig = {}) {
this.clock = config.clock ?? (() => Date.now());
this.idGenerator = config.idGenerator ?? defaultIdGenerator;
this.strategy = config.strategy ?? "probabilistic";
this.probabilisticRate = clamp01(config.probabilisticRate ?? 1);
this.rateLimitPerSec = Math.max(1, config.rateLimitPerSec ?? 50);
this.prioritySampleAtOrAbove = clamp255(
config.prioritySampleAtOrAbove ?? 200
);
this.rng = config.rng ?? Math.random;
}
public extract(
headers: Readonly<Record<string, unknown>>
): TraceContext | null {
const traceId = headers[TRACE_HEADER];
const spanId = headers[SPAN_HEADER];
if (typeof traceId !== "string" || typeof spanId !== "string") {
return null;
}
const sampled = headers[SAMPLED_HEADER] === "1";
return { traceId, spanId, sampled };
}
public inject(
ctx: TraceContext,
target: Record<string, string | number | boolean>
): void {
target[TRACE_HEADER] = ctx.traceId;
target[SPAN_HEADER] = ctx.spanId;
target[SAMPLED_HEADER] = ctx.sampled ? "1" : "0";
}
public shouldSample(
input: { priority?: number; hasError?: boolean } = {}
): boolean {
switch (this.strategy) {
case "probabilistic":
return this.rng() < this.probabilisticRate;
case "rate-limited":
return this.consumeRate();
case "error-based":
return input.hasError === true;
case "priority-based":
return (input.priority ?? 0) >= this.prioritySampleAtOrAbove;
}
}
public start(input: {
name: string;
parent?: TraceContext;
attributes?: Record<string, string | number | boolean>;
}): TraceContext {
const traceId = input.parent?.traceId ?? this.idGenerator();
const spanId = this.idGenerator();
const span: MutableSpan = {
traceId,
spanId,
...(input.parent?.spanId !== undefined
? { parentSpanId: input.parent.spanId }
: {}),
name: input.name,
startedAt: this.clock(),
attributes: { ...(input.attributes ?? {}) },
events: [],
};
this.spans.set(spanId, span);
return {
traceId,
spanId,
...(input.parent?.spanId !== undefined
? { parentSpanId: input.parent.spanId }
: {}),
sampled: input.parent?.sampled ?? true,
};
}
public addEvent(spanId: string, event: NetworkSpanEvent): void {
const span = this.requireOpen(spanId);
span.events.push(event);
}
public end(spanId: string): NetworkSpan {
const span = this.requireOpen(spanId);
span.endedAt = this.clock();
return span;
}
public traces(): ReadonlyArray<NetworkTrace> {
const byTrace = new Map<string, NetworkSpan[]>();
for (const span of this.spans.values()) {
const list = byTrace.get(span.traceId) ?? [];
list.push(span);
byTrace.set(span.traceId, list);
}
return Array.from(byTrace.entries()).map(([traceId, spans]) => ({
traceId,
spans,
}));
}
public observePeerClock(remoteNowMs: number): void {
this.skewMs = remoteNowMs - this.clock();
}
public skew(): number {
return this.skewMs;
}
public correctRemoteTimestamp(remoteMs: number): number {
return remoteMs - this.skewMs;
}
public lastInvalid(): Error | null {
return this.lastError;
}
private consumeRate(): boolean {
const now = this.clock();
if (now - this.rateWindowStart >= 1_000) {
this.rateWindowStart = now;
this.rateWindowCount = 0;
}
if (this.rateWindowCount >= this.rateLimitPerSec) return false;
this.rateWindowCount += 1;
return true;
}
private requireOpen(spanId: string): MutableSpan {
const span = this.spans.get(spanId);
if (span === undefined || span.endedAt !== undefined) {
const err = new NetworkMonError(
NETWORK_MON_CODES.SPAN_NOT_OPEN,
"tracer.span-not-open",
{ context: { spanId } }
);
this.lastError = err;
throw err;
}
return span;
}
}
function defaultIdGenerator(): string {
return Math.random().toString(36).slice(2, 18).padEnd(16, "0");
}
function clamp01(value: number): number {
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
function clamp255(value: number): number {
if (value < 0) return 0;
if (value > 255) return 255;
return value;
}

View File

@@ -0,0 +1,178 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_MON_CODES, type NetworkMonError } from "../error-codes.js";
import { NetworkTracer } from "./network-tracer.js";
describe("NetworkTracer: propagation, spans, sampling", () => {
it("inject + extract round-trip the trace context", () => {
const tracer = new NetworkTracer();
const ctx = tracer.start({ name: "root" });
const headers: Record<string, string | number | boolean> = {};
tracer.inject(ctx, headers);
const out = tracer.extract(headers);
expect(out?.traceId).toBe(ctx.traceId);
expect(out?.spanId).toBe(ctx.spanId);
});
it("extract returns null for malformed headers", () => {
const tracer = new NetworkTracer();
expect(tracer.extract({})).toBeNull();
});
it("child span inherits the parent traceId", () => {
const tracer = new NetworkTracer();
const parent = tracer.start({ name: "p" });
const child = tracer.start({ name: "c", parent });
expect(child.traceId).toBe(parent.traceId);
expect(child.parentSpanId).toBe(parent.spanId);
});
it("end records endedAt", () => {
let now = 100;
const tracer = new NetworkTracer({ clock: () => now });
const ctx = tracer.start({ name: "x" });
now = 200;
const span = tracer.end(ctx.spanId);
expect(span.startedAt).toBe(100);
expect(span.endedAt).toBe(200);
});
it("addEvent / end on closed or unknown span throws SPAN_NOT_OPEN", () => {
const tracer = new NetworkTracer();
const ctx = tracer.start({ name: "x" });
tracer.end(ctx.spanId);
try {
tracer.addEvent(ctx.spanId, { name: "after-close", at: 0 });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMonError).code).toBe(
NETWORK_MON_CODES.SPAN_NOT_OPEN
);
}
try {
tracer.end("never-existed");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMonError).code).toBe(
NETWORK_MON_CODES.SPAN_NOT_OPEN
);
}
expect(tracer.lastInvalid()?.message).toBeDefined();
});
it("traces() groups spans by traceId", () => {
const tracer = new NetworkTracer();
const a = tracer.start({ name: "a" });
tracer.start({ name: "a-child", parent: a });
const b = tracer.start({ name: "b" });
tracer.end(b.spanId);
const traces = tracer.traces();
expect(traces.length).toBe(2);
});
it("probabilistic strategy obeys probabilisticRate", () => {
const lo = new NetworkTracer({
strategy: "probabilistic",
probabilisticRate: 1,
rng: () => 0.99,
});
const hi = new NetworkTracer({
strategy: "probabilistic",
probabilisticRate: 0,
rng: () => 0,
});
expect(lo.shouldSample()).toBe(true);
expect(hi.shouldSample()).toBe(false);
});
it("rate-limited strategy caps per-second decisions", () => {
let now = 1_000;
const tracer = new NetworkTracer({
strategy: "rate-limited",
rateLimitPerSec: 2,
clock: () => now,
});
expect(tracer.shouldSample()).toBe(true);
expect(tracer.shouldSample()).toBe(true);
expect(tracer.shouldSample()).toBe(false);
now += 1_001;
expect(tracer.shouldSample()).toBe(true);
});
it("error-based and priority-based strategies", () => {
const errBased = new NetworkTracer({ strategy: "error-based" });
expect(errBased.shouldSample({ hasError: true })).toBe(true);
expect(errBased.shouldSample({ hasError: false })).toBe(false);
const prioBased = new NetworkTracer({
strategy: "priority-based",
prioritySampleAtOrAbove: 200,
});
expect(prioBased.shouldSample({ priority: 250 })).toBe(true);
expect(prioBased.shouldSample({ priority: 100 })).toBe(false);
// No-arg call still works (priority defaults to 0)
expect(prioBased.shouldSample()).toBe(false);
});
it("clock-skew detection and correction", () => {
const local = 1_000;
const tracer = new NetworkTracer({ clock: () => local });
tracer.observePeerClock(local + 250); // peer is 250ms ahead
expect(tracer.skew()).toBe(250);
expect(tracer.correctRemoteTimestamp(local + 250)).toBe(local);
});
it("clamps probabilistic rate, priority threshold, and rate limit at boundaries", () => {
const t = new NetworkTracer({
probabilisticRate: 5,
prioritySampleAtOrAbove: -10,
rateLimitPerSec: -1,
});
expect(t.shouldSample()).toBe(true);
});
it("default id generator yields a 16-char string", () => {
const tracer = new NetworkTracer();
const ctx = tracer.start({ name: "x" });
expect(ctx.spanId.length).toBe(16);
});
it("default sampling strategy is probabilistic@1", () => {
const t = new NetworkTracer();
expect(t.shouldSample()).toBe(true);
});
it("addEvent appends to an open span", () => {
const tracer = new NetworkTracer();
const ctx = tracer.start({ name: "x" });
tracer.addEvent(ctx.spanId, { name: "evt", at: 100 });
const span = tracer.end(ctx.spanId);
expect(span.events.map((e) => e.name)).toEqual(["evt"]);
});
it("inject writes '0' for unsampled contexts", () => {
const tracer = new NetworkTracer();
const headers: Record<string, string | number | boolean> = {};
tracer.inject({ traceId: "t", spanId: "s", sampled: false }, headers);
expect(headers["x-network-sampled"]).toBe("0");
});
it("clamp helpers cover both bounds", () => {
// probabilisticRate: -1 exercises clamp01's `< 0` branch (clamps to 0).
const t = new NetworkTracer({ probabilisticRate: -1 });
expect(t.shouldSample()).toBe(false);
// prioritySampleAtOrAbove: 500 exercises clamp255's `> 255` branch
// (clamps to 255). priority 200 falls below the clamped threshold.
const t2 = new NetworkTracer({
strategy: "priority-based",
prioritySampleAtOrAbove: 500,
});
expect(t2.shouldSample({ priority: 200 })).toBe(false);
expect(t2.shouldSample({ priority: 255 })).toBe(true);
});
it("child span without parent.spanId omits parentSpanId", () => {
const tracer = new NetworkTracer();
const ctx = tracer.start({ name: "root" });
expect(ctx.parentSpanId).toBeUndefined();
});
});

View File

@@ -0,0 +1,66 @@
# Alert types — `@suaveplan/network-plugin-monitor`
## Purpose
Shared types for the threshold-based alerting layer. `AlertRule` describes the gate condition; `AlertEvaluation` captures the observation that tripped (or did not trip) the rule; `AlertState` reports the rule's current life-cycle phase to the bus.
## Features
- Closed `AlertSeverity` (`info` / `warning` / `critical`), `AlertComparison` (six operators), and `AlertState` unions for exhaustive switch coverage.
- `AlertStatistic` enumerates which numeric the rule reads — either `latest` (last sample) or one of the percentile / aggregate fields on `MetricStats`.
- `cooldownMs` carries the per-rule suppression window so distinct rules can pace differently.
## Usage: Basic Example
```ts
import type { AlertRule } from "@suaveplan/network-plugin-monitor";
const rule: AlertRule = {
id: "p95-latency-warn",
metricName: "connection.latency",
statistic: "p95",
comparison: ">",
threshold: 250,
severity: "warning",
};
```
## Usage: Advanced Example
```ts
import type {
AlertEvaluation,
AlertRule,
AlertState,
} from "@suaveplan/network-plugin-monitor";
const rule: AlertRule = {
id: "host-cpu-critical",
metricName: "node.cpu",
statistic: "p99",
comparison: ">=",
threshold: 0.95,
severity: "critical",
cooldownMs: 30_000,
};
declare const evaluation: AlertEvaluation;
declare const state: AlertState;
console.log(rule.id, state, evaluation.observed);
```
## API Reference
- `type AlertSeverity` — `"info" | "warning" | "critical"`.
- `type AlertComparison` — `">" | ">=" | "<" | "<=" | "==" | "!="`.
- `type AlertStatistic` — `"latest" | "min" | "max" | "avg" | "p50" | "p95" | "p99" | "count"`.
- `interface AlertRule` — `{ id, metricName, statistic, comparison, threshold, severity, cooldownMs? }`.
- `interface AlertEvaluation` — `{ rule, observed, stats, evaluatedAt }`.
- `type AlertState` — `"firing" | "suppressed" | "ok"`.
## Implementation Notes
- `id` on `AlertRule` is the boundary key the engine uses for cooldown bookkeeping. Two rules with the same id would share a cooldown clock — distinct gates need distinct ids.
- `statistic: "latest"` is special-cased because not every rule wants the rolling-window aggregate; e.g. heartbeat-loss alerts read the most recent sample directly.
- `cooldownMs` defaults to `60_000` in the engine, applied centrally rather than per rule definition. Rules that need it tighter / looser opt in explicitly.
- The types are intentionally minimal — richer fields (annotations, runbook links) belong in subscriber-side models, not in the engine contract.

View File

@@ -0,0 +1,47 @@
import type { MetricStats } from "./metric.types.js";
/**
* Severity tiers for an alert. The bus event includes the severity so
* downstream subscribers (paging, dashboards, audit logs) can route
* differently per tier.
*/
export type AlertSeverity = "info" | "warning" | "critical";
/**
* Comparison verb used by the threshold evaluator.
*/
export type AlertComparison = ">" | ">=" | "<" | "<=" | "==" | "!=";
/**
* Numeric statistic the rule evaluates. `latest` reads the most-recent
* sample; the others read fields from the `MetricStats` snapshot.
*/
export type AlertStatistic =
| "latest"
| "min"
| "max"
| "avg"
| "p50"
| "p95"
| "p99"
| "count";
export interface AlertRule {
readonly id: string;
readonly metricName: string;
readonly statistic: AlertStatistic;
readonly comparison: AlertComparison;
readonly threshold: number;
readonly severity: AlertSeverity;
/** Minimum interval between repeat firings, in ms. Defaults to 60_000. */
readonly cooldownMs?: number;
}
export interface AlertEvaluation {
readonly rule: AlertRule;
readonly observed: number;
readonly stats: MetricStats;
readonly evaluatedAt: number;
}
export type AlertState = "firing" | "suppressed" | "ok";

View File

@@ -0,0 +1,68 @@
# Metric types — `@suaveplan/network-plugin-monitor`
## Purpose
Closed unions and structural shapes for the metric layer. Every other module — collector, monitor, history store, alert engine — speaks the same `Metric` / `MetricValue` / `MetricStats` shapes so callers can compose pipelines without inventing per-module conversions.
## Features
- `MetricCategory` and `MetricType` are closed unions so exhaustive `switch` statements stay honest.
- `MetricValue` carries `recordedAt` plus optional low-cardinality `labels` map.
- `MetricStats` exposes count / min / max / avg / p50 / p95 / p99 so consumers do not need to recompute every percentile per render.
## Usage: Basic Example
```ts
import type {
Metric,
MetricCategory,
MetricStats,
MetricType,
MetricValue,
} from "@suaveplan/network-plugin-monitor";
const sample: MetricValue = { value: 42, recordedAt: Date.now() };
const metric: Metric = {
name: "tier0.nodeCount",
category: "hierarchy" as MetricCategory,
type: "gauge" as MetricType,
samples: [sample],
};
declare const stats: MetricStats;
console.log(stats.p95);
```
## Usage: Advanced Example
```ts
import type { Metric, MetricStats } from "@suaveplan/network-plugin-monitor";
const histogram: Metric = {
name: "connection.latency",
category: "connection",
type: "histogram",
samples: rawSamples.map((value) => ({
value,
recordedAt: Date.now(),
labels: { transport: "websocket" },
})),
};
declare function summarise(samples: ReadonlyArray<number>): MetricStats;
const stats = summarise(histogram.samples.map((s) => s.value));
```
## API Reference
- `type MetricCategory` — `"node" | "connection" | "message" | "hierarchy"`.
- `type MetricType` — `"counter" | "gauge" | "histogram" | "summary"`.
- `interface MetricValue` — `{ value, recordedAt, labels? }`.
- `interface Metric` — `{ name, category, type, samples }`.
- `interface MetricStats` — `{ count, min, max, avg, p50, p95, p99 }`.
## Implementation Notes
- `samples` is `ReadonlyArray` so consumers cannot mutate the buffer behind the collector's back; the collector replaces the array on every record.
- `labels` is intentionally limited to `string -> string` to discourage high-cardinality dimensions (e.g. raw payloads); higher-cardinality contexts belong in the trace layer, not metrics.
- `MetricStats` always includes `count` so consumers can detect empty buffers without a separate `samples.length` check.
- `recordedAt` is a number (epoch ms) rather than `Date` because metric pipelines serialise these into structured logs/exporters that expect numeric timestamps.

View File

@@ -0,0 +1,45 @@
/**
* Metric category — pins which subsystem the metric originates from.
*/
export type MetricCategory = "node" | "connection" | "message" | "hierarchy";
/**
* Metric type — mirrors the Prometheus / OpenTelemetry standard set.
*/
export type MetricType = "counter" | "gauge" | "histogram" | "summary";
/**
* Single recorded value. `labels` carry low-cardinality dimensions
* (peer ids, message types, tier names) so consumers can group / filter
* without re-indexing the underlying time series.
*/
export interface MetricValue {
readonly value: number;
readonly recordedAt: number;
readonly labels?: Readonly<Record<string, string>>;
}
/**
* Metric definition + its rolling sample buffer. The collector and
* monitor both produce `Metric` instances; `HistoricalDataStore`
* consumes them.
*/
export interface Metric {
readonly name: string;
readonly category: MetricCategory;
readonly type: MetricType;
readonly samples: ReadonlyArray<MetricValue>;
}
/**
* Statistical summary computed over a `Metric`'s sample buffer.
*/
export interface MetricStats {
readonly count: number;
readonly min: number;
readonly max: number;
readonly avg: number;
readonly p50: number;
readonly p95: number;
readonly p99: number;
}

View File

@@ -0,0 +1,70 @@
# Trace types — `@suaveplan/network-plugin-monitor`
## Purpose
Shared shapes for the distributed-tracing layer. `TraceContext` is the propagated header bundle peers exchange via message envelopes; `NetworkSpan` is the local recording with timing + attributes + ordered events; `NetworkTrace` aggregates spans for a single trace id.
## Features
- OpenTelemetry-shaped fields (`traceId`, `spanId`, `parentSpanId`, `sampled`) so exporters bridge into OTel pipelines without schema translation.
- `attributes` and `events` carry typed-but-flexible string/number/boolean values — high enough fidelity for typical mesh observability without leaking arbitrary objects through the type system.
- `SamplingStrategy` is a closed union mirroring the four strategies the tracer implements; consumer config reads the union directly.
## Usage: Basic Example
```ts
import type {
NetworkSpan,
SamplingStrategy,
TraceContext,
} from "@suaveplan/network-plugin-monitor";
const ctx: TraceContext = {
traceId: "0123abcd",
spanId: "00000001",
sampled: true,
};
const strategy: SamplingStrategy = "probabilistic";
declare const span: NetworkSpan;
console.log(span.name, span.attributes);
```
## Usage: Advanced Example
```ts
import type {
NetworkSpan,
NetworkTrace,
} from "@suaveplan/network-plugin-monitor";
const span: NetworkSpan = {
traceId: "0123abcd",
spanId: "00000002",
parentSpanId: "00000001",
name: "router.forward",
startedAt: 1_700_000_000_000,
attributes: { "peer.id": "peer-A", hop: 2 },
events: [
{ name: "enqueued", at: 1_700_000_000_001 },
{ name: "dispatched", at: 1_700_000_000_002 },
],
};
declare function exportTrace(trace: NetworkTrace): void;
exportTrace({ traceId: span.traceId, spans: [span] });
```
## API Reference
- `type SamplingStrategy` — `"probabilistic" | "rate-limited" | "error-based" | "priority-based"`.
- `interface TraceContext` — `{ traceId, spanId, parentSpanId?, sampled }`.
- `interface NetworkSpan` — `{ traceId, spanId, parentSpanId?, name, startedAt, endedAt?, attributes, events }`.
- `interface NetworkSpanEvent` — `{ name, at, attributes? }`.
- `interface NetworkTrace` — `{ traceId, spans }`.
## Implementation Notes
- `endedAt` is mutable on `NetworkSpan` because spans are populated incrementally — opening, recording events, closing. Consumers that need an immutable snapshot should clone before storing.
- `attributes` accepts `string | number | boolean` so exporters can serialise without bespoke conversions; objects / arrays are intentionally excluded.
- `traceId` and `spanId` are `string` rather than fixed-width hex so test fixtures can use deterministic ids without hex-padding boilerplate.
- The closed `SamplingStrategy` union is the contract between consumers and the tracer; adding a strategy requires updating every implementation switch and the spec-conformance tests.

View File

@@ -0,0 +1,39 @@
/**
* Sampling strategies the tracer supports. Implementations live on
* `NetworkTracer`; this type lets consumers configure the strategy
* without importing the runtime module.
*/
export type SamplingStrategy =
| "probabilistic"
| "rate-limited"
| "error-based"
| "priority-based";
export interface TraceContext {
readonly traceId: string;
readonly spanId: string;
readonly parentSpanId?: string;
readonly sampled: boolean;
}
export interface NetworkSpan {
readonly traceId: string;
readonly spanId: string;
readonly parentSpanId?: string;
readonly name: string;
readonly startedAt: number;
endedAt?: number;
readonly attributes: Record<string, string | number | boolean>;
readonly events: ReadonlyArray<NetworkSpanEvent>;
}
export interface NetworkSpanEvent {
readonly name: string;
readonly at: number;
readonly attributes?: Readonly<Record<string, string | number | boolean>>;
}
export interface NetworkTrace {
readonly traceId: string;
readonly spans: ReadonlyArray<NetworkSpan>;
}

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-monitor dep-graph", () => {
it("loads index.js and exports every public class", async () => {
const mod = await import("../src/index.js");
expect(mod.MetricCollector).toBeDefined();
expect(mod.NetworkMonitor).toBeDefined();
expect(mod.AlertEngine).toBeDefined();
expect(mod.NetworkTracer).toBeDefined();
expect(mod.HistoricalDataStore).toBeDefined();
expect(mod.AdaptiveSampler).toBeDefined();
expect(mod.MonitorPlugin).toBeDefined();
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-plugin-monitor",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/network",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});