openvibers/openvibe.blog Commits
Store request telemetry in Postgres with daily retention (#42)
Changes
nnew file mode 100644@@@ -0,0 +1,28 @@++-- phase: expand++-- Request telemetry (openvibe-sdk/telemetry telemetrySchema()): one row per aggregated sample, pruned after 30 days by the worker.++CREATE TABLE IF NOT EXISTS telemetry_samples (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ at timestamptz NOT NULL,++ service text NOT NULL,++ project text,++ subject text,++ resource text,++ provider text,++ node text,++ cell text,++ region text,++ operation text NOT NULL,++ latency_ms double precision,++ queue_delay_ms double precision,++ ttfb_ms double precision,++ throughput_per_second double precision,++ bytes bigint,++ status text,++ cache_status text,++ cost_estimate double precision,++ route_epoch bigint,++ trace_id text,++ extra jsonb NOT NULL DEFAULT '{}'::jsonb++);++CREATE INDEX IF NOT EXISTS telemetry_samples_at_idx ON telemetry_samples (at);++CREATE INDEX IF NOT EXISTS telemetry_samples_service_idx ON telemetry_samples (service, operation, at);@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0"},@@@ -907,9 +907,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.44.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",-- "integrity": "sha512-8OjkIXVPUrGaOriBeHGK5GDoz4IRpjpKt7knZ5HSZnUpi0ND1Z3XD1YET4HiRydweT/rQy9/1h3oKod+tAP7Hg==",++ "version": "0.46.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",++ "integrity": "sha512-heZ06pqRNd8MKdSNZHGYe7FzHsZJxYz+IeWnTePWHshmqRI3qiPeRu7Xg/aCWiYOdIgLn3DUXw+vXhN/aw+LuA==","license": "MIT","engines": {"node": ">=20"@@@ -29,7 +29,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0"},@@@ -106,6 +106,17 @@ async function createApp(opts = {}) {require('./render/layout').setRelease(release.release);const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'blog', release: release.release });app.locals.metrics = metrics.registry;++ // Request telemetry (openvibe-sdk/service): one aggregated sample per route template, status class and flush++ // window, stored in Blog's own telemetry_samples table; static assets and /metrics produce none.++ const { createHttpTelemetry } = require('openvibe-sdk/service');++ const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');++ const telemetry = createHttpTelemetry({++ service: 'blog',++ sink: opts.telemetrySink || createPgTelemetrySink({ db: store.db, log }),++ skipPrefixes: ['/shared'],++ });++ app.use(telemetry.middleware());++ ctx.telemetry = telemetry;app.locals.ctx = ctx;// Per-actor limits (http/actor-limits.js) for the API, the editor and comment posts, counted once each// router resolved req.viewer; the per-address limits below stay.@@@ -17,7 +17,7 @@ const { startSubscriptions } = require('openvibe-sdk/account-data');function createLifecycle({ server, ctx, exit, signals, extra = [] }) {return gracefulStop({name: 'Blog', server, deadlineExitCode: 0, exit, signals,-- stop: [() => ctx.worker.stop(), () => ctx.changelog.stop(), ...extra],++ stop: [() => ctx.telemetry.stop(), () => ctx.worker.stop(), () => ctx.changelog.stop(), ...extra],close: [() => ctx.outbox.stop(), () => ctx.store.close()],});}@@@ -1,6 +1,6 @@'use strict';/**-- * Background work in the Blog process (openvibe-shared has no job runner; this is two timers):++ * Background work in the Blog process (openvibe-shared has no job runner; this is three timers):** scheduled publication every BLOG_SCHEDULE_INTERVAL_MS: claim due jobs (leased, so two* processes never run the same job at once) and apply them; the effect@@@ -8,10 +8,15 @@* media verification every BLOG_MEDIA_VERIFY_INTERVAL_MS: ask OpenVibe.Media whether the* objects attached to published posts still exist; gone → the explicit* broken-asset state; an outage changes nothing (check_failed)++ * telemetry retention openvibe-sdk/telemetry startTelemetryRetention(): a minute after start, then once a day,++ * telemetry_samples rows older than 30 days are deleted (a redeploy never pushes it a day out)*/++const { pruneTelemetry, startTelemetryRetention } = require('openvibe-sdk/telemetry');++function createWorker({ config, store, posts, media, outbox, log = console }) {let scheduleTimer = null;let mediaTimer = null;++ let retention = null;let running = false;async function scheduleTick() {@@@ -40,8 +45,13 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {return out;}++ async function pruneSamples() {++ try { return await pruneTelemetry({ db: store.db }); } catch (err) { log.warn('[Blog] telemetry retention failed:', err.message); return null; }++ }++return {scheduleTick,++ pruneSamples,verifyMedia,start() {if (!config.worker.enabled) return;@@@ -51,9 +61,10 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {mediaTimer = setInterval(() => verifyMedia().catch((err) => log.warn('[Blog] media verification failed:', err.message)), config.media.verifyIntervalMs);mediaTimer.unref();}++ retention = startTelemetryRetention({ db: store.db, log });setTimeout(scheduleTick, 1000).unref();},-- stop() { clearInterval(scheduleTimer); clearInterval(mediaTimer); },++ async stop() { clearInterval(scheduleTimer); clearInterval(mediaTimer); if (retention) await retention.stop(); },};}@@@ -45,7 +45,7 @@ async function boot(opts = {}) {let built = null;async function start() {const config = configLib.load(env);-- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend });++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend, telemetrySink: opts.telemetrySink });// Warm the shared JWKS cache so an immediately-verified token does not race the first fetch.await require('openvibe-sdk/auth').jwksClient(`${network.url}/api/.well-known/jwks`).keysForKid(null);server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++/**++ * Request telemetry (openvibe-sdk/service, plan T1 step 10b): app.js builds one collector for service 'blog' and++ * mounts its middleware, index.js stops it first, and a route hit five times flushes as ONE aggregated++ * http.request sample labelled with the route template and status class. /metrics and static assets make none.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');++++(async () => {++ await check('app.js creates the blog collector and mounts its middleware; index.js stops it first', () => {++ const app = read('server/app.js');++ assert.match(app, /createHttpTelemetry\(\{[^}]*service: 'blog'/s);++ assert.match(app, /app\.use\(telemetry\.middleware\(\)\)/);++ assert.match(app, /ctx\.telemetry = telemetry/);++ assert.match(read('server/index.js'), /stop: \[\(\) => ctx\.telemetry\.stop\(\),/);++ });++++ const samples = [];++ const t = await boot({ telemetrySink: async (batch) => { samples.push(...batch); } });++ const requests = () => samples.filter((s) => s.operation === 'http.request');++++ await check('five hits on one API route flush as one http.request sample: template, 2xx, count 5', async () => {++ await Promise.all([1, 2, 3, 4, 5].map(async () => assert.strictEqual((await t.get('/api/v1/changelog')).status, 200)));++ await t.ctx.telemetry.flush();++ const mine = requests().filter((s) => /^\/api\/v1\/changelog\b/.test(s.resource || '') && s.status === '2xx');++ assert.strictEqual(mine.length, 1, JSON.stringify(requests()));++ assert.strictEqual(mine[0].service, 'blog');++ assert.strictEqual(mine[0].resource, '/api/v1/changelog');++ assert.strictEqual(mine[0].extra.count, 5);++ });++++ await check('/metrics and a static asset produce no sample', async () => {++ samples.length = 0;++ await t.get('/metrics');++ const css = await t.get('/css/blog.css');++ assert.strictEqual(css.status, 200);++ await t.ctx.telemetry.flush();++ assert.deepStrictEqual(requests(), []);++ });++++ await check('the worker retention drops telemetry_samples rows older than 30 days and keeps newer ones', async () => {++ const db = t.ctx.store.db;++ const insert = (days) => db.query(`INSERT INTO telemetry_samples (at, service, operation) VALUES (now() - make_interval(days => $1), 'blog', 'http.request')`, [days]);++ await insert(45);++ await insert(1);++ assert.strictEqual(await t.ctx.worker.pruneSamples(), 1);++ const left = await db.query('SELECT count(*)::int AS n FROM telemetry_samples');++ assert.strictEqual(left.rows[0].n, 1);++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+-- phase: expand
+-- Request telemetry (openvibe-sdk/telemetry telemetrySchema()): one row per aggregated sample, pruned after 30 days by the worker.
+CREATE TABLE IF NOT EXISTS telemetry_samples (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ at timestamptz NOT NULL,
+ service text NOT NULL,
+ project text,
+ subject text,
+ resource text,
+ provider text,
+ node text,
+ cell text,
+ region text,
+ operation text NOT NULL,
+ latency_ms double precision,
+ queue_delay_ms double precision,
+ ttfb_ms double precision,
+ throughput_per_second double precision,
+ bytes bigint,
+ status text,
+ cache_status text,
+ cost_estimate double precision,
+ route_epoch bigint,
+ trace_id text,
+ extra jsonb NOT NULL DEFAULT '{}'::jsonb
+);
+CREATE INDEX IF NOT EXISTS telemetry_samples_at_idx ON telemetry_samples (at);
+CREATE INDEX IF NOT EXISTS telemetry_samples_service_idx ON telemetry_samples (service, operation, at);
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -907,9 +907,9 @@
@@ -907,9 +907,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.44.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
- "integrity": "sha512-8OjkIXVPUrGaOriBeHGK5GDoz4IRpjpKt7knZ5HSZnUpi0ND1Z3XD1YET4HiRydweT/rQy9/1h3oKod+tAP7Hg==",
+ "version": "0.46.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
+ "integrity": "sha512-heZ06pqRNd8MKdSNZHGYe7FzHsZJxYz+IeWnTePWHshmqRI3qiPeRu7Xg/aCWiYOdIgLn3DUXw+vXhN/aw+LuA==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -106,6 +106,17 @@ async function createApp(opts = {}) {
@@ -106,6 +106,17 @@ async function createApp(opts = {}) {
require('./render/layout').setRelease(release.release);
require('./render/layout').setRelease(release.release);
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'blog', release: release.release });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'blog', release: release.release });
app.locals.metrics = metrics.registry;
app.locals.metrics = metrics.registry;
+ // Request telemetry (openvibe-sdk/service): one aggregated sample per route template, status class and flush
+ // window, stored in Blog's own telemetry_samples table; static assets and /metrics produce none.
+ const { createHttpTelemetry } = require('openvibe-sdk/service');
+ const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');
+ const telemetry = createHttpTelemetry({
+ service: 'blog',
+ sink: opts.telemetrySink || createPgTelemetrySink({ db: store.db, log }),
+ skipPrefixes: ['/shared'],
+ });
+ app.use(telemetry.middleware());
+ ctx.telemetry = telemetry;
app.locals.ctx = ctx;
app.locals.ctx = ctx;
// Per-actor limits (http/actor-limits.js) for the API, the editor and comment posts, counted once each
// Per-actor limits (http/actor-limits.js) for the API, the editor and comment posts, counted once each
// router resolved req.viewer; the per-address limits below stay.
// router resolved req.viewer; the per-address limits below stay.
@@ -17,7 +17,7 @@ const { startSubscriptions } = require('openvibe-sdk/account-data');
@@ -17,7 +17,7 @@ const { startSubscriptions } = require('openvibe-sdk/account-data');
function createLifecycle({ server, ctx, exit, signals, extra = [] }) {
function createLifecycle({ server, ctx, exit, signals, extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'Blog', server, deadlineExitCode: 0, exit, signals,
name: 'Blog', server, deadlineExitCode: 0, exit, signals,
- stop: [() => ctx.worker.stop(), () => ctx.changelog.stop(), ...extra],
+ stop: [() => ctx.telemetry.stop(), () => ctx.worker.stop(), () => ctx.changelog.stop(), ...extra],
close: [() => ctx.outbox.stop(), () => ctx.store.close()],
close: [() => ctx.outbox.stop(), () => ctx.store.close()],
});
});
}
}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * Background work in the Blog process (openvibe-shared has no job runner; this is two timers):
+ * Background work in the Blog process (openvibe-shared has no job runner; this is three timers):
*
*
* scheduled publication every BLOG_SCHEDULE_INTERVAL_MS: claim due jobs (leased, so two
* scheduled publication every BLOG_SCHEDULE_INTERVAL_MS: claim due jobs (leased, so two
* processes never run the same job at once) and apply them; the effect
* processes never run the same job at once) and apply them; the effect
@@ -8,10 +8,15 @@
@@ -8,10 +8,15 @@
* media verification every BLOG_MEDIA_VERIFY_INTERVAL_MS: ask OpenVibe.Media whether the
* media verification every BLOG_MEDIA_VERIFY_INTERVAL_MS: ask OpenVibe.Media whether the
* objects attached to published posts still exist; gone → the explicit
* objects attached to published posts still exist; gone → the explicit
* broken-asset state; an outage changes nothing (check_failed)
* broken-asset state; an outage changes nothing (check_failed)
+ * telemetry retention openvibe-sdk/telemetry startTelemetryRetention(): a minute after start, then once a day,
+ * telemetry_samples rows older than 30 days are deleted (a redeploy never pushes it a day out)
*/
*/
+const { pruneTelemetry, startTelemetryRetention } = require('openvibe-sdk/telemetry');
+
function createWorker({ config, store, posts, media, outbox, log = console }) {
function createWorker({ config, store, posts, media, outbox, log = console }) {
let scheduleTimer = null;
let scheduleTimer = null;
let mediaTimer = null;
let mediaTimer = null;
+ let retention = null;
let running = false;
let running = false;
async function scheduleTick() {
async function scheduleTick() {
@@ -40,8 +45,13 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {
@@ -40,8 +45,13 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {
return out;
return out;
}
}
+ async function pruneSamples() {
+ try { return await pruneTelemetry({ db: store.db }); } catch (err) { log.warn('[Blog] telemetry retention failed:', err.message); return null; }
+ }
+
return {
return {
scheduleTick,
scheduleTick,
+ pruneSamples,
verifyMedia,
verifyMedia,
start() {
start() {
if (!config.worker.enabled) return;
if (!config.worker.enabled) return;
@@ -51,9 +61,10 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {
@@ -51,9 +61,10 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {
mediaTimer = setInterval(() => verifyMedia().catch((err) => log.warn('[Blog] media verification failed:', err.message)), config.media.verifyIntervalMs);
mediaTimer = setInterval(() => verifyMedia().catch((err) => log.warn('[Blog] media verification failed:', err.message)), config.media.verifyIntervalMs);
mediaTimer.unref();
mediaTimer.unref();
}
}
+ retention = startTelemetryRetention({ db: store.db, log });
setTimeout(scheduleTick, 1000).unref();
setTimeout(scheduleTick, 1000).unref();
},
},
- stop() { clearInterval(scheduleTimer); clearInterval(mediaTimer); },
+ async stop() { clearInterval(scheduleTimer); clearInterval(mediaTimer); if (retention) await retention.stop(); },
};
};
}
}
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend });
+ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend, telemetrySink: opts.telemetrySink });
// Warm the shared JWKS cache so an immediately-verified token does not race the first fetch.
// Warm the shared JWKS cache so an immediately-verified token does not race the first fetch.
await require('openvibe-sdk/auth').jwksClient(`${network.url}/api/.well-known/jwks`).keysForKid(null);
await require('openvibe-sdk/auth').jwksClient(`${network.url}/api/.well-known/jwks`).keysForKid(null);
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+/**
+ * Request telemetry (openvibe-sdk/service, plan T1 step 10b): app.js builds one collector for service 'blog' and
+ * mounts its middleware, index.js stops it first, and a route hit five times flushes as ONE aggregated
+ * http.request sample labelled with the route template and status class. /metrics and static assets make none.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
+
+(async () => {
+ await check('app.js creates the blog collector and mounts its middleware; index.js stops it first', () => {
+ const app = read('server/app.js');
+ assert.match(app, /createHttpTelemetry\(\{[^}]*service: 'blog'/s);
+ assert.match(app, /app\.use\(telemetry\.middleware\(\)\)/);
+ assert.match(app, /ctx\.telemetry = telemetry/);
+ assert.match(read('server/index.js'), /stop: \[\(\) => ctx\.telemetry\.stop\(\),/);
+ });
+
+ const samples = [];
+ const t = await boot({ telemetrySink: async (batch) => { samples.push(...batch); } });
+ const requests = () => samples.filter((s) => s.operation === 'http.request');
+
+ await check('five hits on one API route flush as one http.request sample: template, 2xx, count 5', async () => {
+ await Promise.all([1, 2, 3, 4, 5].map(async () => assert.strictEqual((await t.get('/api/v1/changelog')).status, 200)));
+ await t.ctx.telemetry.flush();
+ const mine = requests().filter((s) => /^\/api\/v1\/changelog\b/.test(s.resource || '') && s.status === '2xx');
+ assert.strictEqual(mine.length, 1, JSON.stringify(requests()));
+ assert.strictEqual(mine[0].service, 'blog');
+ assert.strictEqual(mine[0].resource, '/api/v1/changelog');
+ assert.strictEqual(mine[0].extra.count, 5);
+ });
+
+ await check('/metrics and a static asset produce no sample', async () => {
+ samples.length = 0;
+ await t.get('/metrics');
+ const css = await t.get('/css/blog.css');
+ assert.strictEqual(css.status, 200);
+ await t.ctx.telemetry.flush();
+ assert.deepStrictEqual(requests(), []);
+ });
+
+ await check('the worker retention drops telemetry_samples rows older than 30 days and keeps newer ones', async () => {
+ const db = t.ctx.store.db;
+ const insert = (days) => db.query(`INSERT INTO telemetry_samples (at, service, operation) VALUES (now() - make_interval(days => $1), 'blog', 'http.request')`, [days]);
+ await insert(45);
+ await insert(1);
+ assert.strictEqual(await t.ctx.worker.pruneSamples(), 1);
+ const left = await db.query('SELECT count(*)::int AS n FROM telemetry_samples');
+ assert.strictEqual(left.rows[0].n, 1);
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });