Initial Commit
This commit is contained in:
52
packages/networking/network/network-plugin-monitor/README.md
Normal file
52
packages/networking/network/network-plugin-monitor/README.md
Normal 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…
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
119
packages/networking/network/network-plugin-monitor/src/index.ts
Normal file
119
packages/networking/network/network-plugin-monitor/src/index.ts
Normal 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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user