openvibers/openvibe.games Commits
Games: request telemetry with bounded route labels, 14-day retention (#85)
Changes
@@@ -16,6 +16,8 @@ import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'import { createConsoleLogger } from '@openvibe/shared'import { loadConfig } from './config.js'import { openDb } from './db.js'++import { telemetry } from 'openvibe-sdk/service'++import { createPgTelemetrySink, startTelemetryRetention } from 'openvibe-sdk/telemetry'import { GameServer } from './game/gameServer.js'import { GameWorld } from './game/gameWorld.js'import { InstanceHub } from './game/instanceHub.js'@@@ -91,6 +93,12 @@ async function main(): Promise<void> {// PostgreSQL (or embedded PGlite in development), migrated before anything writes. It opens// before content because enabled mod packs add definitions to the content registry.const db = await openDb(config.db, log)++ // Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template (net/telemetryRoute.ts),++ // method and status class each minute, stored in telemetry_samples; rows older than 14 days go daily.++ // (openvibe-sdk 0.46's service and telemetry typings disagree on whether a sample's `service` is optional.)++ const telemetrySink = createPgTelemetrySink({ db, log: console })++ telemetry.init({ service: 'games', sink: (samples) => telemetrySink(samples as Parameters<typeof telemetrySink>[0]), intervalMs: 60_000 })++ const telemetryRetention = startTelemetryRetention({ db, log: console, olderThanDays: 14 })const registry = new InstanceRegistry(db, config.placeId)let primaryLease: InstanceLeaseconst store = openPgStore(db, {@@@ -1035,6 +1043,7 @@ async function main(): Promise<void> {await progressWriter.stop()inventoryRewards?.stop()playSync.stop()++ await within(1000, (async () => { await telemetryRetention.stop(); await telemetry.stop() })()) // request telemetry flushes firstawait store.close()await valkey?.close()log.info('stopped', {@@@ -33,6 +33,7 @@ import { discoveryHandler, homeJsonLdTags } from './discovery.js'import { legalHandler } from './legalPages.js'import { updatesHandler } from './updatesPage.js'import { indexnowHandler, type SharedIndexNow } from './indexnow.js'++import { observeRequest } from './telemetryRoute.js'/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file* per request (a new build changes the mtime and rebuilds it once). */@@@ -208,6 +209,7 @@ export function createHttpServer(const serveIndexNow = indexnow ? indexnowHandler(indexnow) : nullreturn createServer((req: IncomingMessage, res: ServerResponse) => {const url = (req.url ?? '/').split('?')[0] ?? '/'++ observeRequest(req, res, url) // request telemetry (telemetryRoute.ts): a no-op until main.ts calls telemetry.init()// Host-based routing: play.openvibe.games serves the game at its root// (the apex root is the games portal). Everything else — /assets,// /map.json, /api, /auth, websockets — behaves identically on both.nnew file mode 100644@@@ -0,0 +1,73 @@++import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'++import type { Server } from 'node:http'++import type { AddressInfo } from 'node:net'++import { tmpdir } from 'node:os'++import { join } from 'node:path'++import { createConsoleLogger } from '@openvibe/shared'++import { telemetry } from 'openvibe-sdk/service'++import { afterAll, beforeAll, describe, expect, it } from 'vitest'++import { ServerMetrics } from '../observability/metrics.js'++import { createHttpServer } from './httpServer.js'++import { telemetryRoute } from './telemetryRoute.js'++++describe('request telemetry labels (plan T1)', () => {++ it('keeps the API resource, the first segment of a page, and turns the rest into :param', () => {++ expect(telemetryRoute('/')).toBe('/')++ expect(telemetryRoute('/play')).toBe('/play')++ expect(telemetryRoute('/play/vibe-heroes')).toBe('/play/:param')++ expect(telemetryRoute('/api/v1/games/salvage-rush/leaderboard')).toBe('/api/v1/games/:param/:param')++ expect(telemetryRoute('/auth/callback')).toBe('/auth/callback')++ expect(telemetryRoute('/@some.player')).toBe('/:param')++ })++++ it('never counts static files, /shared, health, readiness, metrics or the release manifest', () => {++ for (const p of ['/assets/game.js', '/shared/navbar.js', '/maps/scraplandia.glb', '/api/health', '/api/ready', '/metrics', '/release.json', '/leaderboard.html.map']) {++ expect(telemetryRoute(p)).toBeNull()++ }++ })++++ it('main.ts starts the collector and the 14-day retention and flushes before the store closes', () => {++ const main = readFileSync(new URL('../main.ts', import.meta.url), 'utf8')++ expect(main).toContain('const telemetrySink = createPgTelemetrySink({ db, log: console })')++ expect(main).toContain("telemetry.init({ service: 'games', sink: (samples) => telemetrySink(")++ expect(main).toContain('startTelemetryRetention({ db, log: console, olderThanDays: 14 })')++ expect(main.indexOf('await telemetry.stop()')).toBeLessThan(main.indexOf('await store.close()'))++ })++})++++describe('request telemetry through the HTTP server', () => {++ let dir: string++ let server: Server++ let base: string++ const samples: Record<string, unknown>[] = []++++ beforeAll(async () => {++ dir = mkdtempSync(join(tmpdir(), 'ovg-telemetry-'))++ const dist = join(dir, 'dist')++ mkdirSync(dist, { recursive: true })++ writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')++ writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')++ server = createHttpServer(dist, new ServerMetrics(), createConsoleLogger({ app: 'test' }, 'error'))++ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()))++ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`++ telemetry.init({ service: 'games', sink: async (batch) => { samples.push(...(batch as unknown as Record<string, unknown>[])) }, intervalMs: 3600e3 })++ })++++ afterAll(async () => {++ await telemetry.stop()++ server.close()++ rmSync(dir, { recursive: true, force: true })++ })++++ it('five hits on one page flush as one http.request sample; a 404 is unmatched, never its path', async () => {++ for (let i = 0; i < 5; i++) expect((await fetch(`${base}/terms`)).status).toBe(200)++ expect((await fetch(`${base}/wp-admin/setup.php`)).status).toBe(404)++ await telemetry.flush()++ const requests = samples.filter((s) => s.operation === 'http.request')++ const terms = requests.filter((s) => s.resource === '/terms' && s.status === '2xx')++ expect(terms).toHaveLength(1)++ expect((terms[0]!.extra as { count: number }).count).toBe(5)++ expect(requests.some((s) => s.resource === 'unmatched')).toBe(true)++ expect(JSON.stringify(requests)).not.toContain('wp-admin')++ })++})nnew file mode 100644@@@ -0,0 +1,48 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import { telemetry } from 'openvibe-sdk/service'++++/**++ * Request telemetry (openvibe-sdk/service, plan T1): the HTTP server has no route table, so a request's label is a++ * bounded template built from its path, never the raw URL. The API's first three segments name the resource++ * (api, v1, games), /auth's first two, any other path's first one; every later segment (a game, a map, a player, a++ * room) is `:param`, and so is any kept segment that is not a plain slug. A 404 is labelled `unmatched` when it++ * finishes, so a scanner's paths never become labels. Static files, /shared, health, readiness, metrics and the++ * release manifest carry no product signal and are not counted (null).++ */++const STATIC = /\.(?:js|mjs|css|map|png|jpe?g|gif|svg|webp|avif|ico|glb|gltf|ktx2|wasm|json|txt|xml|webmanifest|woff2?|ttf|mp3|ogg|wav)$/i++const SKIP = new Set(['/api/health', '/api/ready', '/metrics', '/release.json'])++const SLUG = /^[a-z0-9_-]{1,40}$/i++++export function telemetryRoute(path: string): string | null {++ if (SKIP.has(path) || path.startsWith('/shared/') || path.startsWith('/assets/') || STATIC.test(path)) return null++ const parts = path.split('/').filter(Boolean)++ if (parts.length === 0) return '/'++ const keep = parts[0] === 'api' ? 3 : parts[0] === 'auth' ? 2 : 1++ return `/${parts.map((p, i) => (i < keep && SLUG.test(p) ? p : ':param')).join('/')}`++}++++/** Count one request through the process-wide collector (a no-op until main.ts calls telemetry.init()). */++export function observeRequest(req: IncomingMessage, res: ServerResponse, path: string): void {++ const route = telemetryRoute(path)++ if (!route) return++ try {++ telemetry.requestStarted()++ const t0 = process.hrtime.bigint()++ let done = false++ const finish = (): void => {++ if (done) return++ done = true++ const httpStatus = res.writableFinished ? res.statusCode : 0++ telemetry.requestFinished({++ route: httpStatus === 404 ? 'unmatched' : route,++ method: req.method ?? 'GET',++ httpStatus,++ latencyMs: Number(process.hrtime.bigint() - t0) / 1e6,++ })++ }++ res.once('finish', finish)++ res.once('close', finish)++ } catch {++ /* telemetry never breaks a request */++ }++}nnew file mode 100644@@@ -0,0 +1,28 @@++-- phase: expand++-- Request telemetry (openvibe-sdk/telemetry telemetrySchema()): one row per aggregated sample, pruned after 14 days (apps/server/src/main.ts).++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);nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/net/telemetryRoute.test.ts'++// covers: 'apps/server/src/net'++// Generated by node test/run.js --index; do not edit.
@@ -16,6 +16,8 @@ import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
@@ -16,6 +16,8 @@ import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { loadConfig } from './config.js'
import { loadConfig } from './config.js'
import { openDb } from './db.js'
import { openDb } from './db.js'
+import { telemetry } from 'openvibe-sdk/service'
+import { createPgTelemetrySink, startTelemetryRetention } from 'openvibe-sdk/telemetry'
import { GameServer } from './game/gameServer.js'
import { GameServer } from './game/gameServer.js'
import { GameWorld } from './game/gameWorld.js'
import { GameWorld } from './game/gameWorld.js'
import { InstanceHub } from './game/instanceHub.js'
import { InstanceHub } from './game/instanceHub.js'
@@ -91,6 +93,12 @@ async function main(): Promise<void> {
@@ -91,6 +93,12 @@ async function main(): Promise<void> {
// PostgreSQL (or embedded PGlite in development), migrated before anything writes. It opens
// PostgreSQL (or embedded PGlite in development), migrated before anything writes. It opens
// before content because enabled mod packs add definitions to the content registry.
// before content because enabled mod packs add definitions to the content registry.
const db = await openDb(config.db, log)
const db = await openDb(config.db, log)
+ // Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template (net/telemetryRoute.ts),
+ // method and status class each minute, stored in telemetry_samples; rows older than 14 days go daily.
+ // (openvibe-sdk 0.46's service and telemetry typings disagree on whether a sample's `service` is optional.)
+ const telemetrySink = createPgTelemetrySink({ db, log: console })
+ telemetry.init({ service: 'games', sink: (samples) => telemetrySink(samples as Parameters<typeof telemetrySink>[0]), intervalMs: 60_000 })
+ const telemetryRetention = startTelemetryRetention({ db, log: console, olderThanDays: 14 })
const registry = new InstanceRegistry(db, config.placeId)
const registry = new InstanceRegistry(db, config.placeId)
let primaryLease: InstanceLease
let primaryLease: InstanceLease
const store = openPgStore(db, {
const store = openPgStore(db, {
@@ -1035,6 +1043,7 @@ async function main(): Promise<void> {
@@ -1035,6 +1043,7 @@ async function main(): Promise<void> {
await progressWriter.stop()
await progressWriter.stop()
inventoryRewards?.stop()
inventoryRewards?.stop()
playSync.stop()
playSync.stop()
+ await within(1000, (async () => { await telemetryRetention.stop(); await telemetry.stop() })()) // request telemetry flushes first
await store.close()
await store.close()
await valkey?.close()
await valkey?.close()
log.info('stopped', {
log.info('stopped', {
@@ -33,6 +33,7 @@ import { discoveryHandler, homeJsonLdTags } from './discovery.js'
@@ -33,6 +33,7 @@ import { discoveryHandler, homeJsonLdTags } from './discovery.js'
import { legalHandler } from './legalPages.js'
import { legalHandler } from './legalPages.js'
import { updatesHandler } from './updatesPage.js'
import { updatesHandler } from './updatesPage.js'
import { indexnowHandler, type SharedIndexNow } from './indexnow.js'
import { indexnowHandler, type SharedIndexNow } from './indexnow.js'
+import { observeRequest } from './telemetryRoute.js'
/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file
/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file
* per request (a new build changes the mtime and rebuilds it once). */
* per request (a new build changes the mtime and rebuilds it once). */
@@ -208,6 +209,7 @@ export function createHttpServer(
@@ -208,6 +209,7 @@ export function createHttpServer(
const serveIndexNow = indexnow ? indexnowHandler(indexnow) : null
const serveIndexNow = indexnow ? indexnowHandler(indexnow) : null
return createServer((req: IncomingMessage, res: ServerResponse) => {
return createServer((req: IncomingMessage, res: ServerResponse) => {
const url = (req.url ?? '/').split('?')[0] ?? '/'
const url = (req.url ?? '/').split('?')[0] ?? '/'
+ observeRequest(req, res, url) // request telemetry (telemetryRoute.ts): a no-op until main.ts calls telemetry.init()
// Host-based routing: play.openvibe.games serves the game at its root
// Host-based routing: play.openvibe.games serves the game at its root
// (the apex root is the games portal). Everything else — /assets,
// (the apex root is the games portal). Everything else — /assets,
// /map.json, /api, /auth, websockets — behaves identically on both.
// /map.json, /api, /auth, websockets — behaves identically on both.
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import type { Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createConsoleLogger } from '@openvibe/shared'
+import { telemetry } from 'openvibe-sdk/service'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { ServerMetrics } from '../observability/metrics.js'
+import { createHttpServer } from './httpServer.js'
+import { telemetryRoute } from './telemetryRoute.js'
+
+describe('request telemetry labels (plan T1)', () => {
+ it('keeps the API resource, the first segment of a page, and turns the rest into :param', () => {
+ expect(telemetryRoute('/')).toBe('/')
+ expect(telemetryRoute('/play')).toBe('/play')
+ expect(telemetryRoute('/play/vibe-heroes')).toBe('/play/:param')
+ expect(telemetryRoute('/api/v1/games/salvage-rush/leaderboard')).toBe('/api/v1/games/:param/:param')
+ expect(telemetryRoute('/auth/callback')).toBe('/auth/callback')
+ expect(telemetryRoute('/@some.player')).toBe('/:param')
+ })
+
+ it('never counts static files, /shared, health, readiness, metrics or the release manifest', () => {
+ for (const p of ['/assets/game.js', '/shared/navbar.js', '/maps/scraplandia.glb', '/api/health', '/api/ready', '/metrics', '/release.json', '/leaderboard.html.map']) {
+ expect(telemetryRoute(p)).toBeNull()
+ }
+ })
+
+ it('main.ts starts the collector and the 14-day retention and flushes before the store closes', () => {
+ const main = readFileSync(new URL('../main.ts', import.meta.url), 'utf8')
+ expect(main).toContain('const telemetrySink = createPgTelemetrySink({ db, log: console })')
+ expect(main).toContain("telemetry.init({ service: 'games', sink: (samples) => telemetrySink(")
+ expect(main).toContain('startTelemetryRetention({ db, log: console, olderThanDays: 14 })')
+ expect(main.indexOf('await telemetry.stop()')).toBeLessThan(main.indexOf('await store.close()'))
+ })
+})
+
+describe('request telemetry through the HTTP server', () => {
+ let dir: string
+ let server: Server
+ let base: string
+ const samples: Record<string, unknown>[] = []
+
+ beforeAll(async () => {
+ dir = mkdtempSync(join(tmpdir(), 'ovg-telemetry-'))
+ const dist = join(dir, 'dist')
+ mkdirSync(dist, { recursive: true })
+ writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')
+ writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')
+ server = createHttpServer(dist, new ServerMetrics(), createConsoleLogger({ app: 'test' }, 'error'))
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()))
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
+ telemetry.init({ service: 'games', sink: async (batch) => { samples.push(...(batch as unknown as Record<string, unknown>[])) }, intervalMs: 3600e3 })
+ })
+
+ afterAll(async () => {
+ await telemetry.stop()
+ server.close()
+ rmSync(dir, { recursive: true, force: true })
+ })
+
+ it('five hits on one page flush as one http.request sample; a 404 is unmatched, never its path', async () => {
+ for (let i = 0; i < 5; i++) expect((await fetch(`${base}/terms`)).status).toBe(200)
+ expect((await fetch(`${base}/wp-admin/setup.php`)).status).toBe(404)
+ await telemetry.flush()
+ const requests = samples.filter((s) => s.operation === 'http.request')
+ const terms = requests.filter((s) => s.resource === '/terms' && s.status === '2xx')
+ expect(terms).toHaveLength(1)
+ expect((terms[0]!.extra as { count: number }).count).toBe(5)
+ expect(requests.some((s) => s.resource === 'unmatched')).toBe(true)
+ expect(JSON.stringify(requests)).not.toContain('wp-admin')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { telemetry } from 'openvibe-sdk/service'
+
+/**
+ * Request telemetry (openvibe-sdk/service, plan T1): the HTTP server has no route table, so a request's label is a
+ * bounded template built from its path, never the raw URL. The API's first three segments name the resource
+ * (api, v1, games), /auth's first two, any other path's first one; every later segment (a game, a map, a player, a
+ * room) is `:param`, and so is any kept segment that is not a plain slug. A 404 is labelled `unmatched` when it
+ * finishes, so a scanner's paths never become labels. Static files, /shared, health, readiness, metrics and the
+ * release manifest carry no product signal and are not counted (null).
+ */
+const STATIC = /\.(?:js|mjs|css|map|png|jpe?g|gif|svg|webp|avif|ico|glb|gltf|ktx2|wasm|json|txt|xml|webmanifest|woff2?|ttf|mp3|ogg|wav)$/i
+const SKIP = new Set(['/api/health', '/api/ready', '/metrics', '/release.json'])
+const SLUG = /^[a-z0-9_-]{1,40}$/i
+
+export function telemetryRoute(path: string): string | null {
+ if (SKIP.has(path) || path.startsWith('/shared/') || path.startsWith('/assets/') || STATIC.test(path)) return null
+ const parts = path.split('/').filter(Boolean)
+ if (parts.length === 0) return '/'
+ const keep = parts[0] === 'api' ? 3 : parts[0] === 'auth' ? 2 : 1
+ return `/${parts.map((p, i) => (i < keep && SLUG.test(p) ? p : ':param')).join('/')}`
+}
+
+/** Count one request through the process-wide collector (a no-op until main.ts calls telemetry.init()). */
+export function observeRequest(req: IncomingMessage, res: ServerResponse, path: string): void {
+ const route = telemetryRoute(path)
+ if (!route) return
+ try {
+ telemetry.requestStarted()
+ const t0 = process.hrtime.bigint()
+ let done = false
+ const finish = (): void => {
+ if (done) return
+ done = true
+ const httpStatus = res.writableFinished ? res.statusCode : 0
+ telemetry.requestFinished({
+ route: httpStatus === 404 ? 'unmatched' : route,
+ method: req.method ?? 'GET',
+ httpStatus,
+ latencyMs: Number(process.hrtime.bigint() - t0) / 1e6,
+ })
+ }
+ res.once('finish', finish)
+ res.once('close', finish)
+ } catch {
+ /* telemetry never breaks a request */
+ }
+}
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 14 days (apps/server/src/main.ts).
+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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/net/telemetryRoute.test.ts'
+// covers: 'apps/server/src/net'
+// Generated by node test/run.js --index; do not edit.