openvibers/openvibe.sdk Commits
Put the HTTP telemetry middleware and signals in the service kit (#14)
Changes
@@@ -7,6 +7,26 @@ release may change an API and says so here.++## 0.31.0 (2026-10-05)++++- **`openvibe-sdk/service` carries the HTTP telemetry middleware and autoscaling signals (plan T1):** lifted from++ OpenVibe.Network's `server/telemetry.js` and `server/observability.js`, which were the only implementation.++ `createHttpTelemetry({ service, sink, intervalMs = 15000, routeLabel?, skipExact?, skipPrefixes?, skip?, signals? })`++ returns a collector with `middleware()`, `record`/`gauge`/`count`, `requestStarted`, `requestFinished`,++ `observeRequest`, `flush` and `stop`. A request is not a row: requests aggregate per `route|method|status_class`++ per flush into `count`/`sum`/`max`/`p95`, and one `http.request` `platform.telemetry-sample@1` sample per key is++ emitted per flush (its mean in `latency_ms`, the counts in `extra`), plus the active-requests, rolling-p95 and++ event-loop-lag gauges once per flush — never per request, so the SDK buffer cannot overflow. A sample carries the++ route template, method and status class, never a raw URL or token; health, readiness, metrics and static assets are++ skipped (`telemetrySkipped`). The singleton `telemetry.init(...)` / `telemetryMiddleware` / `telemetry.stop()`++ wires it process-wide: `init` starts the event-loop monitor and the unref'd flush timer, `stop` stops both and++ flushes once (a `gracefulStop` stop step), and requiring the kit starts nothing. `registerSignals({start, stop,++ lag})` injects a service's own monitor. No change to the other modules. A request that matched no route is++ labelled `'unmatched'` (never its raw path), `skipPrefixes` defaults to `['/shared']` (Network passes++ `['/shared', '/api/chrome']`), the per-flush aggregation keys and latency samples are capped (overflow folds into++ the `'other'` route label; p95 uses reservoir sampling while count/sum/max stay exact), and a second `init`++ stops (flushes) the collector it replaces.++## 0.30.0 (2026-10-04)- **`openvibe-sdk/bot` (new, both; plan B8):** the OpenVibe.Bot client. `createBotClient(client, {actingSubject?})`@@@ -2,14 +2,14 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.12.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.++**Status:** alpha, v0.31.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.31.0```It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).@@@ -190,7 +190,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/storage` | server | `storageOffer()` and `validateStorageOffer()` for `platform.storage-offer@1` || `openvibe-sdk/delivery` | server | `deliveryOffer()` and `validateDeliveryOffer()` for `platform.delivery-offer@1` || `openvibe-sdk/node` | server | `nodeCapabilities()` and `validateNodeCapabilities()` for `platform.node-capabilities@1`; `nodeOffers()` derives resource and runtime offers from a node descriptor |--| `openvibe-sdk/service` | server | the service kit: `gracefulStop({name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, beforeDrain, handles})` → `{stop, stopping}`, `within(ms, p)`; `createServiceError(name)`, `ServiceError`, `asServiceError`, `run`, `wrap`, `sendError`, `jsonBody({limit})`, `privateNoStore`, `jsonErrors()`; re-exports (loaded on first use) of openvibe-shared `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`, `releaseInfo`, `createRelease` and openvibe-contracts `problem`, `sendProblem` |++| `openvibe-sdk/service` | server | the service kit: `gracefulStop({name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, beforeDrain, handles})` → `{stop, stopping}`, `within(ms, p)`; `createServiceError(name)`, `ServiceError`, `asServiceError`, `run`, `wrap`, `sendError`, `jsonBody({limit})`, `privateNoStore`, `jsonErrors()`; the HTTP telemetry kit: `createHttpTelemetry({service, sink, intervalMs?, routeLabel?, skipPrefixes?, …})` → `{middleware(), record, gauge, count, requestStarted, requestFinished, flush, stop}`, `createTelemetryMiddleware`, `telemetrySkipped`, `defaultRouteLabel`, `registerSignals`, `telemetrySample`, `validateTelemetrySample`, and the singleton `telemetry.{init, record, gauge, count, requestStarted, requestFinished, flush, stop}` + `telemetryMiddleware` (a request is aggregated per `route\|method\|status_class` per flush into one `platform.telemetry-sample@1` `http.request` sample, plus active-requests/p95/event-loop-lag gauges once per flush); re-exports (loaded on first use) of openvibe-shared `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`, `releaseInfo`, `createRelease` and openvibe-contracts `problem`, `sendProblem` || `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` || `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, bot, jobs, tools, projects |@@@ -389,6 +389,18 @@ connections closed every 50 ms, event streams destroyed), the close steps and `hit exits `deadlineExitCode`. `stopping()` turns true at once, for a readiness check. The recipe per service family is[docs/service.md](docs/service.md).++The kit also carries the HTTP telemetry Network wrote in `server/telemetry.js` and `server/observability.js`. A++request is not a row: `createHttpTelemetry({ service, sink, skipPrefixes? })` aggregates per++`route|method|status_class` per flush interval into `count`/`sum`/`max`/`p95` and emits one `http.request`++`platform.telemetry-sample@1` per key, plus active-requests, p95 and event-loop-lag gauges once per flush. A request++that matched no route is labelled `unmatched` (never its raw path), and the per-flush key map and latency samples++are capped, so a scanner or a 404 flood cannot grow them without bound. Mount++`collector.middleware()` before the routes and add `() => collector.stop()` to `gracefulStop`'s stop steps (one++flush). The `telemetry.init({service, sink})` / `telemetryMiddleware` / `telemetry.stop()` singleton is the same++design wired process-wide: health, readiness, metrics and static assets are skipped, `skipPrefixes` defaults to++`['/shared']` (OpenVibe.Network passes `['/shared', '/api/chrome']`), the event-loop monitor starts in `init` and++stops in `stop`, and requiring the kit starts nothing.++### Data: PostgreSQL and Valkey (server)Every service stores its data in PostgreSQL 18 through PgBouncer, and shares caches, limits, queues and fan-out through Valkey (ADR-035). The data role on the host provides both; `roles/data/add-service.sh` in OpenVibe.Host sets `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL` and `VALKEY_PREFIX`.@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.30.0",++ "version": "0.31.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -25,7 +25,8 @@"db (ADR-035): the async PostgreSQL data layer (node-postgres through PgBouncer; PGlite in tests) with safe sql fragments, retrying transactions and savepoints, versioned expand/migrate/contract migrations, and importSqlite for the one-time SQLite move with verification","valkey, cache, queue, pubsub and a shared limits store on Valkey 9, each with an in-process twin for tests","commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",-- "events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)"++ "events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)",++ "the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js"],-- "updated": "2026-10-04"++ "updated": "2026-10-05"}@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.30.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.31.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.30.0';++const SDK_VERSION = '0.31.0';module.exports = {createClient,@@@ -159,6 +159,49 @@ kit.It has no hard timer today. `gracefulStop({ name: 'OpenRe', server, close: [...] })` adds one (5 s, exit 1): newbehaviour, but a safer one.++## The telemetry++++The kit carries the HTTP telemetry Network wrote in `server/telemetry.js` and `server/observability.js`, so every++service emits `platform.telemetry-sample@1` the same way. A request is not a row: requests are aggregated per++`route|method|status_class` per flush interval into `count`, `sum`, `max` and `p95`, and **one** `http.request`++sample per key is emitted per flush — plus the HTTP autoscaling gauges (active requests, rolling p95, event-loop++lag) once per flush from a timer, never per request. The SDK buffer (maxBuffered 1000) therefore cannot overflow.++A sample carries the route template, method and status class — never a raw URL, client id or token (a request++that matched no route is `unmatched`, and the per-flush key map and latency samples are capped, so a scanner or a++404 flood stays bounded).++++```js++const svc = require('openvibe-sdk/service');++const collector = svc.createHttpTelemetry({++ service: 'blog', // the schema's service; there is no `instance` field++ sink: (samples) => post('/internal/telemetry', samples), // one batch per flush++ intervalMs: 15000, // also the p95 window++ skipPrefixes: ['/shared'], // default ['/shared']; Network passes '/shared', '/api/chrome'++ routeLabel: (req) => metrics.routeLabel(req), // optional; default reads req.route.path under req.baseUrl++});++app.use(collector.middleware()); // per request, before the routes++svc.gracefulStop({ name: 'Blog', server, stop: [() => collector.stop()] }); // stop() emits once, then flushes once++```++++The process-wide singleton keeps Network's shape, for a service that just wants it wired at boot:++++```js++svc.telemetry.init({ service: 'blog', sink }); // starts the flush timer and the event-loop monitor++app.use(svc.telemetryMiddleware); // a no-op until init, so mount order is not delicate++svc.gracefulStop({ name: 'Blog', server, stop: [() => svc.telemetry.stop()] }); // stops both, flushes once++```++++`telemetrySkipped(req)` skips `/api/health`, `/ready`, `/api/ready`, `/metrics` and static assets by extension;++`skipExact`, `skipPrefixes` and `skip(req)` add a service's own probes, chrome and shared assets. Field mapping++(`platform.telemetry-sample@1` has no generic gauge field): an aggregated request carries its mean in `latency_ms`++and the counts in `extra`; the p95 gauge carries its value in `latency_ms` (it is a latency); the active-requests++and event-loop-lag gauges carry theirs in `extra`, never in `latency_ms` (which a consumer reads as a response++time). `registerSignals({ start, stop, lag })` injects a service's own monitor in place of the default.++++**Network and Community** delete `server/telemetry.js` and the telemetry block of `server/observability.js`++(`telemetrySkipped`/`telemetryMiddleware`/the loop monitor) and register the kit's middleware with++`skipPrefixes: ['/shared', '/api/chrome']`; their `analyticsSink` becomes the `sink` passed to `init`.++## The errors```js@@@ -4,7 +4,10 @@ import cjs from '../src/service/index.js';export const {gracefulStop, within, DRAIN_MS, DEADLINE_MS, ServiceError, createServiceError,asServiceError, sendError, run, wrap, jsonBody, privateNoStore,-- jsonErrors,++ jsonErrors, DEFAULT_INTERVAL_MS, DEFAULT_MAX_ROUTE_KEYS, LATENCY_SAMPLE_CAP, DEFAULT_SKIP_PREFIXES, HTTP_METHODS,++ telemetrySample, validateTelemetrySample, telemetrySkipped, defaultRouteLabel, createHttpTelemetry, createTelemetryMiddleware,++ registerSignals, startEventLoopMonitor, stopEventLoopMonitor, eventLoopMonitorEnabled, eventLoopLagMs, telemetry,++ telemetryMiddleware,} = cjs;export function createReadiness(...args) { return cjs.createReadiness(...args); }export function skip(...args) { return cjs.skip(...args); }@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.30.0",++ "version": "0.31.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.30.0",++ "version": "0.31.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.30.0",++ "version": "0.31.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.30.0';++const SDK_VERSION = '0.31.0';module.exports = {createClient,@@@ -7,8 +7,10 @@* router.get('/api/posts/:id', svc.run(async (req) => posts.get(req.params.id), 200, { name: 'Blog API' }));* router.post('/api/posts', svc.jsonBody({ limit: '512kb' }), svc.run(create, 201));* app.use(svc.jsonErrors()); // opt-in: 404 + error mapping, mounted last++ * app.use(svc.telemetryMiddleware); // platform.telemetry-sample@1 (aggregated per route)++ * svc.telemetry.init({ service: 'blog', sink: (batch) => post('/internal/telemetry', batch) }); // starts the timer* const server = app.listen(port);-- * svc.gracefulStop({ name: 'Blog', server, stop: [() => worker.stop()], close: [() => outbox.stop(), () => db.close()] });++ * svc.gracefulStop({ name: 'Blog', server, stop: [() => svc.telemetry.stop()], close: [() => db.close()] });** Readiness, metrics, /release.json and the problem body are re-exported from openvibe-shared and* openvibe-contracts (./shared.js), loaded on first use. docs/service.md has the adoption recipe per service family.@@@ -16,8 +18,9 @@const shared = require('./shared');const graceful = require('./graceful');const errors = require('./errors');++const telemetry = require('./telemetry');--const out = { ...graceful, ...errors };++const out = { ...graceful, ...errors, ...telemetry };for (const name of Object.keys(shared)) Object.defineProperty(out, name, Object.getOwnPropertyDescriptor(shared, name));module.exports = out;nnew file mode 100644@@@ -0,0 +1,379 @@++'use strict';++/**++ * openvibe-sdk/service: the HTTP telemetry middleware and autoscaling signals — platform.telemetry-sample@1++ * at a service's request boundary. This is OpenVibe.Network's server/telemetry.js and the telemetry half of++ * its server/observability.js, lifted into the kit so every service emits one shape instead of copying it.++ *++ * const svc = require('openvibe-sdk/service');++ * const collector = svc.createHttpTelemetry({++ * service: 'blog',++ * sink: (samples) => post('/internal/telemetry', samples), // one batch per flush++ * skipPrefixes: ['/shared'], // default ['/shared'] (Network adds '/api/chrome')++ * routeLabel: (req) => metrics.routeLabel(req), // optional; the default reads req.route.path++ * });++ * app.use(collector.middleware());++ * svc.gracefulStop({ name: 'Blog', server, stop: [() => collector.stop()] }); // one flush at shutdown++ *++ * The process-wide singleton keeps Network's shape, for services that just want it wired at boot:++ * `svc.telemetry.init({ service, sink, … })` starts the flush timer and the event-loop monitor,++ * `svc.telemetryMiddleware` is the per-request middleware, `svc.telemetry.stop()` stops both and flushes++ * once (a gracefulStop stop step), and requiring this module starts nothing.++ *++ * Volume: a request is not a row. HTTP requests are aggregated per `route|method|status_class` per flush++ * interval into count, sum, max and p95, and ONE `http.request` sample per key is emitted per flush — not++ * one per request. The HTTP autoscaling gauges (active requests, p95, event-loop lag) are emitted once per++ * flush from a timer, never per request, so the SDK buffer (maxBuffered 1000) cannot overflow.++ *++ * Field mapping (platform.telemetry-sample@1; `latency_ms` is the schema's latency field, there is no++ * generic gauge field): an aggregated request carries its mean in `latency_ms` and count/sum/max/p95 in++ * `extra`; the p95 gauge carries its value in `latency_ms` (it is a latency); the active-requests and++ * event-loop-lag gauges carry theirs in `extra` — never in `latency_ms`, which a consumer maps to a++ * response time and would make indistinguishable from a latency.++ *++ * A sample carries the route template, method and status class — never a raw URL, client id or token; a++ * request that matched no route is labelled 'unmatched', never by its path. Per flush, the distinct aggregation++ * keys are capped (DEFAULT_MAX_ROUTE_KEYS; overflow folds into the 'other' route label) and each latency array is++ * capped (LATENCY_SAMPLE_CAP, reservoir-sampled for the p95 while count/sum/max stay exact), so a flush's memory++ * is bounded no matter the traffic. Health, readiness, metrics and static/shared assets carry no product signal++ * and are skipped before anything is counted (telemetrySkipped).++ */++const path = require('node:path');++const { createTelemetry, telemetrySample, validateTelemetrySample } = require('../telemetry');++++const DEFAULT_INTERVAL_MS = 15000;++/** Distinct route|method|status_class keys kept per flush; further keys fold into the 'other' route label. */++const DEFAULT_MAX_ROUTE_KEYS = 500;++/** Latency samples kept per key (and for the flush p95) by reservoir sampling; count/sum/max stay exact. */++const LATENCY_SAMPLE_CAP = 10000;++/** Path prefixes skipped by default (no product signal); a service overrides them, Network passes more. */++const DEFAULT_SKIP_PREFIXES = ['/shared'];++const HTTP_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);++const statusClass = (code) => (code ? `${Math.floor(code / 100)}xx` : 'aborted');++const round = (n, digits = 3) => { const f = 10 ** digits; return Math.round(n * f) / f; };++++/** p95 (nearest-rank) of an array of ms latencies; null when empty. Computed at most once per flush. */++function percentile95(values) {++ if (!values.length) return null;++ const sorted = [...values].sort((a, b) => a - b);++ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];++}++++/**++ * A fixed-capacity uniform sample of an unbounded stream (reservoir sampling): memory is O(cap). The p95 is++ * read from the sample; a bucket's count/sum/max stay exact on the bucket itself.++ */++function createReservoir(cap) {++ const values = [];++ let count = 0;++ return {++ values,++ push(value) {++ count += 1;++ if (values.length < cap) { values.push(value); return; }++ const j = Math.floor(Math.random() * count);++ if (j < cap) values[j] = value;++ },++ get size() { return values.length; },++ };++}++++// ── the event-loop monitor (a signal init starts and stop stops; requiring this module starts nothing) ──++let loopMonitor = null;++function startEventLoopMonitor() {++ if (loopMonitor) return;++ try {++ loopMonitor = require('node:perf_hooks').monitorEventLoopDelay({ resolution: 20 });++ loopMonitor.enable();++ } catch { loopMonitor = null; }++}++function stopEventLoopMonitor() {++ if (!loopMonitor) return;++ try { loopMonitor.disable(); } catch { /* already gone */ }++ loopMonitor = null;++}++/** Whether the monitor is running (a test asserts that requiring the module starts nothing). */++function eventLoopMonitorEnabled() { return loopMonitor != null; }++++/** The event-loop lag since the last flush, ms (the mean includes the 20 ms sampling interval: subtracted);++ * null before the monitor has a sample. The monitor resets per read, so each gauge covers one flush. */++function eventLoopLagMs() {++ if (!loopMonitor || !(loopMonitor.count > 0 || loopMonitor.max > 0)) return null;++ const lag = Math.max(0, loopMonitor.mean / 1e6 - 20);++ loopMonitor.reset();++ return lag;++}++++// The signals a collector reads. registerSignals lets a service (or Network's observability.js, during the++// move) inject its own start/stop/lag without this module owning them; the defaults are the monitor above.++const defaultSignals = { start: startEventLoopMonitor, stop: stopEventLoopMonitor, lag: eventLoopLagMs };++let signals = defaultSignals;++function registerSignals(injected) {++ signals = { ...signals, ...(injected || {}) };++}++++// ── what not to observe (no product signal) ───────────────────────++const SKIP_EXACT = new Set(['/api/health', '/ready', '/api/ready', '/metrics']);++// Anything express.static serves: public/ files and image/file assets. JSON is deliberately not here++// (routes such as /release.json and /contracts/*.json are product/observability API, not static assets).++const STATIC_EXTENSIONS = new Set([++ '.js', '.mjs', '.cjs', '.css', '.map', '.html', '.htm', '.txt', '.xml', '.webmanifest', '.wasm',++ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif', '.ico', '.bmp',++ '.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp3', '.mp4', '.webm', '.ogg', '.pdf',++]);++++/**++ * Whether a request carries no product signal: the probe paths (/api/health, /ready, /api/ready, /metrics),++ * a configured prefix (skipPrefixes), a static asset by extension, or whatever a `skip(req)` function also++ * refuses. `req.path` (Express) or the URL without its query is matched, never the raw URL. skipPrefixes++ * defaults to ['/shared']; OpenVibe.Network passes ['/shared', '/api/chrome']. Pass `prefixes: []` to disable.++ */++function telemetrySkipped(req, { exact = SKIP_EXACT, prefixes = DEFAULT_SKIP_PREFIXES, skip } = {}) {++ if (typeof skip === 'function' && skip(req)) return true;++ const raw = req == null ? '' : req.path || String(req.url || '').split('?')[0];++ const p = raw || '';++ if (exact.has(p)) return true;++ if (prefixes.some((prefix) => p === prefix || p.startsWith(`${prefix}/`))) return true;++ return STATIC_EXTENSIONS.has(path.extname(p).toLowerCase());++}++++/** The route label without openvibe-shared/metrics: Express's route template (req.route.path under++ * req.baseUrl), else 'unmatched'. A request with no matched route is never labelled by its raw path, so a++ * 404 or a scanner stays one bounded 'unmatched' key (as OpenVibe.Network's metrics.routeLabel does). */++function defaultRouteLabel(req) {++ if (req && req.route && typeof req.route.path === 'string') return `${req.baseUrl || ''}${req.route.path}`;++ return 'unmatched';++}++++/**++ * createHttpTelemetry({ service, sink, intervalMs = 15000, now, log, maxBuffered, routeLabel,++ * skipped, skipExact, skipPrefixes = ['/shared'], skip, maxRouteKeys = 500, signals }) -> a collector:++ * record/gauge/count, flush, stop, requestStarted, requestFinished, observeRequest, bufferStats,++ * routeLabel(req), skipped(req), middleware(opts) -> (req, res, next)++ * A request began/ended is folded into the interval's aggregation; flush() emits the interval's samples and++ * hands the SDK everything buffered; stop() emits once and lets the SDK clear its timer and flush (idempotent).++ * `service` is the schema's only process identity: it has no `instance` field, so none is emitted.++ */++function createHttpTelemetry(options = {}) {++ const {++ service = null, sink, intervalMs = DEFAULT_INTERVAL_MS, now, log, maxBuffered,++ routeLabel = defaultRouteLabel, skipped = null, skipExact, skipPrefixes = DEFAULT_SKIP_PREFIXES,++ skip, maxRouteKeys = DEFAULT_MAX_ROUTE_KEYS, signals: injected,++ } = options;++ const sdk = createTelemetry({ service, sink, intervalMs, now, log, maxBuffered });++ const sig = injected ? { ...signals, ...injected } : signals;++ const skipFn = typeof skipped === 'function'++ ? skipped++ : (req) => telemetrySkipped(req, { exact: skipExact, prefixes: skipPrefixes, skip });++++ let routes = new Map(); // key -> { route, method, status, count, sumMs, maxMs, latencies, codes }++ let allLatencies = createReservoir(LATENCY_SAMPLE_CAP); // the flush's latencies, for the single p95 gauge++ let active = 0; // requests in flight right now++ let activeMax = 0; // the interval's peak, sampled at request start++ let stopped = false;++++ /** A request began: track the in-flight peak here, not after the decrement. */++ function requestStarted() {++ active += 1;++ if (active > activeMax) activeMax = active;++ }++++ function observeRequest(info = {}) {++ let r = String(info.route == null ? 'unmatched' : info.route).slice(0, 200);++ const m = HTTP_METHODS.has(info.method) ? info.method : 'OTHER';++ const s = statusClass(info.httpStatus);++ const latency = Number.isFinite(info.latencyMs) && info.latencyMs >= 0 ? info.latencyMs : 0;++ let key = `${r}|${m}|${s}`;++ if (!routes.has(key) && routes.size >= maxRouteKeys) {++ r = 'other'; // the interval's key map is full: fold the new route label, bounded++ key = `other|${m}|${s}`;++ }++ let bucket = routes.get(key);++ if (!bucket) {++ bucket = { route: r, method: m, status: s, count: 0, sumMs: 0, maxMs: 0, latencies: createReservoir(LATENCY_SAMPLE_CAP), codes: new Set() };++ routes.set(key, bucket);++ }++ bucket.count += 1;++ bucket.sumMs += latency;++ if (latency > bucket.maxMs) bucket.maxMs = latency;++ bucket.latencies.push(latency);++ if (Number.isInteger(info.httpStatus) && info.httpStatus > 0) bucket.codes.add(info.httpStatus);++ allLatencies.push(latency);++ }++++ /** The live buffer sizes (diagnostics/tests): the aggregation keys and the sampled latencies held per flush. */++ function bufferStats() {++ let maxKeyLatencies = 0;++ for (const b of routes.values()) if (b.latencies.size > maxKeyLatencies) maxKeyLatencies = b.latencies.size;++ return { keys: routes.size, latencies: allLatencies.size, maxKeyLatencies };++ }++++ /** A request ended: free the in-flight slot, then fold it into its aggregation key. */++ function requestFinished(info) {++ if (active > 0) active -= 1;++ observeRequest(info);++ }++++ /** Push a gauge whose value(s) live in `extra`; the schema has no generic gauge field and latency_ms++ * is a consumer's response time, so extra is the documented home (never latency_ms). */++ function emitExtraGauge(name, extra) {++ const sample = sdk.record(name, 0, { extra });++ delete sample.latency_ms;++ return sample;++ }++++ /** Drain this interval: one http.request sample per route|method|status_class, then the three gauges. */++ function emitSamples() {++ const buckets = [...routes.values()];++ const latencies = allLatencies.values;++ routes = new Map();++ allLatencies = createReservoir(LATENCY_SAMPLE_CAP);++ for (const b of buckets) {++ const mean = b.count ? b.sumMs / b.count : 0;++ const p95 = percentile95(b.latencies.values);++ const extra = {++ method: b.method,++ status_class: b.status,++ count: b.count,++ sum_ms: round(b.sumMs),++ max_ms: round(b.maxMs),++ };++ if (p95 != null) extra.p95_ms = round(p95);++ if (b.codes.size === 1) extra.http_status = b.codes.values().next().value;++ sdk.record('http.request', round(mean), { status: b.status, resource: b.route, extra });++ }++ // The gauges, once per flush (never per request).++ emitExtraGauge('http.active_requests', { last: active, max: activeMax });++ activeMax = active; // the next interval's peak starts from what is still in flight++ const p95 = percentile95(latencies);++ if (p95 != null) sdk.record('http.latency_p95_ms', round(p95)); // latency_ms: the schema's latency field++ const lag = sig.lag();++ if (lag != null) emitExtraGauge('http.eventloop_lag_ms', { value: round(lag) });++ }++++ /** Emit the interval's samples, then hand the SDK everything buffered (a flush is also the tick). */++ function flush() {++ if (stopped) return Promise.resolve();++ emitSamples();++ return sdk.flush();++ }++++ /** Emit once, then let the SDK clear its timer and flush (idempotent). */++ async function stop() {++ if (stopped) return;++ stopped = true;++ emitSamples();++ await sdk.stop();++ }++++ const collector = {++ record: sdk.record, gauge: sdk.gauge, count: sdk.count,++ flush, stop, requestStarted, requestFinished, observeRequest, bufferStats,++ routeLabel, skipped: skipFn,++ middleware: (o = {}) => createTelemetryMiddleware(collector, { routeLabel, skipped: skipFn, ...o }),++ };++ return collector;++}++++/**++ * The Express/Connect middleware for a collector: a skipped request passes through; otherwise the in-flight++ * peak is sampled at start and the finished request (res 'finish', or 'close' for a client that left) is++ * folded by route|method|status_class. Telemetry never breaks a request and next() runs exactly once.++ */++function createTelemetryMiddleware(collector, { routeLabel = defaultRouteLabel, skipped = telemetrySkipped } = {}) {++ return function telemetryMiddleware(req, res, next) {++ if (skipped(req)) return next();++ try {++ collector.requestStarted();++ const t0 = process.hrtime.bigint();++ let done = false;++ const finish = () => {++ if (done) return;++ done = true;++ try {++ const latencyMs = Number(process.hrtime.bigint() - t0) / 1e6;++ const httpStatus = res.writableFinished || res.finished ? (res.statusCode || 0) : 0;++ collector.requestFinished({ route: routeLabel(req), method: req.method, httpStatus, latencyMs });++ } catch { /* telemetry never breaks a request */ }++ };++ res.once('finish', finish);++ res.once('close', finish);++ } catch { /* telemetry never breaks a request */ }++ return next();++ };++}++++// ── the process-wide collector (Network's shape: init/stop across the process, nothing at require time) ──++let current = null;++let currentMiddleware = null;++let timer = null;++let activeSignals = signals;++++/**++ * init({ service, sink, intervalMs?, routeLabel?, skipPrefixes?, signals?, … createHttpTelemetry options })++ * -> the collector. Starts the event-loop monitor (the injected signals, else the module's) and the flush timer++ * (unref'd). Call once at boot; a second call stops the previous collector first (its interval is emitted and++ * flushed, its timer cleared) and replaces it.++ */++function init(opts = {}) {++ if (timer) { clearInterval(timer); timer = null; }++ const previous = current;++ current = null;++ currentMiddleware = null;++ if (previous) previous.stop(); // flush the replaced collector's interval; its timer is stopped too++ current = createHttpTelemetry(opts);++ currentMiddleware = createTelemetryMiddleware(current, opts);++ activeSignals = opts.signals ? { ...signals, ...opts.signals } : signals;++ activeSignals.start();++ const interval = Number.isFinite(opts.intervalMs) ? opts.intervalMs : DEFAULT_INTERVAL_MS;++ timer = setInterval(() => { current.flush(); }, interval);++ if (timer.unref) timer.unref();++ return current;++}++++function record(...args) { return current ? current.record(...args) : undefined; }++function gauge(...args) { return current ? current.gauge(...args) : undefined; }++function count(...args) { return current ? current.count(...args) : undefined; }++function requestStarted() { return current ? current.requestStarted() : undefined; }++function requestFinished(info) { return current ? current.requestFinished(info) : undefined; }++function flush() { return current ? current.flush() : Promise.resolve(); }++++/** Stop the flush timer and the event-loop monitor, and flush the collector once (a gracefulStop stop step;++ * a second stop changes nothing). After it, the record/… functions are no-ops until the next init. */++async function stop() {++ if (timer) { clearInterval(timer); timer = null; }++ const c = current;++ current = null;++ currentMiddleware = null;++ if (c) await c.stop();++ activeSignals.stop();++ activeSignals = signals;++}++++/** The singleton's middleware: mounted before any route, a no-op until init (Network's ordering). */++function telemetryMiddleware(req, res, next) {++ if (!currentMiddleware) return next();++ return currentMiddleware(req, res, next);++}++++const telemetry = { init, record, gauge, count, requestStarted, requestFinished, flush, stop, middleware: telemetryMiddleware };++++module.exports = {++ DEFAULT_INTERVAL_MS,++ DEFAULT_MAX_ROUTE_KEYS,++ LATENCY_SAMPLE_CAP,++ DEFAULT_SKIP_PREFIXES,++ HTTP_METHODS,++ telemetrySample,++ validateTelemetrySample,++ telemetrySkipped,++ defaultRouteLabel,++ createHttpTelemetry,++ createTelemetryMiddleware,++ registerSignals,++ startEventLoopMonitor,++ stopEventLoopMonitor,++ eventLoopMonitorEnabled,++ eventLoopLagMs,++ telemetry,++ telemetryMiddleware,++};nnew file mode 100644@@@ -0,0 +1,276 @@++'use strict';++/**++ * openvibe-sdk/service telemetry: the per-request platform.telemetry-sample@1 middleware and the HTTP++ * autoscaling signals (active requests, p95, event-loop lag), lifted from OpenVibe.Network. Requests are++ * aggregated per route|method|status_class per flush; every emitted sample validates; one flush batches;++ * stop() flushes once; the event-loop monitor starts in init and stops in stop.++ */++const assert = require('node:assert/strict');++const http = require('node:http');++const { run } = require('./helpers');++const svc = require('../src/service');++++const quiet = { log() {}, warn() {}, error() {} };++const noSignals = { start() {}, stop() {}, lag() { return null; } };++let contracts = null;++try { contracts = require('openvibe-contracts'); } catch { /* the validation assertions skip */ }++++/** A collector with a capturing sink, a manual-only interval and the loop monitor replaced. */++function collector(opts = {}) {++ const batches = [];++ const c = svc.createHttpTelemetry({++ service: 'demo', intervalMs: 3600e3, log: quiet, signals: noSignals,++ sink: async (samples) => { batches.push(samples); }, ...opts,++ });++ return { c, batches, samples: () => batches.flat() };++}++++function get(url) {++ return new Promise((resolve, reject) => {++ http.get(url, (res) => { res.resume(); res.on('end', () => resolve(res.statusCode)); res.on('error', reject); }).on('error', reject);++ });++}++++async function listen(handler) {++ const server = http.createServer(handler);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { server, url: `http://127.0.0.1:${server.address().port}` };++}++++run([++ ['telemetrySkipped avoids probes and static assets, and honours prefixes and a custom skip', async () => {++ const req = (url) => ({ url });++ for (const p of ['/api/health', '/ready', '/api/ready', '/metrics', '/app.js', '/logo.PNG', '/x/y.css?cache=1']) {++ assert.equal(svc.telemetrySkipped(req(p)), true, p);++ }++ assert.equal(svc.telemetrySkipped(req('/api/posts/1?draft=1')), false, 'a product route is observed, query ignored');++ assert.equal(svc.telemetrySkipped(req('/release.json')), false, 'JSON API is not a static asset');++ assert.equal(svc.telemetrySkipped({ path: '/shared/nav.js', url: '/shared/nav.js' }, { prefixes: ['/shared'] }), true);++ assert.equal(svc.telemetrySkipped({ path: '/shared', url: '/shared' }, { prefixes: ['/shared'] }), true, 'the prefix itself');++ assert.equal(svc.telemetrySkipped({ path: '/sharedness', url: '/sharedness' }, { prefixes: ['/shared'] }), false, 'not a path segment');++ assert.equal(svc.telemetrySkipped(req('/api/x'), { skip: (r) => r.url === '/api/x' }), true);++ }],++++ ['requests aggregate per route|method|status_class and every sample validates: one flush batches', async () => {++ const { c, batches, samples } = collector({ signals: { start() {}, stop() {}, lag() { return 9.5; } } });++ c.requestStarted();++ c.requestFinished({ route: '/api/posts/:id', method: 'GET', httpStatus: 200, latencyMs: 12 });++ c.requestFinished({ route: '/api/posts/:id', method: 'GET', httpStatus: 200, latencyMs: 40 });++ c.requestFinished({ route: '/api/posts/:id', method: 'GET', httpStatus: 500, latencyMs: 30 });++ c.requestFinished({ route: '/api/posts', method: 'POST', httpStatus: 201, latencyMs: 7 });++ c.requestFinished({ route: undefined, method: 'GET', httpStatus: 404, latencyMs: 2 });++ c.requestFinished({ route: '/stream', method: 'TRACE', httpStatus: 0, latencyMs: NaN });++++ await c.flush();++ assert.equal(batches.length, 1, 'one flush is one sink call');++ const requests = samples().filter((s) => s.operation === 'http.request');++ assert.equal(requests.length, 5, 'route|method|status_class keys: two GET /:id by class, POST, unmatched, TRACE');++ const byKey = Object.fromEntries(requests.map((s) => [`${s.resource}|${s.extra.method}|${s.extra.status_class}`, s]));++ const ok = byKey['/api/posts/:id|GET|2xx'];++ assert.deepEqual(ok.extra, { method: 'GET', status_class: '2xx', count: 2, sum_ms: 52, max_ms: 40, p95_ms: 40, http_status: 200 });++ assert.equal(ok.latency_ms, 26, 'latency_ms is the mean');++ assert.equal(ok.status, '2xx');++ assert.deepEqual(byKey['/api/posts/:id|GET|5xx'].extra, { method: 'GET', status_class: '5xx', count: 1, sum_ms: 30, max_ms: 30, p95_ms: 30, http_status: 500 });++ assert.deepEqual(byKey['/api/posts|POST|2xx'].extra.method, 'POST');++ assert.deepEqual(byKey['unmatched|GET|4xx'].extra.http_status, 404);++ assert.equal(byKey['/stream|OTHER|aborted'].extra.status_class, 'aborted', 'a non-method and an aborted status are labelled');++++ assert.equal(samples().find((s) => s.operation === 'http.active_requests').extra.last, 0, 'all requests ended');++ assert.equal(samples().find((s) => s.operation === 'http.latency_p95_ms').latency_ms, 40);++ assert.equal(samples().find((s) => s.operation === 'http.eventloop_lag_ms').extra.value, 9.5);++ assert.ok(!('latency_ms' in samples().find((s) => s.operation === 'http.active_requests')), 'an extra gauge is never a latency');++ if (contracts) for (const s of samples()) assert.deepEqual(svc.validateTelemetrySample(s), { ok: true, errors: [] }, s.operation);++ else console.log('telemetry sample validation: skipped (openvibe-contracts not installed: npm install)');++ }],++++ ['the gauges are emitted once per flush, from the timer not per request', async () => {++ const { c, batches, samples } = collector({ signals: { start() {}, stop() {}, lag() { return null; } } });++ c.requestStarted(); // one in flight when the interval drains: the peak is 1++ c.requestFinished({ route: '/a', method: 'GET', httpStatus: 200, latencyMs: 5 });++ c.requestStarted();++ c.requestFinished({ route: '/a', method: 'GET', httpStatus: 200, latencyMs: 5 });++ await c.flush();++ assert.deepEqual(samples().find((s) => s.operation === 'http.active_requests').extra, { last: 0, max: 1 }, 'the interval peak, sampled at start');++ assert.equal(samples().filter((s) => s.operation === 'http.active_requests').length, 1, 'one active gauge for the interval, not per request');++ assert.equal(samples().filter((s) => s.operation === 'http.latency_p95_ms').length, 1);++ assert.equal(samples().filter((s) => s.operation === 'http.eventloop_lag_ms').length, 0, 'no lag reading: no gauge');++++ c.requestStarted(); // still in flight at the next flush: activeMax starts from it++ await c.flush();++ assert.deepEqual(samples().filter((s) => s.operation === 'http.active_requests').at(-1).extra, { last: 1, max: 1 });++ assert.equal(samples().filter((s) => s.operation === 'http.latency_p95_ms').length, 1, 'no latencies this interval: no p95 gauge');++ }],++++ ['stop() flushes once, is idempotent, and flush() after stop changes nothing', async () => {++ const { c, batches, samples } = collector();++ c.requestStarted();++ c.requestFinished({ route: '/a', method: 'GET', httpStatus: 200, latencyMs: 5 });++ await c.stop();++ assert.equal(batches.length, 1, 'stop flushes once');++ assert.equal(samples().filter((s) => s.operation === 'http.request').length, 1, 'the interval was emitted before the flush');++ assert.equal(samples().find((s) => s.operation === 'http.active_requests').extra.last, 0);++ await c.stop();++ await c.flush();++ assert.equal(batches.length, 1, 'a second stop/flush is a no-op');++ assert.equal(samples().length, 3, 'http.request + active + p95 (no lag signal)');++ }],++++ ['the middleware observes a real request by route template, skips health, and next() runs once', async () => {++ const { c, samples } = collector();++ const nextCalls = [];++ const mw = c.middleware();++ const { server, url } = await listen((req, res) => {++ if (req.url.startsWith('/api/posts/')) req.route = { path: '/api/posts/:id' };++ mw(req, res, () => { nextCalls.push(req.url); res.end('ok'); });++ });++ try {++ assert.equal(await get(`${url}/api/posts/1`), 200);++ assert.equal(await get(`${url}/api/posts/2`), 200);++ assert.equal(await get(`${url}/api/health`), 200);++ assert.equal(await get(`${url}/missing`), 200); // the stub answers 200 for any path++ await c.flush();++ const requests = samples().filter((s) => s.operation === 'http.request');++ const posts = requests.find((s) => s.resource === '/api/posts/:id');++ assert.equal(posts.extra.count, 2, 'the route template aggregates both ids');++ assert.deepEqual([posts.extra.method, posts.extra.status_class, posts.extra.http_status], ['GET', '2xx', 200]);++ assert.ok(!requests.some((s) => s.resource === '/api/health'), 'health was skipped before counting');++ assert.ok(nextCalls.includes('/api/health'), 'a skipped request still reached the app');++ assert.equal(nextCalls.length, 4, 'next() ran once per request');++ } finally {++ server.close();++ }++ }],++++ ['registerSignals injects the lag a default collector reads (the monitor is not started by require)', async () => {++ assert.equal(svc.eventLoopMonitorEnabled(), false, 'requiring the kit starts no monitor');++ svc.registerSignals({ lag: () => 4.25 });++ try {++ const { c, samples } = collector({ signals: undefined }); // no injection: the module signals++ await c.flush();++ assert.equal(samples().find((s) => s.operation === 'http.eventloop_lag_ms').extra.value, 4.25);++ } finally {++ svc.registerSignals({ start: svc.startEventLoopMonitor, stop: svc.stopEventLoopMonitor, lag: svc.eventLoopLagMs });++ }++ }],++++ ['the singleton: init starts the loop monitor and timer, stop stops both and flushes once', async () => {++ const batches = [];++ const fakeRes = { finished: true, statusCode: 204, once() {} };++ let next = 0;++ svc.telemetryMiddleware({ url: '/x' }, fakeRes, () => { next += 1; }); // before init: a no-op++ assert.equal(next, 1);++++ const c = svc.telemetry.init({ service: 'demo', sink: async (s) => { batches.push(s); }, intervalMs: 3600e3, log: quiet });++ assert.equal(typeof c.stop, 'function');++ assert.equal(svc.eventLoopMonitorEnabled(), true, 'init starts the monitor');++ svc.telemetry.record('boot', 1);++ svc.telemetry.requestFinished({ route: '/x', method: 'GET', httpStatus: 204, latencyMs: 3 });++ assert.equal(await svc.telemetry.flush(), undefined);++ const emitted = batches.flat();++ assert.ok(emitted.some((s) => s.operation === 'boot'));++ assert.ok(emitted.some((s) => s.operation === 'http.request' && s.resource === '/x'));++++ await svc.telemetry.stop();++ assert.equal(svc.eventLoopMonitorEnabled(), false, 'stop stops the monitor');++ assert.equal(batches.length, 2, 'init flush + stop flush');++ assert.equal(svc.telemetry.record('after', 1), undefined, 'after stop the singleton is a no-op');++ await svc.telemetry.stop(); // idempotent++ assert.equal(batches.length, 2);++ }],++++ ['defaultRouteLabel is the matched template or unmatched, never a raw path', async () => {++ assert.equal(svc.defaultRouteLabel({ route: { path: '/api/posts/:id' }, baseUrl: '/v1', path: '/v1/api/posts/7' }), '/v1/api/posts/:id');++ assert.equal(svc.defaultRouteLabel({ route: { path: '/' }, baseUrl: '' }), '/');++ assert.equal(svc.defaultRouteLabel({ path: '/etc/passwd', url: '/etc/passwd' }), 'unmatched', 'a request with no matched route is never labelled by its path');++ assert.equal(svc.defaultRouteLabel({ url: '/wp-login.php?x=1' }), 'unmatched', 'a scanner path is not a label');++ assert.equal(svc.defaultRouteLabel(null), 'unmatched');++ }],++++ ['1000 distinct 404 paths produce one unmatched key', async () => {++ const { c, samples } = collector();++ for (let i = 0; i < 1000; i += 1) {++ const route = svc.defaultRouteLabel({ path: `/missing/${i}`, url: `/missing/${i}?q=${i}` });++ c.requestFinished({ route, method: 'GET', httpStatus: 404, latencyMs: 1 });++ }++ await c.flush();++ const requests = samples().filter((s) => s.operation === 'http.request');++ assert.equal(requests.length, 1, 'all unmatched paths share one key');++ assert.equal(requests[0].resource, 'unmatched');++ assert.equal(requests[0].extra.status_class, '4xx');++ assert.equal(requests[0].extra.count, 1000, 'the count stays exact across the shared key');++ }],++++ ['distinct matched templates past the per-flush cap fold into the other key', async () => {++ const { c, samples } = collector({ maxRouteKeys: 3 });++ for (let i = 0; i < 6; i += 1) c.requestFinished({ route: `/api/r${i}/:id`, method: 'GET', httpStatus: 200, latencyMs: 1 });++ await c.flush();++ const requests = samples().filter((s) => s.operation === 'http.request');++ assert.equal(requests.length, 4, 'the cap of distinct templates plus the one overflow bucket');++ assert.equal(requests.filter((s) => s.resource !== 'other').length, 3, 'the first maxRouteKeys templates keep their labels');++ const other = requests.find((s) => s.resource === 'other');++ assert.equal(other.extra.count, 3, 'every later template folds into other');++ }],++++ ['skipPrefixes defaults to /shared and is overridable', async () => {++ assert.equal(svc.telemetrySkipped({ path: '/shared/data', url: '/shared/data' }), true, 'the default prefix');++ assert.equal(svc.telemetrySkipped({ path: '/shared', url: '/shared' }), true, 'the prefix itself');++ assert.equal(svc.telemetrySkipped({ path: '/sharedness', url: '/sharedness' }), false, 'not a path segment');++ assert.equal(svc.telemetrySkipped({ path: '/shared/data', url: '/shared/data' }, { prefixes: [] }), false, 'overridable with []');++ assert.equal(svc.telemetrySkipped({ path: '/api/chrome/x', url: '/api/chrome/x' }, { prefixes: ['/shared', '/api/chrome'] }), true, "Network's two prefixes");++ assert.equal(svc.telemetrySkipped({ path: '/api/chrome/x', url: '/api/chrome/x' }), false, 'only /shared is the SDK default');++++ const { c, samples } = collector();++ const mw = c.middleware();++ const { server, url } = await listen((req, res) => { mw(req, res, () => res.end('ok')); });++ try {++ assert.equal(await get(`${url}/shared/data`), 200);++ assert.equal(await get(`${url}/api/posts`), 200);++ await c.flush();++ assert.ok(!samples().some((s) => typeof s.resource === 'string' && s.resource.startsWith('/shared')), 'a default collector skips /shared before counting');++ assert.ok(samples().some((s) => s.resource === 'unmatched'), 'a non-skipped unmatched request is still counted');++ } finally {++ server.close();++ }++ }],++++ ['50k requests keep the latency buffers at the cap with the count exact', async () => {++ const { c, samples } = collector();++ for (let i = 0; i < 50000; i += 1) c.requestFinished({ route: '/api/x', method: 'GET', httpStatus: 200, latencyMs: (i % 100) + 1 });++ const stats = c.bufferStats();++ assert.deepEqual(stats, { keys: 1, latencies: svc.LATENCY_SAMPLE_CAP, maxKeyLatencies: svc.LATENCY_SAMPLE_CAP }, 'arrays capped at 10k');++ await c.flush();++ const s = samples().find((x) => x.operation === 'http.request');++ assert.equal(s.extra.count, 50000, 'the count is exact');++ assert.equal(s.extra.max_ms, 100, 'the max is exact');++ assert.equal(s.extra.sum_ms, 2525000, 'the sum is exact (1..100, 500 times each)');++ assert.equal(s.latency_ms, 50.5, 'the mean is exact');++ assert.ok(s.extra.p95_ms >= 1 && s.extra.p95_ms <= 100, 'the p95 comes from the bounded sample');++ assert.deepEqual(c.bufferStats(), { keys: 0, latencies: 0, maxKeyLatencies: 0 }, 'a flush resets the buffers');++ }],++++ ['init stops the previous collector: its interval is flushed and its timer replaced', async () => {++ const first = [];++ const second = [];++ const sinkOf = (batches) => async (s) => { batches.push(s); };++ const c1 = svc.telemetry.init({ service: 'demo', sink: sinkOf(first), intervalMs: 3600e3, log: quiet, signals: noSignals });++ svc.telemetry.requestFinished({ route: '/old', method: 'GET', httpStatus: 200, latencyMs: 4 });++ const c2 = svc.telemetry.init({ service: 'demo', sink: sinkOf(second), intervalMs: 3600e3, log: quiet, signals: noSignals });++ assert.notEqual(c2, c1);++ await new Promise((r) => setTimeout(r, 20)); // previous.stop() flushes asynchronously++ assert.ok(first.flat().some((s) => s.operation === 'http.request' && s.resource === '/old'), 'the replaced interval was flushed to the old sink');++ assert.equal(second.flat().length, 0, 'the new sink saw nothing yet');++++ svc.telemetry.requestFinished({ route: '/new', method: 'GET', httpStatus: 200, latencyMs: 2 });++ await svc.telemetry.flush();++ assert.ok(second.flat().some((s) => s.operation === 'http.request' && s.resource === '/new'), 'the singleton routes into the new collector');++ assert.ok(!second.flat().some((s) => s.resource === '/old'), 'the old interval did not leak into the new collector');++ await svc.telemetry.stop();++ }],++++ ['DEFAULT_INTERVAL_MS is the 15 s flush interval', async () => {++ assert.equal(svc.DEFAULT_INTERVAL_MS, 15000);++ assert.equal(svc.DEFAULT_MAX_ROUTE_KEYS, 500);++ assert.equal(svc.LATENCY_SAMPLE_CAP, 10000);++ assert.deepEqual(svc.DEFAULT_SKIP_PREFIXES, ['/shared']);++ }],++]);@@@ -117,6 +117,139 @@ export type JsonErrors = [(req: Req, res: Res) => unknown, (err: unknown, req: R};export declare function jsonErrors(options?: JsonErrorsOptions): JsonErrors;++// ── telemetry: platform.telemetry-sample@1 at the request boundary ──++++/** A platform.telemetry-sample@1 record. The schema allows only these fields; `extra` holds scalars. */++export interface TelemetrySample {++ service?: string;++ project?: string;++ subject?: string;++ resource?: string;++ provider?: string;++ node?: string;++ cell?: string;++ region?: string;++ operation?: string;++ at?: string;++ latency_ms?: number;++ queue_delay_ms?: number;++ ttfb_ms?: number;++ throughput_per_second?: number;++ bytes?: number;++ status?: string;++ cache_status?: string;++ cost_estimate?: number;++ route_epoch?: number;++ trace_id?: string;++ extra?: Record<string, string | number | boolean>;++}++++/** Build a record in the schema's field order; `at` defaults to now (ISO) and null/undefined are stripped. */++export declare function telemetrySample(fields: Partial<TelemetrySample>): TelemetrySample;++/** `{ ok, errors }` against platform.telemetry-sample@1 (openvibe-contracts, required lazily; a missing++ * package is never a claim of validity: `ok: false`, `errors: []`). */++export declare function validateTelemetrySample(record: unknown): { ok: boolean; errors: unknown[] };++++/** start/stop/lag; the defaults are the SDK's event-loop monitor (started by init, stopped by stop). */++export interface TelemetrySignals {++ start(): void;++ stop(): void;++ lag(): number | null;++}++++export interface HttpTelemetryOptions {++ /** The schema's `service` — the only process identity (there is no `instance` field). */++ service: string;++ /** Receives one batch per flush: `await sink(samples)`. */++ sink: (samples: TelemetrySample[]) => unknown;++ /** The flush interval; also the rolling p95 window. Default 15000. */++ intervalMs?: number;++ now?: () => number;++ log?: Logger;++ maxBuffered?: number;++ /** The route template for a request. Default: Express's `req.route.path` under `req.baseUrl`, else `'unmatched'`++ * (a raw path is never used as a label). */++ routeLabel?: (req: Req) => string;++ /** Replaces telemetrySkipped entirely. */++ skipped?: (req: Req) => boolean;++ /** Paths never observed. Default: /api/health, /ready, /api/ready, /metrics. */++ skipExact?: Set<string> | string[];++ /** Prefixes never observed (a path segment). Default `['/shared']`; OpenVibe.Network passes++ * `['/shared', '/api/chrome']`. Pass `[]` to disable. */++ skipPrefixes?: string[];++ /** An extra always-skip predicate. */++ skip?: (req: Req) => boolean;++ /** Distinct route|method|status_class keys kept per flush; further keys fold into the `'other'` route label.++ * Default 500. */++ maxRouteKeys?: number;++ /** Override the module's signals for this collector (tests; a service with its own monitor). */++ signals?: Partial<TelemetrySignals>;++}++++export interface RequestObservation {++ route?: string;++ method?: string;++ httpStatus?: number;++ latencyMs?: number;++}++++export interface HttpTelemetry {++ /** A number becomes `latency_ms`; any other value becomes `extra[name]` (a scalar dimension). */++ record(name: string, value: unknown, labels?: Record<string, unknown>): TelemetrySample;++ gauge(name: string, value: number, labels?: Record<string, unknown>): TelemetrySample;++ count(name: string, labels?: Record<string, unknown>): TelemetrySample;++ /** Emit the interval's aggregation, then hand the SDK everything buffered. */++ flush(): Promise<void>;++ /** Emit once, clear the timer and flush (idempotent; a gracefulStop stop step). */++ stop(): Promise<void>;++ /** A request began: the in-flight peak is sampled here. */++ requestStarted(): void;++ /** A request ended: free the in-flight slot and fold it by route|method|status_class. */++ requestFinished(info: RequestObservation): void;++ observeRequest(info?: RequestObservation): void;++ /** The live buffer sizes (diagnostics/tests): aggregation keys and sampled latencies held per flush. */++ bufferStats(): { keys: number; latencies: number; maxKeyLatencies: number };++ routeLabel(req: Req): string;++ skipped(req: Req): boolean;++ /** The per-request middleware for this collector. */++ middleware(options?: { routeLabel?: (req: Req) => string; skipped?: (req: Req) => boolean }): (req: Req, res: Res, next: Next) => void;++}++++export declare function createHttpTelemetry(options: HttpTelemetryOptions): HttpTelemetry;++/** The middleware for a collector; telemetry never breaks a request and next() runs exactly once. */++export declare function createTelemetryMiddleware(collector: HttpTelemetry, options?: { routeLabel?: (req: Req) => string; skipped?: (req: Req) => boolean }): (req: Req, res: Res, next: Next) => void;++/** Whether a request carries no product signal (probe paths, configured prefixes, static assets). */++export declare function telemetrySkipped(req: Req, options?: { exact?: Set<string> | string[]; prefixes?: string[]; skip?: (req: Req) => boolean }): boolean;++/** The route label without openvibe-shared/metrics: the matched route template, else `'unmatched'`. */++export declare function defaultRouteLabel(req: Req): string;++/** Merge signals over the current defaults (a service or test injecting start/stop/lag). */++export declare function registerSignals(injected?: Partial<TelemetrySignals>): void;++export declare function startEventLoopMonitor(): void;++export declare function stopEventLoopMonitor(): void;++/** Whether the monitor is running; requiring the kit starts nothing. */++export declare function eventLoopMonitorEnabled(): boolean;++/** The event-loop lag since the last read, ms (the monitor resets per read); null before a sample. */++export declare function eventLoopLagMs(): number | null;++/** The singleton's middleware; a no-op until `telemetry.init()`. */++export declare const telemetryMiddleware: (req: Req, res: Res, next: Next) => void;++/** The process-wide collector: init at boot, stop on shutdown, nothing at require time. */++export declare const telemetry: {++ init(options: HttpTelemetryOptions): HttpTelemetry;++ record: HttpTelemetry['record'];++ gauge: HttpTelemetry['gauge'];++ count: HttpTelemetry['count'];++ requestStarted: HttpTelemetry['requestStarted'];++ requestFinished: HttpTelemetry['requestFinished'];++ flush: HttpTelemetry['flush'];++ stop: HttpTelemetry['stop'];++ middleware: (req: Req, res: Res, next: Next) => void;++};++export declare const DEFAULT_INTERVAL_MS: 15000;++export declare const DEFAULT_MAX_ROUTE_KEYS: 500;++export declare const LATENCY_SAMPLE_CAP: 10000;++export declare const DEFAULT_SKIP_PREFIXES: string[];++export declare const HTTP_METHODS: Set<string>;++// ── re-exported from openvibe-shared and openvibe-contracts (loaded on first use) ──type Fn = (...args: any[]) => any;
@@ -7,6 +7,26 @@ release may change an API and says so here.
@@ -7,6 +7,26 @@ release may change an API and says so here.
+## 0.31.0 (2026-10-05)
+
+- **`openvibe-sdk/service` carries the HTTP telemetry middleware and autoscaling signals (plan T1):** lifted from
+ OpenVibe.Network's `server/telemetry.js` and `server/observability.js`, which were the only implementation.
+ `createHttpTelemetry({ service, sink, intervalMs = 15000, routeLabel?, skipExact?, skipPrefixes?, skip?, signals? })`
+ returns a collector with `middleware()`, `record`/`gauge`/`count`, `requestStarted`, `requestFinished`,
+ `observeRequest`, `flush` and `stop`. A request is not a row: requests aggregate per `route|method|status_class`
+ per flush into `count`/`sum`/`max`/`p95`, and one `http.request` `platform.telemetry-sample@1` sample per key is
+ emitted per flush (its mean in `latency_ms`, the counts in `extra`), plus the active-requests, rolling-p95 and
+ event-loop-lag gauges once per flush — never per request, so the SDK buffer cannot overflow. A sample carries the
+ route template, method and status class, never a raw URL or token; health, readiness, metrics and static assets are
+ skipped (`telemetrySkipped`). The singleton `telemetry.init(...)` / `telemetryMiddleware` / `telemetry.stop()`
+ wires it process-wide: `init` starts the event-loop monitor and the unref'd flush timer, `stop` stops both and
+ flushes once (a `gracefulStop` stop step), and requiring the kit starts nothing. `registerSignals({start, stop,
+ lag})` injects a service's own monitor. No change to the other modules. A request that matched no route is
+ labelled `'unmatched'` (never its raw path), `skipPrefixes` defaults to `['/shared']` (Network passes
+ `['/shared', '/api/chrome']`), the per-flush aggregation keys and latency samples are capped (overflow folds into
+ the `'other'` route label; p95 uses reservoir sampling while count/sum/max stay exact), and a second `init`
+ stops (flushes) the collector it replaces.
+
## 0.30.0 (2026-10-04)
## 0.30.0 (2026-10-04)
- **`openvibe-sdk/bot` (new, both; plan B8):** the OpenVibe.Bot client. `createBotClient(client, {actingSubject?})`
- **`openvibe-sdk/bot` (new, both; plan B8):** the OpenVibe.Bot client. `createBotClient(client, {actingSubject?})`
@@ -2,14 +2,14 @@
@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.12.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
+**Status:** alpha, v0.31.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.31.0
```
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@ -190,7 +190,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -190,7 +190,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/storage` | server | `storageOffer()` and `validateStorageOffer()` for `platform.storage-offer@1` |
| `openvibe-sdk/storage` | server | `storageOffer()` and `validateStorageOffer()` for `platform.storage-offer@1` |
| `openvibe-sdk/delivery` | server | `deliveryOffer()` and `validateDeliveryOffer()` for `platform.delivery-offer@1` |
| `openvibe-sdk/delivery` | server | `deliveryOffer()` and `validateDeliveryOffer()` for `platform.delivery-offer@1` |
| `openvibe-sdk/node` | server | `nodeCapabilities()` and `validateNodeCapabilities()` for `platform.node-capabilities@1`; `nodeOffers()` derives resource and runtime offers from a node descriptor |
| `openvibe-sdk/node` | server | `nodeCapabilities()` and `validateNodeCapabilities()` for `platform.node-capabilities@1`; `nodeOffers()` derives resource and runtime offers from a node descriptor |
-| `openvibe-sdk/service` | server | the service kit: `gracefulStop({name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, beforeDrain, handles})` → `{stop, stopping}`, `within(ms, p)`; `createServiceError(name)`, `ServiceError`, `asServiceError`, `run`, `wrap`, `sendError`, `jsonBody({limit})`, `privateNoStore`, `jsonErrors()`; re-exports (loaded on first use) of openvibe-shared `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`, `releaseInfo`, `createRelease` and openvibe-contracts `problem`, `sendProblem` |
+| `openvibe-sdk/service` | server | the service kit: `gracefulStop({name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, beforeDrain, handles})` → `{stop, stopping}`, `within(ms, p)`; `createServiceError(name)`, `ServiceError`, `asServiceError`, `run`, `wrap`, `sendError`, `jsonBody({limit})`, `privateNoStore`, `jsonErrors()`; the HTTP telemetry kit: `createHttpTelemetry({service, sink, intervalMs?, routeLabel?, skipPrefixes?, …})` → `{middleware(), record, gauge, count, requestStarted, requestFinished, flush, stop}`, `createTelemetryMiddleware`, `telemetrySkipped`, `defaultRouteLabel`, `registerSignals`, `telemetrySample`, `validateTelemetrySample`, and the singleton `telemetry.{init, record, gauge, count, requestStarted, requestFinished, flush, stop}` + `telemetryMiddleware` (a request is aggregated per `route\|method\|status_class` per flush into one `platform.telemetry-sample@1` `http.request` sample, plus active-requests/p95/event-loop-lag gauges once per flush); re-exports (loaded on first use) of openvibe-shared `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`, `releaseInfo`, `createRelease` and openvibe-contracts `problem`, `sendProblem` |
| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` |
| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` |
| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, bot, jobs, tools, projects |
| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, bot, jobs, tools, projects |
@@ -389,6 +389,18 @@ connections closed every 50 ms, event streams destroyed), the close steps and `h
@@ -389,6 +389,18 @@ connections closed every 50 ms, event streams destroyed), the close steps and `h
it exits `deadlineExitCode`. `stopping()` turns true at once, for a readiness check. The recipe per service family is
it exits `deadlineExitCode`. `stopping()` turns true at once, for a readiness check. The recipe per service family is
[docs/service.md](docs/service.md).
[docs/service.md](docs/service.md).
+The kit also carries the HTTP telemetry Network wrote in `server/telemetry.js` and `server/observability.js`. A
+request is not a row: `createHttpTelemetry({ service, sink, skipPrefixes? })` aggregates per
+`route|method|status_class` per flush interval into `count`/`sum`/`max`/`p95` and emits one `http.request`
+`platform.telemetry-sample@1` per key, plus active-requests, p95 and event-loop-lag gauges once per flush. A request
+that matched no route is labelled `unmatched` (never its raw path), and the per-flush key map and latency samples
+are capped, so a scanner or a 404 flood cannot grow them without bound. Mount
+`collector.middleware()` before the routes and add `() => collector.stop()` to `gracefulStop`'s stop steps (one
+flush). The `telemetry.init({service, sink})` / `telemetryMiddleware` / `telemetry.stop()` singleton is the same
+design wired process-wide: health, readiness, metrics and static assets are skipped, `skipPrefixes` defaults to
+`['/shared']` (OpenVibe.Network passes `['/shared', '/api/chrome']`), the event-loop monitor starts in `init` and
+stops in `stop`, and requiring the kit starts nothing.
+
### Data: PostgreSQL and Valkey (server)
### Data: PostgreSQL and Valkey (server)
Every service stores its data in PostgreSQL 18 through PgBouncer, and shares caches, limits, queues and fan-out through Valkey (ADR-035). The data role on the host provides both; `roles/data/add-service.sh` in OpenVibe.Host sets `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL` and `VALKEY_PREFIX`.
Every service stores its data in PostgreSQL 18 through PgBouncer, and shares caches, limits, queues and fan-out through Valkey (ADR-035). The data role on the host provides both; `roles/data/add-service.sh` in OpenVibe.Host sets `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL` and `VALKEY_PREFIX`.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.30.0",
+ "version": "0.31.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -25,7 +25,8 @@
@@ -25,7 +25,8 @@
"db (ADR-035): the async PostgreSQL data layer (node-postgres through PgBouncer; PGlite in tests) with safe sql fragments, retrying transactions and savepoints, versioned expand/migrate/contract migrations, and importSqlite for the one-time SQLite move with verification",
"db (ADR-035): the async PostgreSQL data layer (node-postgres through PgBouncer; PGlite in tests) with safe sql fragments, retrying transactions and savepoints, versioned expand/migrate/contract migrations, and importSqlite for the one-time SQLite move with verification",
"valkey, cache, queue, pubsub and a shared limits store on Valkey 9, each with an in-process twin for tests",
"valkey, cache, queue, pubsub and a shared limits store on Valkey 9, each with an in-process twin for tests",
"commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",
"commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",
- "events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)"
+ "events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)",
+ "the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js"
],
],
- "updated": "2026-10-04"
+ "updated": "2026-10-05"
}
}
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.30.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.31.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.30.0';
+const SDK_VERSION = '0.31.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -159,6 +159,49 @@ kit.
@@ -159,6 +159,49 @@ kit.
It has no hard timer today. `gracefulStop({ name: 'OpenRe', server, close: [...] })` adds one (5 s, exit 1): new
It has no hard timer today. `gracefulStop({ name: 'OpenRe', server, close: [...] })` adds one (5 s, exit 1): new
behaviour, but a safer one.
behaviour, but a safer one.
+## The telemetry
+
+The kit carries the HTTP telemetry Network wrote in `server/telemetry.js` and `server/observability.js`, so every
+service emits `platform.telemetry-sample@1` the same way. A request is not a row: requests are aggregated per
+`route|method|status_class` per flush interval into `count`, `sum`, `max` and `p95`, and **one** `http.request`
+sample per key is emitted per flush — plus the HTTP autoscaling gauges (active requests, rolling p95, event-loop
+lag) once per flush from a timer, never per request. The SDK buffer (maxBuffered 1000) therefore cannot overflow.
+A sample carries the route template, method and status class — never a raw URL, client id or token (a request
+that matched no route is `unmatched`, and the per-flush key map and latency samples are capped, so a scanner or a
+404 flood stays bounded).
+
+```js
+const svc = require('openvibe-sdk/service');
+const collector = svc.createHttpTelemetry({
+ service: 'blog', // the schema's service; there is no `instance` field
+ sink: (samples) => post('/internal/telemetry', samples), // one batch per flush
+ intervalMs: 15000, // also the p95 window
+ skipPrefixes: ['/shared'], // default ['/shared']; Network passes '/shared', '/api/chrome'
+ routeLabel: (req) => metrics.routeLabel(req), // optional; default reads req.route.path under req.baseUrl
+});
+app.use(collector.middleware()); // per request, before the routes
+svc.gracefulStop({ name: 'Blog', server, stop: [() => collector.stop()] }); // stop() emits once, then flushes once
+```
+
+The process-wide singleton keeps Network's shape, for a service that just wants it wired at boot:
+
+```js
+svc.telemetry.init({ service: 'blog', sink }); // starts the flush timer and the event-loop monitor
+app.use(svc.telemetryMiddleware); // a no-op until init, so mount order is not delicate
+svc.gracefulStop({ name: 'Blog', server, stop: [() => svc.telemetry.stop()] }); // stops both, flushes once
+```
+
+`telemetrySkipped(req)` skips `/api/health`, `/ready`, `/api/ready`, `/metrics` and static assets by extension;
+`skipExact`, `skipPrefixes` and `skip(req)` add a service's own probes, chrome and shared assets. Field mapping
+(`platform.telemetry-sample@1` has no generic gauge field): an aggregated request carries its mean in `latency_ms`
+and the counts in `extra`; the p95 gauge carries its value in `latency_ms` (it is a latency); the active-requests
+and event-loop-lag gauges carry theirs in `extra`, never in `latency_ms` (which a consumer reads as a response
+time). `registerSignals({ start, stop, lag })` injects a service's own monitor in place of the default.
+
+**Network and Community** delete `server/telemetry.js` and the telemetry block of `server/observability.js`
+(`telemetrySkipped`/`telemetryMiddleware`/the loop monitor) and register the kit's middleware with
+`skipPrefixes: ['/shared', '/api/chrome']`; their `analyticsSink` becomes the `sink` passed to `init`.
+
## The errors
## The errors
```js
```js
@@ -4,7 +4,10 @@ import cjs from '../src/service/index.js';
@@ -4,7 +4,10 @@ import cjs from '../src/service/index.js';
export const {
export const {
gracefulStop, within, DRAIN_MS, DEADLINE_MS, ServiceError, createServiceError,
gracefulStop, within, DRAIN_MS, DEADLINE_MS, ServiceError, createServiceError,
asServiceError, sendError, run, wrap, jsonBody, privateNoStore,
asServiceError, sendError, run, wrap, jsonBody, privateNoStore,
- jsonErrors,
+ jsonErrors, DEFAULT_INTERVAL_MS, DEFAULT_MAX_ROUTE_KEYS, LATENCY_SAMPLE_CAP, DEFAULT_SKIP_PREFIXES, HTTP_METHODS,
+ telemetrySample, validateTelemetrySample, telemetrySkipped, defaultRouteLabel, createHttpTelemetry, createTelemetryMiddleware,
+ registerSignals, startEventLoopMonitor, stopEventLoopMonitor, eventLoopMonitorEnabled, eventLoopLagMs, telemetry,
+ telemetryMiddleware,
} = cjs;
} = cjs;
export function createReadiness(...args) { return cjs.createReadiness(...args); }
export function createReadiness(...args) { return cjs.createReadiness(...args); }
export function skip(...args) { return cjs.skip(...args); }
export function skip(...args) { return cjs.skip(...args); }
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.30.0",
+ "version": "0.31.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.30.0",
+ "version": "0.31.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.30.0",
+ "version": "0.31.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.30.0';
+const SDK_VERSION = '0.31.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -7,8 +7,10 @@
@@ -7,8 +7,10 @@
* router.get('/api/posts/:id', svc.run(async (req) => posts.get(req.params.id), 200, { name: 'Blog API' }));
* router.get('/api/posts/:id', svc.run(async (req) => posts.get(req.params.id), 200, { name: 'Blog API' }));
* router.post('/api/posts', svc.jsonBody({ limit: '512kb' }), svc.run(create, 201));
* router.post('/api/posts', svc.jsonBody({ limit: '512kb' }), svc.run(create, 201));
* app.use(svc.jsonErrors()); // opt-in: 404 + error mapping, mounted last
* app.use(svc.jsonErrors()); // opt-in: 404 + error mapping, mounted last
+ * app.use(svc.telemetryMiddleware); // platform.telemetry-sample@1 (aggregated per route)
+ * svc.telemetry.init({ service: 'blog', sink: (batch) => post('/internal/telemetry', batch) }); // starts the timer
* const server = app.listen(port);
* const server = app.listen(port);
- * svc.gracefulStop({ name: 'Blog', server, stop: [() => worker.stop()], close: [() => outbox.stop(), () => db.close()] });
+ * svc.gracefulStop({ name: 'Blog', server, stop: [() => svc.telemetry.stop()], close: [() => db.close()] });
*
*
* Readiness, metrics, /release.json and the problem body are re-exported from openvibe-shared and
* Readiness, metrics, /release.json and the problem body are re-exported from openvibe-shared and
* openvibe-contracts (./shared.js), loaded on first use. docs/service.md has the adoption recipe per service family.
* openvibe-contracts (./shared.js), loaded on first use. docs/service.md has the adoption recipe per service family.
@@ -16,8 +18,9 @@
@@ -16,8 +18,9 @@
const shared = require('./shared');
const shared = require('./shared');
const graceful = require('./graceful');
const graceful = require('./graceful');
const errors = require('./errors');
const errors = require('./errors');
+const telemetry = require('./telemetry');
-const out = { ...graceful, ...errors };
+const out = { ...graceful, ...errors, ...telemetry };
for (const name of Object.keys(shared)) Object.defineProperty(out, name, Object.getOwnPropertyDescriptor(shared, name));
for (const name of Object.keys(shared)) Object.defineProperty(out, name, Object.getOwnPropertyDescriptor(shared, name));
module.exports = out;
module.exports = out;
new file mode 100644
new file mode 100644
@@ -0,0 +1,379 @@
@@ -0,0 +1,379 @@
+'use strict';
+/**
+ * openvibe-sdk/service: the HTTP telemetry middleware and autoscaling signals — platform.telemetry-sample@1
+ * at a service's request boundary. This is OpenVibe.Network's server/telemetry.js and the telemetry half of
+ * its server/observability.js, lifted into the kit so every service emits one shape instead of copying it.
+ *
+ * const svc = require('openvibe-sdk/service');
+ * const collector = svc.createHttpTelemetry({
+ * service: 'blog',
+ * sink: (samples) => post('/internal/telemetry', samples), // one batch per flush
+ * skipPrefixes: ['/shared'], // default ['/shared'] (Network adds '/api/chrome')
+ * routeLabel: (req) => metrics.routeLabel(req), // optional; the default reads req.route.path
+ * });
+ * app.use(collector.middleware());
+ * svc.gracefulStop({ name: 'Blog', server, stop: [() => collector.stop()] }); // one flush at shutdown
+ *
+ * The process-wide singleton keeps Network's shape, for services that just want it wired at boot:
+ * `svc.telemetry.init({ service, sink, … })` starts the flush timer and the event-loop monitor,
+ * `svc.telemetryMiddleware` is the per-request middleware, `svc.telemetry.stop()` stops both and flushes
+ * once (a gracefulStop stop step), and requiring this module starts nothing.
+ *
+ * Volume: a request is not a row. HTTP requests are aggregated per `route|method|status_class` per flush
+ * interval into count, sum, max and p95, and ONE `http.request` sample per key is emitted per flush — not
+ * one per request. The HTTP autoscaling gauges (active requests, p95, event-loop lag) are emitted once per
+ * flush from a timer, never per request, so the SDK buffer (maxBuffered 1000) cannot overflow.
+ *
+ * Field mapping (platform.telemetry-sample@1; `latency_ms` is the schema's latency field, there is no
+ * generic gauge field): an aggregated request carries its mean in `latency_ms` and count/sum/max/p95 in
+ * `extra`; the p95 gauge carries its value in `latency_ms` (it is a latency); the active-requests and
+ * event-loop-lag gauges carry theirs in `extra` — never in `latency_ms`, which a consumer maps to a
+ * response time and would make indistinguishable from a latency.
+ *
+ * A sample carries the route template, method and status class — never a raw URL, client id or token; a
+ * request that matched no route is labelled 'unmatched', never by its path. Per flush, the distinct aggregation
+ * keys are capped (DEFAULT_MAX_ROUTE_KEYS; overflow folds into the 'other' route label) and each latency array is
+ * capped (LATENCY_SAMPLE_CAP, reservoir-sampled for the p95 while count/sum/max stay exact), so a flush's memory
+ * is bounded no matter the traffic. Health, readiness, metrics and static/shared assets carry no product signal
+ * and are skipped before anything is counted (telemetrySkipped).
+ */
+const path = require('node:path');
+const { createTelemetry, telemetrySample, validateTelemetrySample } = require('../telemetry');
+
+const DEFAULT_INTERVAL_MS = 15000;
+/** Distinct route|method|status_class keys kept per flush; further keys fold into the 'other' route label. */
+const DEFAULT_MAX_ROUTE_KEYS = 500;
+/** Latency samples kept per key (and for the flush p95) by reservoir sampling; count/sum/max stay exact. */
+const LATENCY_SAMPLE_CAP = 10000;
+/** Path prefixes skipped by default (no product signal); a service overrides them, Network passes more. */
+const DEFAULT_SKIP_PREFIXES = ['/shared'];
+const HTTP_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
+const statusClass = (code) => (code ? `${Math.floor(code / 100)}xx` : 'aborted');
+const round = (n, digits = 3) => { const f = 10 ** digits; return Math.round(n * f) / f; };
+
+/** p95 (nearest-rank) of an array of ms latencies; null when empty. Computed at most once per flush. */
+function percentile95(values) {
+ if (!values.length) return null;
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
+}
+
+/**
+ * A fixed-capacity uniform sample of an unbounded stream (reservoir sampling): memory is O(cap). The p95 is
+ * read from the sample; a bucket's count/sum/max stay exact on the bucket itself.
+ */
+function createReservoir(cap) {
+ const values = [];
+ let count = 0;
+ return {
+ values,
+ push(value) {
+ count += 1;
+ if (values.length < cap) { values.push(value); return; }
+ const j = Math.floor(Math.random() * count);
+ if (j < cap) values[j] = value;
+ },
+ get size() { return values.length; },
+ };
+}
+
+// ── the event-loop monitor (a signal init starts and stop stops; requiring this module starts nothing) ──
+let loopMonitor = null;
+function startEventLoopMonitor() {
+ if (loopMonitor) return;
+ try {
+ loopMonitor = require('node:perf_hooks').monitorEventLoopDelay({ resolution: 20 });
+ loopMonitor.enable();
+ } catch { loopMonitor = null; }
+}
+function stopEventLoopMonitor() {
+ if (!loopMonitor) return;
+ try { loopMonitor.disable(); } catch { /* already gone */ }
+ loopMonitor = null;
+}
+/** Whether the monitor is running (a test asserts that requiring the module starts nothing). */
+function eventLoopMonitorEnabled() { return loopMonitor != null; }
+
+/** The event-loop lag since the last flush, ms (the mean includes the 20 ms sampling interval: subtracted);
+ * null before the monitor has a sample. The monitor resets per read, so each gauge covers one flush. */
+function eventLoopLagMs() {
+ if (!loopMonitor || !(loopMonitor.count > 0 || loopMonitor.max > 0)) return null;
+ const lag = Math.max(0, loopMonitor.mean / 1e6 - 20);
+ loopMonitor.reset();
+ return lag;
+}
+
+// The signals a collector reads. registerSignals lets a service (or Network's observability.js, during the
+// move) inject its own start/stop/lag without this module owning them; the defaults are the monitor above.
+const defaultSignals = { start: startEventLoopMonitor, stop: stopEventLoopMonitor, lag: eventLoopLagMs };
+let signals = defaultSignals;
+function registerSignals(injected) {
+ signals = { ...signals, ...(injected || {}) };
+}
+
+// ── what not to observe (no product signal) ───────────────────────
+const SKIP_EXACT = new Set(['/api/health', '/ready', '/api/ready', '/metrics']);
+// Anything express.static serves: public/ files and image/file assets. JSON is deliberately not here
+// (routes such as /release.json and /contracts/*.json are product/observability API, not static assets).
+const STATIC_EXTENSIONS = new Set([
+ '.js', '.mjs', '.cjs', '.css', '.map', '.html', '.htm', '.txt', '.xml', '.webmanifest', '.wasm',
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif', '.ico', '.bmp',
+ '.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp3', '.mp4', '.webm', '.ogg', '.pdf',
+]);
+
+/**
+ * Whether a request carries no product signal: the probe paths (/api/health, /ready, /api/ready, /metrics),
+ * a configured prefix (skipPrefixes), a static asset by extension, or whatever a `skip(req)` function also
+ * refuses. `req.path` (Express) or the URL without its query is matched, never the raw URL. skipPrefixes
+ * defaults to ['/shared']; OpenVibe.Network passes ['/shared', '/api/chrome']. Pass `prefixes: []` to disable.
+ */
+function telemetrySkipped(req, { exact = SKIP_EXACT, prefixes = DEFAULT_SKIP_PREFIXES, skip } = {}) {
+ if (typeof skip === 'function' && skip(req)) return true;
+ const raw = req == null ? '' : req.path || String(req.url || '').split('?')[0];
+ const p = raw || '';
+ if (exact.has(p)) return true;
+ if (prefixes.some((prefix) => p === prefix || p.startsWith(`${prefix}/`))) return true;
+ return STATIC_EXTENSIONS.has(path.extname(p).toLowerCase());
+}
+
+/** The route label without openvibe-shared/metrics: Express's route template (req.route.path under
+ * req.baseUrl), else 'unmatched'. A request with no matched route is never labelled by its raw path, so a
+ * 404 or a scanner stays one bounded 'unmatched' key (as OpenVibe.Network's metrics.routeLabel does). */
+function defaultRouteLabel(req) {
+ if (req && req.route && typeof req.route.path === 'string') return `${req.baseUrl || ''}${req.route.path}`;
+ return 'unmatched';
+}
+
+/**
+ * createHttpTelemetry({ service, sink, intervalMs = 15000, now, log, maxBuffered, routeLabel,
+ * skipped, skipExact, skipPrefixes = ['/shared'], skip, maxRouteKeys = 500, signals }) -> a collector:
+ * record/gauge/count, flush, stop, requestStarted, requestFinished, observeRequest, bufferStats,
+ * routeLabel(req), skipped(req), middleware(opts) -> (req, res, next)
+ * A request began/ended is folded into the interval's aggregation; flush() emits the interval's samples and
+ * hands the SDK everything buffered; stop() emits once and lets the SDK clear its timer and flush (idempotent).
+ * `service` is the schema's only process identity: it has no `instance` field, so none is emitted.
+ */
+function createHttpTelemetry(options = {}) {
+ const {
+ service = null, sink, intervalMs = DEFAULT_INTERVAL_MS, now, log, maxBuffered,
+ routeLabel = defaultRouteLabel, skipped = null, skipExact, skipPrefixes = DEFAULT_SKIP_PREFIXES,
+ skip, maxRouteKeys = DEFAULT_MAX_ROUTE_KEYS, signals: injected,
+ } = options;
+ const sdk = createTelemetry({ service, sink, intervalMs, now, log, maxBuffered });
+ const sig = injected ? { ...signals, ...injected } : signals;
+ const skipFn = typeof skipped === 'function'
+ ? skipped
+ : (req) => telemetrySkipped(req, { exact: skipExact, prefixes: skipPrefixes, skip });
+
+ let routes = new Map(); // key -> { route, method, status, count, sumMs, maxMs, latencies, codes }
+ let allLatencies = createReservoir(LATENCY_SAMPLE_CAP); // the flush's latencies, for the single p95 gauge
+ let active = 0; // requests in flight right now
+ let activeMax = 0; // the interval's peak, sampled at request start
+ let stopped = false;
+
+ /** A request began: track the in-flight peak here, not after the decrement. */
+ function requestStarted() {
+ active += 1;
+ if (active > activeMax) activeMax = active;
+ }
+
+ function observeRequest(info = {}) {
+ let r = String(info.route == null ? 'unmatched' : info.route).slice(0, 200);
+ const m = HTTP_METHODS.has(info.method) ? info.method : 'OTHER';
+ const s = statusClass(info.httpStatus);
+ const latency = Number.isFinite(info.latencyMs) && info.latencyMs >= 0 ? info.latencyMs : 0;
+ let key = `${r}|${m}|${s}`;
+ if (!routes.has(key) && routes.size >= maxRouteKeys) {
+ r = 'other'; // the interval's key map is full: fold the new route label, bounded
+ key = `other|${m}|${s}`;
+ }
+ let bucket = routes.get(key);
+ if (!bucket) {
+ bucket = { route: r, method: m, status: s, count: 0, sumMs: 0, maxMs: 0, latencies: createReservoir(LATENCY_SAMPLE_CAP), codes: new Set() };
+ routes.set(key, bucket);
+ }
+ bucket.count += 1;
+ bucket.sumMs += latency;
+ if (latency > bucket.maxMs) bucket.maxMs = latency;
+ bucket.latencies.push(latency);
+ if (Number.isInteger(info.httpStatus) && info.httpStatus > 0) bucket.codes.add(info.httpStatus);
+ allLatencies.push(latency);
+ }
+
+ /** The live buffer sizes (diagnostics/tests): the aggregation keys and the sampled latencies held per flush. */
+ function bufferStats() {
+ let maxKeyLatencies = 0;
+ for (const b of routes.values()) if (b.latencies.size > maxKeyLatencies) maxKeyLatencies = b.latencies.size;
+ return { keys: routes.size, latencies: allLatencies.size, maxKeyLatencies };
+ }
+
+ /** A request ended: free the in-flight slot, then fold it into its aggregation key. */
+ function requestFinished(info) {
+ if (active > 0) active -= 1;
+ observeRequest(info);
+ }
+
+ /** Push a gauge whose value(s) live in `extra`; the schema has no generic gauge field and latency_ms
+ * is a consumer's response time, so extra is the documented home (never latency_ms). */
+ function emitExtraGauge(name, extra) {
+ const sample = sdk.record(name, 0, { extra });
+ delete sample.latency_ms;
+ return sample;
+ }
+
+ /** Drain this interval: one http.request sample per route|method|status_class, then the three gauges. */
+ function emitSamples() {
+ const buckets = [...routes.values()];
+ const latencies = allLatencies.values;
+ routes = new Map();
+ allLatencies = createReservoir(LATENCY_SAMPLE_CAP);
+ for (const b of buckets) {
+ const mean = b.count ? b.sumMs / b.count : 0;
+ const p95 = percentile95(b.latencies.values);
+ const extra = {
+ method: b.method,
+ status_class: b.status,
+ count: b.count,
+ sum_ms: round(b.sumMs),
+ max_ms: round(b.maxMs),
+ };
+ if (p95 != null) extra.p95_ms = round(p95);
+ if (b.codes.size === 1) extra.http_status = b.codes.values().next().value;
+ sdk.record('http.request', round(mean), { status: b.status, resource: b.route, extra });
+ }
+ // The gauges, once per flush (never per request).
+ emitExtraGauge('http.active_requests', { last: active, max: activeMax });
+ activeMax = active; // the next interval's peak starts from what is still in flight
+ const p95 = percentile95(latencies);
+ if (p95 != null) sdk.record('http.latency_p95_ms', round(p95)); // latency_ms: the schema's latency field
+ const lag = sig.lag();
+ if (lag != null) emitExtraGauge('http.eventloop_lag_ms', { value: round(lag) });
+ }
+
+ /** Emit the interval's samples, then hand the SDK everything buffered (a flush is also the tick). */
+ function flush() {
+ if (stopped) return Promise.resolve();
+ emitSamples();
+ return sdk.flush();
+ }
+
+ /** Emit once, then let the SDK clear its timer and flush (idempotent). */
+ async function stop() {
+ if (stopped) return;
+ stopped = true;
+ emitSamples();
+ await sdk.stop();
+ }
+
+ const collector = {
+ record: sdk.record, gauge: sdk.gauge, count: sdk.count,
+ flush, stop, requestStarted, requestFinished, observeRequest, bufferStats,
+ routeLabel, skipped: skipFn,
+ middleware: (o = {}) => createTelemetryMiddleware(collector, { routeLabel, skipped: skipFn, ...o }),
+ };
+ return collector;
+}
+
+/**
+ * The Express/Connect middleware for a collector: a skipped request passes through; otherwise the in-flight
+ * peak is sampled at start and the finished request (res 'finish', or 'close' for a client that left) is
+ * folded by route|method|status_class. Telemetry never breaks a request and next() runs exactly once.
+ */
+function createTelemetryMiddleware(collector, { routeLabel = defaultRouteLabel, skipped = telemetrySkipped } = {}) {
+ return function telemetryMiddleware(req, res, next) {
+ if (skipped(req)) return next();
+ try {
+ collector.requestStarted();
+ const t0 = process.hrtime.bigint();
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ try {
+ const latencyMs = Number(process.hrtime.bigint() - t0) / 1e6;
+ const httpStatus = res.writableFinished || res.finished ? (res.statusCode || 0) : 0;
+ collector.requestFinished({ route: routeLabel(req), method: req.method, httpStatus, latencyMs });
+ } catch { /* telemetry never breaks a request */ }
+ };
+ res.once('finish', finish);
+ res.once('close', finish);
+ } catch { /* telemetry never breaks a request */ }
+ return next();
+ };
+}
+
+// ── the process-wide collector (Network's shape: init/stop across the process, nothing at require time) ──
+let current = null;
+let currentMiddleware = null;
+let timer = null;
+let activeSignals = signals;
+
+/**
+ * init({ service, sink, intervalMs?, routeLabel?, skipPrefixes?, signals?, … createHttpTelemetry options })
+ * -> the collector. Starts the event-loop monitor (the injected signals, else the module's) and the flush timer
+ * (unref'd). Call once at boot; a second call stops the previous collector first (its interval is emitted and
+ * flushed, its timer cleared) and replaces it.
+ */
+function init(opts = {}) {
+ if (timer) { clearInterval(timer); timer = null; }
+ const previous = current;
+ current = null;
+ currentMiddleware = null;
+ if (previous) previous.stop(); // flush the replaced collector's interval; its timer is stopped too
+ current = createHttpTelemetry(opts);
+ currentMiddleware = createTelemetryMiddleware(current, opts);
+ activeSignals = opts.signals ? { ...signals, ...opts.signals } : signals;
+ activeSignals.start();
+ const interval = Number.isFinite(opts.intervalMs) ? opts.intervalMs : DEFAULT_INTERVAL_MS;
+ timer = setInterval(() => { current.flush(); }, interval);
+ if (timer.unref) timer.unref();
+ return current;
+}
+
+function record(...args) { return current ? current.record(...args) : undefined; }
+function gauge(...args) { return current ? current.gauge(...args) : undefined; }
+function count(...args) { return current ? current.count(...args) : undefined; }
+function requestStarted() { return current ? current.requestStarted() : undefined; }
+function requestFinished(info) { return current ? current.requestFinished(info) : undefined; }
+function flush() { return current ? current.flush() : Promise.resolve(); }
+
+/** Stop the flush timer and the event-loop monitor, and flush the collector once (a gracefulStop stop step;
+ * a second stop changes nothing). After it, the record/… functions are no-ops until the next init. */
+async function stop() {
+ if (timer) { clearInterval(timer); timer = null; }
+ const c = current;
+ current = null;
+ currentMiddleware = null;
+ if (c) await c.stop();
+ activeSignals.stop();
+ activeSignals = signals;
+}
+
+/** The singleton's middleware: mounted before any route, a no-op until init (Network's ordering). */
+function telemetryMiddleware(req, res, next) {
+ if (!currentMiddleware) return next();
+ return currentMiddleware(req, res, next);
+}
+
+const telemetry = { init, record, gauge, count, requestStarted, requestFinished, flush, stop, middleware: telemetryMiddleware };
+
+module.exports = {
+ DEFAULT_INTERVAL_MS,
+ DEFAULT_MAX_ROUTE_KEYS,
+ LATENCY_SAMPLE_CAP,
+ DEFAULT_SKIP_PREFIXES,
+ HTTP_METHODS,
+ telemetrySample,
+ validateTelemetrySample,
+ telemetrySkipped,
+ defaultRouteLabel,
+ createHttpTelemetry,
+ createTelemetryMiddleware,
+ registerSignals,
+ startEventLoopMonitor,
+ stopEventLoopMonitor,
+ eventLoopMonitorEnabled,
+ eventLoopLagMs,
+ telemetry,
+ telemetryMiddleware,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,276 @@
@@ -0,0 +1,276 @@
+'use strict';
+/**
+ * openvibe-sdk/service telemetry: the per-request platform.telemetry-sample@1 middleware and the HTTP
+ * autoscaling signals (active requests, p95, event-loop lag), lifted from OpenVibe.Network. Requests are
+ * aggregated per route|method|status_class per flush; every emitted sample validates; one flush batches;
+ * stop() flushes once; the event-loop monitor starts in init and stops in stop.
+ */
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const { run } = require('./helpers');
+const svc = require('../src/service');
+
+const quiet = { log() {}, warn() {}, error() {} };
+const noSignals = { start() {}, stop() {}, lag() { return null; } };
+let contracts = null;
+try { contracts = require('openvibe-contracts'); } catch { /* the validation assertions skip */ }
+
+/** A collector with a capturing sink, a manual-only interval and the loop monitor replaced. */
+function collector(opts = {}) {
+ const batches = [];
+ const c = svc.createHttpTelemetry({
+ service: 'demo', intervalMs: 3600e3, log: quiet, signals: noSignals,
+ sink: async (samples) => { batches.push(samples); }, ...opts,
+ });
+ return { c, batches, samples: () => batches.flat() };
+}
+
+function get(url) {
+ return new Promise((resolve, reject) => {
+ http.get(url, (res) => { res.resume(); res.on('end', () => resolve(res.statusCode)); res.on('error', reject); }).on('error', reject);
+ });
+}
+
+async function listen(handler) {
+ const server = http.createServer(handler);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { server, url: `http://127.0.0.1:${server.address().port}` };
+}
+
+run([
+ ['telemetrySkipped avoids probes and static assets, and honours prefixes and a custom skip', async () => {
+ const req = (url) => ({ url });
+ for (const p of ['/api/health', '/ready', '/api/ready', '/metrics', '/app.js', '/logo.PNG', '/x/y.css?cache=1']) {
+ assert.equal(svc.telemetrySkipped(req(p)), true, p);
+ }
+ assert.equal(svc.telemetrySkipped(req('/api/posts/1?draft=1')), false, 'a product route is observed, query ignored');
+ assert.equal(svc.telemetrySkipped(req('/release.json')), false, 'JSON API is not a static asset');
+ assert.equal(svc.telemetrySkipped({ path: '/shared/nav.js', url: '/shared/nav.js' }, { prefixes: ['/shared'] }), true);
+ assert.equal(svc.telemetrySkipped({ path: '/shared', url: '/shared' }, { prefixes: ['/shared'] }), true, 'the prefix itself');
+ assert.equal(svc.telemetrySkipped({ path: '/sharedness', url: '/sharedness' }, { prefixes: ['/shared'] }), false, 'not a path segment');
+ assert.equal(svc.telemetrySkipped(req('/api/x'), { skip: (r) => r.url === '/api/x' }), true);
+ }],
+
+ ['requests aggregate per route|method|status_class and every sample validates: one flush batches', async () => {
+ const { c, batches, samples } = collector({ signals: { start() {}, stop() {}, lag() { return 9.5; } } });
+ c.requestStarted();
+ c.requestFinished({ route: '/api/posts/:id', method: 'GET', httpStatus: 200, latencyMs: 12 });
+ c.requestFinished({ route: '/api/posts/:id', method: 'GET', httpStatus: 200, latencyMs: 40 });
+ c.requestFinished({ route: '/api/posts/:id', method: 'GET', httpStatus: 500, latencyMs: 30 });
+ c.requestFinished({ route: '/api/posts', method: 'POST', httpStatus: 201, latencyMs: 7 });
+ c.requestFinished({ route: undefined, method: 'GET', httpStatus: 404, latencyMs: 2 });
+ c.requestFinished({ route: '/stream', method: 'TRACE', httpStatus: 0, latencyMs: NaN });
+
+ await c.flush();
+ assert.equal(batches.length, 1, 'one flush is one sink call');
+ const requests = samples().filter((s) => s.operation === 'http.request');
+ assert.equal(requests.length, 5, 'route|method|status_class keys: two GET /:id by class, POST, unmatched, TRACE');
+ const byKey = Object.fromEntries(requests.map((s) => [`${s.resource}|${s.extra.method}|${s.extra.status_class}`, s]));
+ const ok = byKey['/api/posts/:id|GET|2xx'];
+ assert.deepEqual(ok.extra, { method: 'GET', status_class: '2xx', count: 2, sum_ms: 52, max_ms: 40, p95_ms: 40, http_status: 200 });
+ assert.equal(ok.latency_ms, 26, 'latency_ms is the mean');
+ assert.equal(ok.status, '2xx');
+ assert.deepEqual(byKey['/api/posts/:id|GET|5xx'].extra, { method: 'GET', status_class: '5xx', count: 1, sum_ms: 30, max_ms: 30, p95_ms: 30, http_status: 500 });
+ assert.deepEqual(byKey['/api/posts|POST|2xx'].extra.method, 'POST');
+ assert.deepEqual(byKey['unmatched|GET|4xx'].extra.http_status, 404);
+ assert.equal(byKey['/stream|OTHER|aborted'].extra.status_class, 'aborted', 'a non-method and an aborted status are labelled');
+
+ assert.equal(samples().find((s) => s.operation === 'http.active_requests').extra.last, 0, 'all requests ended');
+ assert.equal(samples().find((s) => s.operation === 'http.latency_p95_ms').latency_ms, 40);
+ assert.equal(samples().find((s) => s.operation === 'http.eventloop_lag_ms').extra.value, 9.5);
+ assert.ok(!('latency_ms' in samples().find((s) => s.operation === 'http.active_requests')), 'an extra gauge is never a latency');
+ if (contracts) for (const s of samples()) assert.deepEqual(svc.validateTelemetrySample(s), { ok: true, errors: [] }, s.operation);
+ else console.log('telemetry sample validation: skipped (openvibe-contracts not installed: npm install)');
+ }],
+
+ ['the gauges are emitted once per flush, from the timer not per request', async () => {
+ const { c, batches, samples } = collector({ signals: { start() {}, stop() {}, lag() { return null; } } });
+ c.requestStarted(); // one in flight when the interval drains: the peak is 1
+ c.requestFinished({ route: '/a', method: 'GET', httpStatus: 200, latencyMs: 5 });
+ c.requestStarted();
+ c.requestFinished({ route: '/a', method: 'GET', httpStatus: 200, latencyMs: 5 });
+ await c.flush();
+ assert.deepEqual(samples().find((s) => s.operation === 'http.active_requests').extra, { last: 0, max: 1 }, 'the interval peak, sampled at start');
+ assert.equal(samples().filter((s) => s.operation === 'http.active_requests').length, 1, 'one active gauge for the interval, not per request');
+ assert.equal(samples().filter((s) => s.operation === 'http.latency_p95_ms').length, 1);
+ assert.equal(samples().filter((s) => s.operation === 'http.eventloop_lag_ms').length, 0, 'no lag reading: no gauge');
+
+ c.requestStarted(); // still in flight at the next flush: activeMax starts from it
+ await c.flush();
+ assert.deepEqual(samples().filter((s) => s.operation === 'http.active_requests').at(-1).extra, { last: 1, max: 1 });
+ assert.equal(samples().filter((s) => s.operation === 'http.latency_p95_ms').length, 1, 'no latencies this interval: no p95 gauge');
+ }],
+
+ ['stop() flushes once, is idempotent, and flush() after stop changes nothing', async () => {
+ const { c, batches, samples } = collector();
+ c.requestStarted();
+ c.requestFinished({ route: '/a', method: 'GET', httpStatus: 200, latencyMs: 5 });
+ await c.stop();
+ assert.equal(batches.length, 1, 'stop flushes once');
+ assert.equal(samples().filter((s) => s.operation === 'http.request').length, 1, 'the interval was emitted before the flush');
+ assert.equal(samples().find((s) => s.operation === 'http.active_requests').extra.last, 0);
+ await c.stop();
+ await c.flush();
+ assert.equal(batches.length, 1, 'a second stop/flush is a no-op');
+ assert.equal(samples().length, 3, 'http.request + active + p95 (no lag signal)');
+ }],
+
+ ['the middleware observes a real request by route template, skips health, and next() runs once', async () => {
+ const { c, samples } = collector();
+ const nextCalls = [];
+ const mw = c.middleware();
+ const { server, url } = await listen((req, res) => {
+ if (req.url.startsWith('/api/posts/')) req.route = { path: '/api/posts/:id' };
+ mw(req, res, () => { nextCalls.push(req.url); res.end('ok'); });
+ });
+ try {
+ assert.equal(await get(`${url}/api/posts/1`), 200);
+ assert.equal(await get(`${url}/api/posts/2`), 200);
+ assert.equal(await get(`${url}/api/health`), 200);
+ assert.equal(await get(`${url}/missing`), 200); // the stub answers 200 for any path
+ await c.flush();
+ const requests = samples().filter((s) => s.operation === 'http.request');
+ const posts = requests.find((s) => s.resource === '/api/posts/:id');
+ assert.equal(posts.extra.count, 2, 'the route template aggregates both ids');
+ assert.deepEqual([posts.extra.method, posts.extra.status_class, posts.extra.http_status], ['GET', '2xx', 200]);
+ assert.ok(!requests.some((s) => s.resource === '/api/health'), 'health was skipped before counting');
+ assert.ok(nextCalls.includes('/api/health'), 'a skipped request still reached the app');
+ assert.equal(nextCalls.length, 4, 'next() ran once per request');
+ } finally {
+ server.close();
+ }
+ }],
+
+ ['registerSignals injects the lag a default collector reads (the monitor is not started by require)', async () => {
+ assert.equal(svc.eventLoopMonitorEnabled(), false, 'requiring the kit starts no monitor');
+ svc.registerSignals({ lag: () => 4.25 });
+ try {
+ const { c, samples } = collector({ signals: undefined }); // no injection: the module signals
+ await c.flush();
+ assert.equal(samples().find((s) => s.operation === 'http.eventloop_lag_ms').extra.value, 4.25);
+ } finally {
+ svc.registerSignals({ start: svc.startEventLoopMonitor, stop: svc.stopEventLoopMonitor, lag: svc.eventLoopLagMs });
+ }
+ }],
+
+ ['the singleton: init starts the loop monitor and timer, stop stops both and flushes once', async () => {
+ const batches = [];
+ const fakeRes = { finished: true, statusCode: 204, once() {} };
+ let next = 0;
+ svc.telemetryMiddleware({ url: '/x' }, fakeRes, () => { next += 1; }); // before init: a no-op
+ assert.equal(next, 1);
+
+ const c = svc.telemetry.init({ service: 'demo', sink: async (s) => { batches.push(s); }, intervalMs: 3600e3, log: quiet });
+ assert.equal(typeof c.stop, 'function');
+ assert.equal(svc.eventLoopMonitorEnabled(), true, 'init starts the monitor');
+ svc.telemetry.record('boot', 1);
+ svc.telemetry.requestFinished({ route: '/x', method: 'GET', httpStatus: 204, latencyMs: 3 });
+ assert.equal(await svc.telemetry.flush(), undefined);
+ const emitted = batches.flat();
+ assert.ok(emitted.some((s) => s.operation === 'boot'));
+ assert.ok(emitted.some((s) => s.operation === 'http.request' && s.resource === '/x'));
+
+ await svc.telemetry.stop();
+ assert.equal(svc.eventLoopMonitorEnabled(), false, 'stop stops the monitor');
+ assert.equal(batches.length, 2, 'init flush + stop flush');
+ assert.equal(svc.telemetry.record('after', 1), undefined, 'after stop the singleton is a no-op');
+ await svc.telemetry.stop(); // idempotent
+ assert.equal(batches.length, 2);
+ }],
+
+ ['defaultRouteLabel is the matched template or unmatched, never a raw path', async () => {
+ assert.equal(svc.defaultRouteLabel({ route: { path: '/api/posts/:id' }, baseUrl: '/v1', path: '/v1/api/posts/7' }), '/v1/api/posts/:id');
+ assert.equal(svc.defaultRouteLabel({ route: { path: '/' }, baseUrl: '' }), '/');
+ assert.equal(svc.defaultRouteLabel({ path: '/etc/passwd', url: '/etc/passwd' }), 'unmatched', 'a request with no matched route is never labelled by its path');
+ assert.equal(svc.defaultRouteLabel({ url: '/wp-login.php?x=1' }), 'unmatched', 'a scanner path is not a label');
+ assert.equal(svc.defaultRouteLabel(null), 'unmatched');
+ }],
+
+ ['1000 distinct 404 paths produce one unmatched key', async () => {
+ const { c, samples } = collector();
+ for (let i = 0; i < 1000; i += 1) {
+ const route = svc.defaultRouteLabel({ path: `/missing/${i}`, url: `/missing/${i}?q=${i}` });
+ c.requestFinished({ route, method: 'GET', httpStatus: 404, latencyMs: 1 });
+ }
+ await c.flush();
+ const requests = samples().filter((s) => s.operation === 'http.request');
+ assert.equal(requests.length, 1, 'all unmatched paths share one key');
+ assert.equal(requests[0].resource, 'unmatched');
+ assert.equal(requests[0].extra.status_class, '4xx');
+ assert.equal(requests[0].extra.count, 1000, 'the count stays exact across the shared key');
+ }],
+
+ ['distinct matched templates past the per-flush cap fold into the other key', async () => {
+ const { c, samples } = collector({ maxRouteKeys: 3 });
+ for (let i = 0; i < 6; i += 1) c.requestFinished({ route: `/api/r${i}/:id`, method: 'GET', httpStatus: 200, latencyMs: 1 });
+ await c.flush();
+ const requests = samples().filter((s) => s.operation === 'http.request');
+ assert.equal(requests.length, 4, 'the cap of distinct templates plus the one overflow bucket');
+ assert.equal(requests.filter((s) => s.resource !== 'other').length, 3, 'the first maxRouteKeys templates keep their labels');
+ const other = requests.find((s) => s.resource === 'other');
+ assert.equal(other.extra.count, 3, 'every later template folds into other');
+ }],
+
+ ['skipPrefixes defaults to /shared and is overridable', async () => {
+ assert.equal(svc.telemetrySkipped({ path: '/shared/data', url: '/shared/data' }), true, 'the default prefix');
+ assert.equal(svc.telemetrySkipped({ path: '/shared', url: '/shared' }), true, 'the prefix itself');
+ assert.equal(svc.telemetrySkipped({ path: '/sharedness', url: '/sharedness' }), false, 'not a path segment');
+ assert.equal(svc.telemetrySkipped({ path: '/shared/data', url: '/shared/data' }, { prefixes: [] }), false, 'overridable with []');
+ assert.equal(svc.telemetrySkipped({ path: '/api/chrome/x', url: '/api/chrome/x' }, { prefixes: ['/shared', '/api/chrome'] }), true, "Network's two prefixes");
+ assert.equal(svc.telemetrySkipped({ path: '/api/chrome/x', url: '/api/chrome/x' }), false, 'only /shared is the SDK default');
+
+ const { c, samples } = collector();
+ const mw = c.middleware();
+ const { server, url } = await listen((req, res) => { mw(req, res, () => res.end('ok')); });
+ try {
+ assert.equal(await get(`${url}/shared/data`), 200);
+ assert.equal(await get(`${url}/api/posts`), 200);
+ await c.flush();
+ assert.ok(!samples().some((s) => typeof s.resource === 'string' && s.resource.startsWith('/shared')), 'a default collector skips /shared before counting');
+ assert.ok(samples().some((s) => s.resource === 'unmatched'), 'a non-skipped unmatched request is still counted');
+ } finally {
+ server.close();
+ }
+ }],
+
+ ['50k requests keep the latency buffers at the cap with the count exact', async () => {
+ const { c, samples } = collector();
+ for (let i = 0; i < 50000; i += 1) c.requestFinished({ route: '/api/x', method: 'GET', httpStatus: 200, latencyMs: (i % 100) + 1 });
+ const stats = c.bufferStats();
+ assert.deepEqual(stats, { keys: 1, latencies: svc.LATENCY_SAMPLE_CAP, maxKeyLatencies: svc.LATENCY_SAMPLE_CAP }, 'arrays capped at 10k');
+ await c.flush();
+ const s = samples().find((x) => x.operation === 'http.request');
+ assert.equal(s.extra.count, 50000, 'the count is exact');
+ assert.equal(s.extra.max_ms, 100, 'the max is exact');
+ assert.equal(s.extra.sum_ms, 2525000, 'the sum is exact (1..100, 500 times each)');
+ assert.equal(s.latency_ms, 50.5, 'the mean is exact');
+ assert.ok(s.extra.p95_ms >= 1 && s.extra.p95_ms <= 100, 'the p95 comes from the bounded sample');
+ assert.deepEqual(c.bufferStats(), { keys: 0, latencies: 0, maxKeyLatencies: 0 }, 'a flush resets the buffers');
+ }],
+
+ ['init stops the previous collector: its interval is flushed and its timer replaced', async () => {
+ const first = [];
+ const second = [];
+ const sinkOf = (batches) => async (s) => { batches.push(s); };
+ const c1 = svc.telemetry.init({ service: 'demo', sink: sinkOf(first), intervalMs: 3600e3, log: quiet, signals: noSignals });
+ svc.telemetry.requestFinished({ route: '/old', method: 'GET', httpStatus: 200, latencyMs: 4 });
+ const c2 = svc.telemetry.init({ service: 'demo', sink: sinkOf(second), intervalMs: 3600e3, log: quiet, signals: noSignals });
+ assert.notEqual(c2, c1);
+ await new Promise((r) => setTimeout(r, 20)); // previous.stop() flushes asynchronously
+ assert.ok(first.flat().some((s) => s.operation === 'http.request' && s.resource === '/old'), 'the replaced interval was flushed to the old sink');
+ assert.equal(second.flat().length, 0, 'the new sink saw nothing yet');
+
+ svc.telemetry.requestFinished({ route: '/new', method: 'GET', httpStatus: 200, latencyMs: 2 });
+ await svc.telemetry.flush();
+ assert.ok(second.flat().some((s) => s.operation === 'http.request' && s.resource === '/new'), 'the singleton routes into the new collector');
+ assert.ok(!second.flat().some((s) => s.resource === '/old'), 'the old interval did not leak into the new collector');
+ await svc.telemetry.stop();
+ }],
+
+ ['DEFAULT_INTERVAL_MS is the 15 s flush interval', async () => {
+ assert.equal(svc.DEFAULT_INTERVAL_MS, 15000);
+ assert.equal(svc.DEFAULT_MAX_ROUTE_KEYS, 500);
+ assert.equal(svc.LATENCY_SAMPLE_CAP, 10000);
+ assert.deepEqual(svc.DEFAULT_SKIP_PREFIXES, ['/shared']);
+ }],
+]);
@@ -117,6 +117,139 @@ export type JsonErrors = [(req: Req, res: Res) => unknown, (err: unknown, req: R
@@ -117,6 +117,139 @@ export type JsonErrors = [(req: Req, res: Res) => unknown, (err: unknown, req: R
};
};
export declare function jsonErrors(options?: JsonErrorsOptions): JsonErrors;
export declare function jsonErrors(options?: JsonErrorsOptions): JsonErrors;
+// ── telemetry: platform.telemetry-sample@1 at the request boundary ──
+
+/** A platform.telemetry-sample@1 record. The schema allows only these fields; `extra` holds scalars. */
+export interface TelemetrySample {
+ service?: string;
+ project?: string;
+ subject?: string;
+ resource?: string;
+ provider?: string;
+ node?: string;
+ cell?: string;
+ region?: string;
+ operation?: string;
+ at?: string;
+ latency_ms?: number;
+ queue_delay_ms?: number;
+ ttfb_ms?: number;
+ throughput_per_second?: number;
+ bytes?: number;
+ status?: string;
+ cache_status?: string;
+ cost_estimate?: number;
+ route_epoch?: number;
+ trace_id?: string;
+ extra?: Record<string, string | number | boolean>;
+}
+
+/** Build a record in the schema's field order; `at` defaults to now (ISO) and null/undefined are stripped. */
+export declare function telemetrySample(fields: Partial<TelemetrySample>): TelemetrySample;
+/** `{ ok, errors }` against platform.telemetry-sample@1 (openvibe-contracts, required lazily; a missing
+ * package is never a claim of validity: `ok: false`, `errors: []`). */
+export declare function validateTelemetrySample(record: unknown): { ok: boolean; errors: unknown[] };
+
+/** start/stop/lag; the defaults are the SDK's event-loop monitor (started by init, stopped by stop). */
+export interface TelemetrySignals {
+ start(): void;
+ stop(): void;
+ lag(): number | null;
+}
+
+export interface HttpTelemetryOptions {
+ /** The schema's `service` — the only process identity (there is no `instance` field). */
+ service: string;
+ /** Receives one batch per flush: `await sink(samples)`. */
+ sink: (samples: TelemetrySample[]) => unknown;
+ /** The flush interval; also the rolling p95 window. Default 15000. */
+ intervalMs?: number;
+ now?: () => number;
+ log?: Logger;
+ maxBuffered?: number;
+ /** The route template for a request. Default: Express's `req.route.path` under `req.baseUrl`, else `'unmatched'`
+ * (a raw path is never used as a label). */
+ routeLabel?: (req: Req) => string;
+ /** Replaces telemetrySkipped entirely. */
+ skipped?: (req: Req) => boolean;
+ /** Paths never observed. Default: /api/health, /ready, /api/ready, /metrics. */
+ skipExact?: Set<string> | string[];
+ /** Prefixes never observed (a path segment). Default `['/shared']`; OpenVibe.Network passes
+ * `['/shared', '/api/chrome']`. Pass `[]` to disable. */
+ skipPrefixes?: string[];
+ /** An extra always-skip predicate. */
+ skip?: (req: Req) => boolean;
+ /** Distinct route|method|status_class keys kept per flush; further keys fold into the `'other'` route label.
+ * Default 500. */
+ maxRouteKeys?: number;
+ /** Override the module's signals for this collector (tests; a service with its own monitor). */
+ signals?: Partial<TelemetrySignals>;
+}
+
+export interface RequestObservation {
+ route?: string;
+ method?: string;
+ httpStatus?: number;
+ latencyMs?: number;
+}
+
+export interface HttpTelemetry {
+ /** A number becomes `latency_ms`; any other value becomes `extra[name]` (a scalar dimension). */
+ record(name: string, value: unknown, labels?: Record<string, unknown>): TelemetrySample;
+ gauge(name: string, value: number, labels?: Record<string, unknown>): TelemetrySample;
+ count(name: string, labels?: Record<string, unknown>): TelemetrySample;
+ /** Emit the interval's aggregation, then hand the SDK everything buffered. */
+ flush(): Promise<void>;
+ /** Emit once, clear the timer and flush (idempotent; a gracefulStop stop step). */
+ stop(): Promise<void>;
+ /** A request began: the in-flight peak is sampled here. */
+ requestStarted(): void;
+ /** A request ended: free the in-flight slot and fold it by route|method|status_class. */
+ requestFinished(info: RequestObservation): void;
+ observeRequest(info?: RequestObservation): void;
+ /** The live buffer sizes (diagnostics/tests): aggregation keys and sampled latencies held per flush. */
+ bufferStats(): { keys: number; latencies: number; maxKeyLatencies: number };
+ routeLabel(req: Req): string;
+ skipped(req: Req): boolean;
+ /** The per-request middleware for this collector. */
+ middleware(options?: { routeLabel?: (req: Req) => string; skipped?: (req: Req) => boolean }): (req: Req, res: Res, next: Next) => void;
+}
+
+export declare function createHttpTelemetry(options: HttpTelemetryOptions): HttpTelemetry;
+/** The middleware for a collector; telemetry never breaks a request and next() runs exactly once. */
+export declare function createTelemetryMiddleware(collector: HttpTelemetry, options?: { routeLabel?: (req: Req) => string; skipped?: (req: Req) => boolean }): (req: Req, res: Res, next: Next) => void;
+/** Whether a request carries no product signal (probe paths, configured prefixes, static assets). */
+export declare function telemetrySkipped(req: Req, options?: { exact?: Set<string> | string[]; prefixes?: string[]; skip?: (req: Req) => boolean }): boolean;
+/** The route label without openvibe-shared/metrics: the matched route template, else `'unmatched'`. */
+export declare function defaultRouteLabel(req: Req): string;
+/** Merge signals over the current defaults (a service or test injecting start/stop/lag). */
+export declare function registerSignals(injected?: Partial<TelemetrySignals>): void;
+export declare function startEventLoopMonitor(): void;
+export declare function stopEventLoopMonitor(): void;
+/** Whether the monitor is running; requiring the kit starts nothing. */
+export declare function eventLoopMonitorEnabled(): boolean;
+/** The event-loop lag since the last read, ms (the monitor resets per read); null before a sample. */
+export declare function eventLoopLagMs(): number | null;
+/** The singleton's middleware; a no-op until `telemetry.init()`. */
+export declare const telemetryMiddleware: (req: Req, res: Res, next: Next) => void;
+/** The process-wide collector: init at boot, stop on shutdown, nothing at require time. */
+export declare const telemetry: {
+ init(options: HttpTelemetryOptions): HttpTelemetry;
+ record: HttpTelemetry['record'];
+ gauge: HttpTelemetry['gauge'];
+ count: HttpTelemetry['count'];
+ requestStarted: HttpTelemetry['requestStarted'];
+ requestFinished: HttpTelemetry['requestFinished'];
+ flush: HttpTelemetry['flush'];
+ stop: HttpTelemetry['stop'];
+ middleware: (req: Req, res: Res, next: Next) => void;
+};
+export declare const DEFAULT_INTERVAL_MS: 15000;
+export declare const DEFAULT_MAX_ROUTE_KEYS: 500;
+export declare const LATENCY_SAMPLE_CAP: 10000;
+export declare const DEFAULT_SKIP_PREFIXES: string[];
+export declare const HTTP_METHODS: Set<string>;
+
// ── re-exported from openvibe-shared and openvibe-contracts (loaded on first use) ──
// ── re-exported from openvibe-shared and openvibe-contracts (loaded on first use) ──
type Fn = (...args: any[]) => any;
type Fn = (...args: any[]) => any;