openvibers/openvibe.games Commits

Games: request telemetry with bounded route labels, 14-day retention (#85)

OP OpenVibers committed 29 days ago · abd108354d · 6 files, +163 −0

Plan T1 step 10 (universal telemetry) for openvibe.games, using openvibe-sdk 0.46's process-wide collector.

- **Migration** `packages/persistence/migrations/0009_telemetry.sql` (expand): the `telemetry_samples` table from `telemetrySchema()`.
- **`apps/server/src/net/telemetryRoute.ts`**: 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 keeps its first three segments (`/api/v1/games/:param/:param`), `/auth` its first two, any other page its first one. Everything after, and any kept segment that isn't a plain slug, becomes `:param`.
  - A 404 is labelled `unmatched` when it finishes, so a scanner's paths never become labels.
  - Static files, `/shared`, `/assets`, health, readiness, `/metrics` and `/release.json` aren't counted.
  - `observeRequest()` reports each request through the singleton's `requestStarted`/`requestFinished`. It does nothing until `init()`.
- **`httpServer.ts`** calls `observeRequest` first in its handler.
- **`main.ts`**:
  - `telemetry.init({ service: 'games', sink: createPgTelemetrySink(...), intervalMs: 60_000 })` once the database opens; `startTelemetryRetention` keeps 14 days.
  - On stop, both stop and the collector flushes, at most 1 s, before the store closes.
  - The sink is cast once, because openvibe-sdk 0.46's `service` and `telemetry` typings disagree on whether a sample's `service` is optional.
- **`telemetryRoute.test.ts`** (with its `test/` pointer) checks:
  - the labels and the skips;
  - the wiring in `main.ts`;
  - five hits through the real server flush as one sample (count 5);
  - a 404 is `unmatched` with no trace of its path.
- Typecheck and lint are clean.

Changes

ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex fe5d9c3..482d5fb 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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: InstanceLease
const 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 first
await store.close()
await valkey?.close()
log.info('stopped', {
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex c4e6308..093c4f2 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -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) : null
return 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.
ddiff --git a/apps/server/src/net/telemetryRoute.test.ts b/apps/server/src/net/telemetryRoute.test.ts
nnew file mode 100644
iindex 0000000..1a6bd21
---- /dev/null
++++ b/apps/server/src/net/telemetryRoute.test.ts
@@@ -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')
++ })
++})
ddiff --git a/apps/server/src/net/telemetryRoute.ts b/apps/server/src/net/telemetryRoute.ts
nnew file mode 100644
iindex 0000000..7ec58a4
---- /dev/null
++++ b/apps/server/src/net/telemetryRoute.ts
@@@ -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 */
++ }
++}
ddiff --git a/packages/persistence/migrations/0009_telemetry.sql b/packages/persistence/migrations/0009_telemetry.sql
nnew file mode 100644
iindex 0000000..9d9ac60
---- /dev/null
++++ b/packages/persistence/migrations/0009_telemetry.sql
@@@ -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);
ddiff --git a/test/apps__server__src__net__telemetryRoute.test.js b/test/apps__server__src__net__telemetryRoute.test.js
nnew file mode 100644
iindex 0000000..aa54a60
---- /dev/null
++++ b/test/apps__server__src__net__telemetryRoute.test.js
@@@ -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.