openvibers/openvibe.sdk Commits
Add startTelemetryRetention: prune soon after start, then daily (#44)
Changes
@@@ -3,6 +3,14 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.46.0 (2026-10-13)++++- `startTelemetryRetention({ db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log? })` → `{ run(), stop() }`:++ the scheduled form of `pruneTelemetry()`. It first prunes `delayMs` after the call, then every `everyMs`, on unref'd++ timers. A service that waited a full day for the first prune never pruned when it was redeployed more often than that++ (found in review of OpenVibe.Blog#42). A failing prune is logged and retried at the next tick; runs never overlap;++ `stop()` cancels the timers and waits for a run in flight.++## 0.45.0 (2026-10-13)The telemetry sink every service stores into (plan T1, step 10). Step 5 left `createTelemetry({ sink })` and@@@ -187,7 +187,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` || `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it || `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit`, `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |--| `openvibe-sdk/telemetry` | server | `telemetrySample(fields)` / `validateTelemetrySample(record)` for `platform.telemetry-sample@1`; `createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000})` → `{record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}`: buffered samples flushed to `await sink(samples)` on an unref'd interval and on `stop()` (a `gracefulStop` stop step); `record` builds `{service, instance, operation, at, latency_ms, ...labels}` (a non-number value goes to `extra[name]`); a rejecting sink is logged once and its samples kept; past `maxBuffered` the oldest sample is dropped, with a warning at most once a minute; the per-service PostgreSQL sink: `telemetrySchema(table = 'telemetry_samples')` (migration DDL), `createPgTelemetrySink({db, table?, log?})` (one multi-row INSERT per flush, malformed samples dropped with a warning), `pruneTelemetry({db, table?, olderThanDays = 30})` |++| `openvibe-sdk/telemetry` | server | `telemetrySample(fields)` / `validateTelemetrySample(record)` for `platform.telemetry-sample@1`; `createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000})` → `{record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}`: buffered samples flushed to `await sink(samples)` on an unref'd interval and on `stop()` (a `gracefulStop` stop step); `record` builds `{service, instance, operation, at, latency_ms, ...labels}` (a non-number value goes to `extra[name]`); a rejecting sink is logged once and its samples kept; past `maxBuffered` the oldest sample is dropped, with a warning at most once a minute; the per-service PostgreSQL sink: `telemetrySchema(table = 'telemetry_samples')` (migration DDL), `createPgTelemetrySink({db, table?, log?})` (one multi-row INSERT per flush, malformed samples dropped with a warning), `pruneTelemetry({db, table?, olderThanDays = 30})`, `startTelemetryRetention({db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log?})` → `{run(), stop()}` (prunes soon after start and then daily on unref'd timers, so a service redeployed more than daily still prunes) || `openvibe-sdk/cost` | server | Build and validate `platform.rate-card@1` and `platform.cost-snapshot@1` records with `rateCard()`, `costSnapshot()`, `validateRateCard()`, and `validateCostSnapshot()`; `estimate()` calculates a charge from a rate card || `openvibe-sdk/runtime` | server | Build and validate `platform.runtime-offer@1` records with `runtimeOffer()` and `validateRuntimeOffer()`; `runtimeClass()`, `RUNTIME_CLASSES`, and `validateRuntimeClass()` handle known runtime classes || `openvibe-sdk/storage` | server | `storageOffer()` and `validateStorageOffer()` for `platform.storage-offer@1` |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.45.0",++ "version": "0.46.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",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.45.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.46.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.45.0';++const SDK_VERSION = '0.46.0';module.exports = {createClient,@@@ -212,12 +212,13 @@ time). `registerSignals({ start, stop, lag })` injects a service's own monitor ia consumer that needs the fleet view reads the tables (or a later relay copies them) without changing any emitter.```js--const { telemetrySchema, createPgTelemetrySink, pruneTelemetry } = require('openvibe-sdk/telemetry');++const { telemetrySchema, createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');// migrations/NNN_telemetry.sql: the text of telemetrySchema() (21 columns in platform.telemetry-sample@1 order,// no `instance`, `extra` jsonb, indexes on (at) and (service, operation, at)); the default table is telemetry_samples.const collector = svc.createHttpTelemetry({ service: 'blog', sink: createPgTelemetrySink({ db, log }) });svc.gracefulStop({ name: 'Blog', server, stop: [() => collector.stop()] }); // the last flush is one INSERT--await pruneTelemetry({ db }); // a daily job: DELETE … WHERE at < now() - 30 days (olderThanDays to change it)++const retention = startTelemetryRetention({ db, log }); // DELETE … WHERE at < now() - 30 days: 60 s after start, then daily++// add `() => retention.stop()` to the stop steps; pruneTelemetry({ db }) is the one-off delete it calls```The sink does one multi-row parameterized INSERT per flush (a second only past 3120 rows, PostgreSQL's 65535@@@ -3,5 +3,6 @@ import cjs from '../src/telemetry.js';export const {telemetrySample, validateTelemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry,++ startTelemetryRetention,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.45.0",++ "version": "0.46.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.45.0",++ "version": "0.46.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.45.0",++ "version": "0.46.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.45.0';++const SDK_VERSION = '0.46.0';module.exports = {createClient,@@@ -12,7 +12,7 @@* stop: [() => t.stop()], // a gracefulStop stop step: one flush** Where samples land: each service's own PostgreSQL table (telemetrySchema() in a migration, createPgTelemetrySink()-- * as the sink, pruneTelemetry() for retention), not a central ingest; docs/service.md says why.++ * as the sink, pruneTelemetry() for retention, startTelemetryRetention() to schedule it), not a central ingest; docs/service.md says why.*//** The fields platform.telemetry-sample@1 requires or allows, in the schema's order; null/undefined are stripped. */@@@ -236,4 +236,34 @@ async function pruneTelemetry({ db, table = 'telemetry_samples', olderThanDays =return r.rowCount;}--module.exports = { telemetrySample, validateTelemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry };++/** Scheduled retention: runs pruneTelemetry() `delayMs` after the call (default 60 s) and then every `everyMs`++ * (default 24 h), both on unref'd timers. The first run is soon, not a day out, so a service that is redeployed++ * more often than once a day still prunes. A failing prune is logged and tried again at the next tick; runs never++ * overlap. `run()` prunes now; `stop()` cancels the timers. */++function startTelemetryRetention({ db, table = 'telemetry_samples', olderThanDays = 30, everyMs = 86400e3, delayMs = 60e3, log = console } = {}) {++ // pruneTelemetry validates db, table and olderThanDays; fail here, not inside a timer++ if (!db || typeof db.query !== 'function') throw new TypeError('startTelemetryRetention: an openvibe-sdk/db handle is required');++ checkTable(table, 'startTelemetryRetention');++ if (!(Number.isFinite(olderThanDays) && olderThanDays > 0)) throw new TypeError('startTelemetryRetention: olderThanDays must be a positive number');++ if (!(Number.isFinite(everyMs) && everyMs > 0)) throw new TypeError('startTelemetryRetention: everyMs must be a positive number');++ if (!(Number.isFinite(delayMs) && delayMs >= 0)) throw new TypeError('startTelemetryRetention: delayMs must be a non-negative number');++ let running = null;++ let stopped = false;++ const run = () => {++ if (running) return running;++ running = pruneTelemetry({ db, table, olderThanDays })++ .catch((err) => { log.error(`[telemetry] ${table}: retention prune failed: ${err && err.message}`); return 0; })++ .finally(() => { running = null; });++ return running;++ };++ const first = setTimeout(() => { if (!stopped) run(); }, delayMs);++ const every = setInterval(() => { if (!stopped) run(); }, everyMs);++ if (first.unref) first.unref();++ if (every.unref) every.unref();++ return {++ run,++ async stop() { stopped = true; clearTimeout(first); clearInterval(every); if (running) await running; },++ };++}++++module.exports = { telemetrySample, validateTelemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry, startTelemetryRetention };@@@ -7,7 +7,7 @@const assert = require('node:assert/strict');const { run } = require('./helpers');const { createDb } = require('../src/db');--const { telemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry } = require('../src/telemetry');++const { telemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry, startTelemetryRetention } = require('../src/telemetry');const COLUMNS = ['id', 'at', 'service', 'project', 'subject', 'resource', 'provider', 'node', 'cell', 'region', 'operation','latency_ms', 'queue_delay_ms', 'ttfb_ms', 'throughput_per_second', 'bytes', 'status', 'cache_status',@@@ -138,4 +138,28 @@ run([await assert.rejects(pruneTelemetry({ db, olderThanDays: 0 }), /olderThanDays/);} finally { await db.close(); }}],++++ ['startTelemetryRetention prunes soon after start (not a day later), then again, logs a failure and stops', async () => {++ const db = await open();++ try {++ const old = new Date(Date.now() - 40 * 86400e3).toISOString();++ await createPgTelemetrySink({ db, log: logger() })([{ service: 'blog', operation: 'old', at: old }, { service: 'blog', operation: 'new', at: new Date().toISOString() }]);++ const errors = [];++ const r = startTelemetryRetention({ db, delayMs: 20, everyMs: 60, log: { error: (m) => errors.push(String(m)) } });++ await new Promise((ok) => setTimeout(ok, 250));++ assert.deepEqual((await db.many('SELECT operation FROM telemetry_samples')).map((x) => x.operation), ['new']);++ assert.equal(errors.length, 0);++ assert.equal(await r.run(), 0);++ await r.stop();++ await createPgTelemetrySink({ db, log: logger() })([{ service: 'blog', operation: 'old2', at: old }]);++ await new Promise((ok) => setTimeout(ok, 150));++ assert.equal(await db.value('SELECT count(*)::int FROM telemetry_samples'), 2, 'nothing prunes after stop()');++ const bad = startTelemetryRetention({ db: { query: async () => { throw new Error('db down'); } }, delayMs: 5, everyMs: 1000, log: { error: (m) => errors.push(String(m)) } });++ assert.equal(await bad.run(), 0);++ await bad.stop();++ assert.match(errors[0], /retention prune failed: db down/);++ assert.throws(() => startTelemetryRetention({ db, everyMs: 0 }), /everyMs/);++ assert.throws(() => startTelemetryRetention({ db, table: 'x; drop' }), /bad telemetry table name/);++ } finally { await db.close(); }++ }],]);@@@ -40,3 +40,9 @@ export declare function telemetrySchema(table?: string): string;export declare function createPgTelemetrySink(opts: { db: TelemetryDb; table?: string; log?: { warn(...a: unknown[]): void } }): (samples: TelemetrySample[]) => Promise<void>;/** Retention: deletes rows whose `at` is older than olderThanDays (default 30); resolves with the count. */export declare function pruneTelemetry(opts: { db: TelemetryDb; table?: string; olderThanDays?: number }): Promise<number>;++/** Scheduled retention: pruneTelemetry() delayMs after the call (default 60000) and then every everyMs (default++ * 86400000), on unref'd timers, so a service redeployed more than daily still prunes. A failing prune is logged++ * and retried at the next tick; runs never overlap. run() prunes now (resolves with the count, 0 on failure);++ * stop() cancels the timers and waits for a run in flight. */++export declare function startTelemetryRetention(opts: { db: TelemetryDb; table?: string; olderThanDays?: number; everyMs?: number; delayMs?: number;++ log?: { error(...a: unknown[]): void } }): { run(): Promise<number>; stop(): Promise<void> };
@@ -3,6 +3,14 @@
@@ -3,6 +3,14 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.46.0 (2026-10-13)
+
+- `startTelemetryRetention({ db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log? })` → `{ run(), stop() }`:
+ the scheduled form of `pruneTelemetry()`. It first prunes `delayMs` after the call, then every `everyMs`, on unref'd
+ timers. A service that waited a full day for the first prune never pruned when it was redeployed more often than that
+ (found in review of OpenVibe.Blog#42). A failing prune is logged and retried at the next tick; runs never overlap;
+ `stop()` cancels the timers and waits for a run in flight.
+
## 0.45.0 (2026-10-13)
## 0.45.0 (2026-10-13)
The telemetry sink every service stores into (plan T1, step 10). Step 5 left `createTelemetry({ sink })` and
The telemetry sink every service stores into (plan T1, step 10). Step 5 left `createTelemetry({ sink })` and
@@ -187,7 +187,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -187,7 +187,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit`, `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |
| `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit`, `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |
-| `openvibe-sdk/telemetry` | server | `telemetrySample(fields)` / `validateTelemetrySample(record)` for `platform.telemetry-sample@1`; `createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000})` → `{record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}`: buffered samples flushed to `await sink(samples)` on an unref'd interval and on `stop()` (a `gracefulStop` stop step); `record` builds `{service, instance, operation, at, latency_ms, ...labels}` (a non-number value goes to `extra[name]`); a rejecting sink is logged once and its samples kept; past `maxBuffered` the oldest sample is dropped, with a warning at most once a minute; the per-service PostgreSQL sink: `telemetrySchema(table = 'telemetry_samples')` (migration DDL), `createPgTelemetrySink({db, table?, log?})` (one multi-row INSERT per flush, malformed samples dropped with a warning), `pruneTelemetry({db, table?, olderThanDays = 30})` |
+| `openvibe-sdk/telemetry` | server | `telemetrySample(fields)` / `validateTelemetrySample(record)` for `platform.telemetry-sample@1`; `createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000})` → `{record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}`: buffered samples flushed to `await sink(samples)` on an unref'd interval and on `stop()` (a `gracefulStop` stop step); `record` builds `{service, instance, operation, at, latency_ms, ...labels}` (a non-number value goes to `extra[name]`); a rejecting sink is logged once and its samples kept; past `maxBuffered` the oldest sample is dropped, with a warning at most once a minute; the per-service PostgreSQL sink: `telemetrySchema(table = 'telemetry_samples')` (migration DDL), `createPgTelemetrySink({db, table?, log?})` (one multi-row INSERT per flush, malformed samples dropped with a warning), `pruneTelemetry({db, table?, olderThanDays = 30})`, `startTelemetryRetention({db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log?})` → `{run(), stop()}` (prunes soon after start and then daily on unref'd timers, so a service redeployed more than daily still prunes) |
| `openvibe-sdk/cost` | server | Build and validate `platform.rate-card@1` and `platform.cost-snapshot@1` records with `rateCard()`, `costSnapshot()`, `validateRateCard()`, and `validateCostSnapshot()`; `estimate()` calculates a charge from a rate card |
| `openvibe-sdk/cost` | server | Build and validate `platform.rate-card@1` and `platform.cost-snapshot@1` records with `rateCard()`, `costSnapshot()`, `validateRateCard()`, and `validateCostSnapshot()`; `estimate()` calculates a charge from a rate card |
| `openvibe-sdk/runtime` | server | Build and validate `platform.runtime-offer@1` records with `runtimeOffer()` and `validateRuntimeOffer()`; `runtimeClass()`, `RUNTIME_CLASSES`, and `validateRuntimeClass()` handle known runtime classes |
| `openvibe-sdk/runtime` | server | Build and validate `platform.runtime-offer@1` records with `runtimeOffer()` and `validateRuntimeOffer()`; `runtimeClass()`, `RUNTIME_CLASSES`, and `validateRuntimeClass()` handle known runtime classes |
| `openvibe-sdk/storage` | server | `storageOffer()` and `validateStorageOffer()` for `platform.storage-offer@1` |
| `openvibe-sdk/storage` | server | `storageOffer()` and `validateStorageOffer()` for `platform.storage-offer@1` |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.45.0",
+ "version": "0.46.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",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.45.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.46.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.45.0';
+const SDK_VERSION = '0.46.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -212,12 +212,13 @@ time). `registerSignals({ start, stop, lag })` injects a service's own monitor i
@@ -212,12 +212,13 @@ time). `registerSignals({ start, stop, lag })` injects a service's own monitor i
a consumer that needs the fleet view reads the tables (or a later relay copies them) without changing any emitter.
a consumer that needs the fleet view reads the tables (or a later relay copies them) without changing any emitter.
```js
```js
-const { telemetrySchema, createPgTelemetrySink, pruneTelemetry } = require('openvibe-sdk/telemetry');
+const { telemetrySchema, createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
// migrations/NNN_telemetry.sql: the text of telemetrySchema() (21 columns in platform.telemetry-sample@1 order,
// migrations/NNN_telemetry.sql: the text of telemetrySchema() (21 columns in platform.telemetry-sample@1 order,
// no `instance`, `extra` jsonb, indexes on (at) and (service, operation, at)); the default table is telemetry_samples.
// no `instance`, `extra` jsonb, indexes on (at) and (service, operation, at)); the default table is telemetry_samples.
const collector = svc.createHttpTelemetry({ service: 'blog', sink: createPgTelemetrySink({ db, log }) });
const collector = svc.createHttpTelemetry({ service: 'blog', sink: createPgTelemetrySink({ db, log }) });
svc.gracefulStop({ name: 'Blog', server, stop: [() => collector.stop()] }); // the last flush is one INSERT
svc.gracefulStop({ name: 'Blog', server, stop: [() => collector.stop()] }); // the last flush is one INSERT
-await pruneTelemetry({ db }); // a daily job: DELETE … WHERE at < now() - 30 days (olderThanDays to change it)
+const retention = startTelemetryRetention({ db, log }); // DELETE … WHERE at < now() - 30 days: 60 s after start, then daily
+// add `() => retention.stop()` to the stop steps; pruneTelemetry({ db }) is the one-off delete it calls
```
```
The sink does one multi-row parameterized INSERT per flush (a second only past 3120 rows, PostgreSQL's 65535
The sink does one multi-row parameterized INSERT per flush (a second only past 3120 rows, PostgreSQL's 65535
@@ -3,5 +3,6 @@ import cjs from '../src/telemetry.js';
@@ -3,5 +3,6 @@ import cjs from '../src/telemetry.js';
export const {
export const {
telemetrySample, validateTelemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry,
telemetrySample, validateTelemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry,
+ startTelemetryRetention,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.45.0",
+ "version": "0.46.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.45.0",
+ "version": "0.46.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.45.0",
+ "version": "0.46.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.45.0';
+const SDK_VERSION = '0.46.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
* stop: [() => t.stop()], // a gracefulStop stop step: one flush
* stop: [() => t.stop()], // a gracefulStop stop step: one flush
*
*
* Where samples land: each service's own PostgreSQL table (telemetrySchema() in a migration, createPgTelemetrySink()
* Where samples land: each service's own PostgreSQL table (telemetrySchema() in a migration, createPgTelemetrySink()
- * as the sink, pruneTelemetry() for retention), not a central ingest; docs/service.md says why.
+ * as the sink, pruneTelemetry() for retention, startTelemetryRetention() to schedule it), not a central ingest; docs/service.md says why.
*/
*/
/** The fields platform.telemetry-sample@1 requires or allows, in the schema's order; null/undefined are stripped. */
/** The fields platform.telemetry-sample@1 requires or allows, in the schema's order; null/undefined are stripped. */
@@ -236,4 +236,34 @@ async function pruneTelemetry({ db, table = 'telemetry_samples', olderThanDays =
@@ -236,4 +236,34 @@ async function pruneTelemetry({ db, table = 'telemetry_samples', olderThanDays =
return r.rowCount;
return r.rowCount;
}
}
-module.exports = { telemetrySample, validateTelemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry };
+/** Scheduled retention: runs pruneTelemetry() `delayMs` after the call (default 60 s) and then every `everyMs`
+ * (default 24 h), both on unref'd timers. The first run is soon, not a day out, so a service that is redeployed
+ * more often than once a day still prunes. A failing prune is logged and tried again at the next tick; runs never
+ * overlap. `run()` prunes now; `stop()` cancels the timers. */
+function startTelemetryRetention({ db, table = 'telemetry_samples', olderThanDays = 30, everyMs = 86400e3, delayMs = 60e3, log = console } = {}) {
+ // pruneTelemetry validates db, table and olderThanDays; fail here, not inside a timer
+ if (!db || typeof db.query !== 'function') throw new TypeError('startTelemetryRetention: an openvibe-sdk/db handle is required');
+ checkTable(table, 'startTelemetryRetention');
+ if (!(Number.isFinite(olderThanDays) && olderThanDays > 0)) throw new TypeError('startTelemetryRetention: olderThanDays must be a positive number');
+ if (!(Number.isFinite(everyMs) && everyMs > 0)) throw new TypeError('startTelemetryRetention: everyMs must be a positive number');
+ if (!(Number.isFinite(delayMs) && delayMs >= 0)) throw new TypeError('startTelemetryRetention: delayMs must be a non-negative number');
+ let running = null;
+ let stopped = false;
+ const run = () => {
+ if (running) return running;
+ running = pruneTelemetry({ db, table, olderThanDays })
+ .catch((err) => { log.error(`[telemetry] ${table}: retention prune failed: ${err && err.message}`); return 0; })
+ .finally(() => { running = null; });
+ return running;
+ };
+ const first = setTimeout(() => { if (!stopped) run(); }, delayMs);
+ const every = setInterval(() => { if (!stopped) run(); }, everyMs);
+ if (first.unref) first.unref();
+ if (every.unref) every.unref();
+ return {
+ run,
+ async stop() { stopped = true; clearTimeout(first); clearInterval(every); if (running) await running; },
+ };
+}
+
+module.exports = { telemetrySample, validateTelemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry, startTelemetryRetention };
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const { run } = require('./helpers');
const { run } = require('./helpers');
const { createDb } = require('../src/db');
const { createDb } = require('../src/db');
-const { telemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry } = require('../src/telemetry');
+const { telemetrySample, createTelemetry, telemetrySchema, createPgTelemetrySink, pruneTelemetry, startTelemetryRetention } = require('../src/telemetry');
const COLUMNS = ['id', 'at', 'service', 'project', 'subject', 'resource', 'provider', 'node', 'cell', 'region', 'operation',
const COLUMNS = ['id', 'at', 'service', 'project', 'subject', 'resource', 'provider', 'node', 'cell', 'region', 'operation',
'latency_ms', 'queue_delay_ms', 'ttfb_ms', 'throughput_per_second', 'bytes', 'status', 'cache_status',
'latency_ms', 'queue_delay_ms', 'ttfb_ms', 'throughput_per_second', 'bytes', 'status', 'cache_status',
@@ -138,4 +138,28 @@ run([
@@ -138,4 +138,28 @@ run([
await assert.rejects(pruneTelemetry({ db, olderThanDays: 0 }), /olderThanDays/);
await assert.rejects(pruneTelemetry({ db, olderThanDays: 0 }), /olderThanDays/);
} finally { await db.close(); }
} finally { await db.close(); }
}],
}],
+
+ ['startTelemetryRetention prunes soon after start (not a day later), then again, logs a failure and stops', async () => {
+ const db = await open();
+ try {
+ const old = new Date(Date.now() - 40 * 86400e3).toISOString();
+ await createPgTelemetrySink({ db, log: logger() })([{ service: 'blog', operation: 'old', at: old }, { service: 'blog', operation: 'new', at: new Date().toISOString() }]);
+ const errors = [];
+ const r = startTelemetryRetention({ db, delayMs: 20, everyMs: 60, log: { error: (m) => errors.push(String(m)) } });
+ await new Promise((ok) => setTimeout(ok, 250));
+ assert.deepEqual((await db.many('SELECT operation FROM telemetry_samples')).map((x) => x.operation), ['new']);
+ assert.equal(errors.length, 0);
+ assert.equal(await r.run(), 0);
+ await r.stop();
+ await createPgTelemetrySink({ db, log: logger() })([{ service: 'blog', operation: 'old2', at: old }]);
+ await new Promise((ok) => setTimeout(ok, 150));
+ assert.equal(await db.value('SELECT count(*)::int FROM telemetry_samples'), 2, 'nothing prunes after stop()');
+ const bad = startTelemetryRetention({ db: { query: async () => { throw new Error('db down'); } }, delayMs: 5, everyMs: 1000, log: { error: (m) => errors.push(String(m)) } });
+ assert.equal(await bad.run(), 0);
+ await bad.stop();
+ assert.match(errors[0], /retention prune failed: db down/);
+ assert.throws(() => startTelemetryRetention({ db, everyMs: 0 }), /everyMs/);
+ assert.throws(() => startTelemetryRetention({ db, table: 'x; drop' }), /bad telemetry table name/);
+ } finally { await db.close(); }
+ }],
]);
]);
@@ -40,3 +40,9 @@ export declare function telemetrySchema(table?: string): string;
@@ -40,3 +40,9 @@ export declare function telemetrySchema(table?: string): string;
export declare function createPgTelemetrySink(opts: { db: TelemetryDb; table?: string; log?: { warn(...a: unknown[]): void } }): (samples: TelemetrySample[]) => Promise<void>;
export declare function createPgTelemetrySink(opts: { db: TelemetryDb; table?: string; log?: { warn(...a: unknown[]): void } }): (samples: TelemetrySample[]) => Promise<void>;
/** Retention: deletes rows whose `at` is older than olderThanDays (default 30); resolves with the count. */
/** Retention: deletes rows whose `at` is older than olderThanDays (default 30); resolves with the count. */
export declare function pruneTelemetry(opts: { db: TelemetryDb; table?: string; olderThanDays?: number }): Promise<number>;
export declare function pruneTelemetry(opts: { db: TelemetryDb; table?: string; olderThanDays?: number }): Promise<number>;
+/** Scheduled retention: pruneTelemetry() delayMs after the call (default 60000) and then every everyMs (default
+ * 86400000), on unref'd timers, so a service redeployed more than daily still prunes. A failing prune is logged
+ * and retried at the next tick; runs never overlap. run() prunes now (resolves with the count, 0 on failure);
+ * stop() cancels the timers and waits for a run in flight. */
+export declare function startTelemetryRetention(opts: { db: TelemetryDb; table?: string; olderThanDays?: number; everyMs?: number; delayMs?: number;
+ log?: { error(...a: unknown[]): void } }): { run(): Promise<number>; stop(): Promise<void> };