first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.