openvibers/openvibe.games Commits

Prometheus text metrics (Track O): GET /metrics (loopback only) answers the snapshot as games_* gauges in Prometheus text when the scraper asks (Accept text/plain or OpenMetrics, or ?format=prometheus); JSON stays the default

OP OpenVibers committed 24 Sept 2026 · c31851363e · 6 files, +47 −4


Changes

ddiff --git a/apps/server/dist-types/net/httpServer.d.ts.map b/apps/server/dist-types/net/httpServer.d.ts.map
iindex 0c65370..4f708ff 100644
---- a/apps/server/dist-types/net/httpServer.d.ts.map
++++ b/apps/server/dist-types/net/httpServer.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAwBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AA2BlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAmCD,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;IAC/D,gEAAgE;IAChE,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAC5F;AAYD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC3B,MAAM,CA8jBR"}
\\ No newline at end of file
++{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAwBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AA2BlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAmCD,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;IAC/D,gEAAgE;IAChE,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAC5F;AAYD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC3B,MAAM,CAmkBR"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/observability/release.d.ts b/apps/server/dist-types/observability/release.d.ts
iindex c7e764e..b585700 100644
---- a/apps/server/dist-types/observability/release.d.ts
++++ b/apps/server/dist-types/observability/release.d.ts
@@@ -31,4 +31,11 @@ export declare function buildRelease(root: string, { env, now }?: {
export declare function releaseHandler(manifest: ReleaseManifest): (req: IncomingMessage, res: ServerResponse) => boolean;
/** A request from this machine that did not come through the proxy (nginx sets the client-IP headers). */
export declare function isDirectLoopback(req: IncomingMessage): boolean;
++/**
++ * The metrics snapshot in Prometheus text format (Track O): every finite number becomes a gauge
++ * games_<snake_case key>; nested objects and non-numbers are skipped.
++ */
++export declare function prometheusText(snapshot: Record<string, unknown>): string;
++/** Does this scrape ask for the text format (Prometheus sends text/plain or OpenMetrics in Accept)? */
++export declare function wantsPrometheus(req: IncomingMessage): boolean;
//# sourceMappingURL=release.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/observability/release.d.ts.map b/apps/server/dist-types/observability/release.d.ts.map
iindex 80cd16f..39ded34 100644
---- a/apps/server/dist-types/observability/release.d.ts.map
++++ b/apps/server/dist-types/observability/release.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"release.d.ts","sourceRoot":"","sources":["../../src/observability/release.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC,0BAA0B,EAAE,MAAM,CAAA;IAClC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAC7D,iBAAiB,EAAE,IAAI,CAAA;IACvB,sBAAsB,EAAE,IAAI,CAAA;IAC5B,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACxC;AAED,wHAAwH;AACxH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAuBtD;AAUD,wBAAgB,YAAY,CAC1B,IAAI,EAAE,MAAM,EACZ,EAAE,GAAiB,EAAE,GAAsB,EAAE,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CAAO,GAChG,eAAe,CAuBjB;AAED,4DAA4D;AAC5D,wBAAgB,cAAc,CAAC,QAAQ,EAAE,eAAe,IAE9C,KAAK,eAAe,EAAE,KAAK,cAAc,KAAG,OAAO,CAM5D;AAED,0GAA0G;AAC1G,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAK9D"}
\\ No newline at end of file
++{"version":3,"file":"release.d.ts","sourceRoot":"","sources":["../../src/observability/release.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC,0BAA0B,EAAE,MAAM,CAAA;IAClC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAC7D,iBAAiB,EAAE,IAAI,CAAA;IACvB,sBAAsB,EAAE,IAAI,CAAA;IAC5B,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACxC;AAED,wHAAwH;AACxH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAuBtD;AAUD,wBAAgB,YAAY,CAC1B,IAAI,EAAE,MAAM,EACZ,EAAE,GAAiB,EAAE,GAAsB,EAAE,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CAAO,GAChG,eAAe,CAuBjB;AAED,4DAA4D;AAC5D,wBAAgB,cAAc,CAAC,QAAQ,EAAE,eAAe,IAE9C,KAAK,eAAe,EAAE,KAAK,cAAc,KAAG,OAAO,CAM5D;AAED,0GAA0G;AAC1G,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAK9D;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAQxE;AAED,uGAAuG;AACvG,wBAAgB,eAAe,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAI7D"}
\\ No newline at end of file
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 7c525f5..5aedccf 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -4,7 +4,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, extname, join, normalize, resolve } from 'node:path'
import type { Logger } from '@openvibe/shared'
import type { ServerMetrics } from '../observability/metrics.js'
--import { isDirectLoopback } from '../observability/release.js'
++import { isDirectLoopback, prometheusText, wantsPrometheus } from '../observability/release.js'
import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
import {
LOGIN_STATE_COOKIE,
@@@ -621,6 +621,11 @@ export function createHttpServer(
notFound()
return
}
++ if (wantsPrometheus(req)) {
++ res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' })
++ res.end(prometheusText(metrics.snapshot() as unknown as Record<string, unknown>))
++ return
++ }
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(metrics.snapshot()))
return
ddiff --git a/apps/server/src/observability/release.test.ts b/apps/server/src/observability/release.test.ts
iindex 799a0ba..3f600f4 100644
---- a/apps/server/src/observability/release.test.ts
++++ b/apps/server/src/observability/release.test.ts
@@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { describe, expect, it } from 'vitest'
--import { buildRelease, gitCommit, isDirectLoopback, releaseHandler } from './release.js'
++import { buildRelease, gitCommit, isDirectLoopback, prometheusText, releaseHandler, wantsPrometheus } from './release.js'
const SHA = 'a'.repeat(40)
function checkout(kind: 'loose' | 'packed' | 'detached'): string {
@@@ -54,4 +54,14 @@ describe('release manifest (D43)', () => {
expect(isDirectLoopback(req('127.0.0.1', { 'x-real-ip': '1.2.3.4' }))).toBe(false)
expect(isDirectLoopback(req('203.0.113.9'))).toBe(false)
})
++
++ it('serves the metrics snapshot as Prometheus text when a scraper asks', () => {
++ const text = prometheusText({ tick: 42, tickDurationMs: 0.5, sessions: 3, name: 'x', nested: { a: 1 }, bad: Number.NaN })
++ expect(text).toBe('# TYPE games_tick gauge\ngames_tick 42\n# TYPE games_tick_duration_ms gauge\ngames_tick_duration_ms 0.5\n# TYPE games_sessions gauge\ngames_sessions 3\n')
++ const req = (accept: string, url = '/metrics') => ({ headers: { accept }, url }) as unknown as IncomingMessage
++ expect(wantsPrometheus(req('text/plain;version=0.0.4;q=0.5,*/*;q=0.1'))).toBe(true)
++ expect(wantsPrometheus(req('application/openmetrics-text;version=1.0.0'))).toBe(true)
++ expect(wantsPrometheus(req('', '/metrics?format=prometheus'))).toBe(true)
++ expect(wantsPrometheus(req('application/json'))).toBe(false)
++ })
})
ddiff --git a/apps/server/src/observability/release.ts b/apps/server/src/observability/release.ts
iindex 109df82..c4bf1d4 100644
---- a/apps/server/src/observability/release.ts
++++ b/apps/server/src/observability/release.ts
@@@ -103,3 +103,24 @@ export function isDirectLoopback(req: IncomingMessage): boolean {
const h = req.headers
return loop && !h['x-forwarded-for'] && !h['x-real-ip'] && !h['cf-connecting-ip']
}
++
++/**
++ * The metrics snapshot in Prometheus text format (Track O): every finite number becomes a gauge
++ * games_<snake_case key>; nested objects and non-numbers are skipped.
++ */
++export function prometheusText(snapshot: Record<string, unknown>): string {
++ const lines: string[] = []
++ for (const [k, v] of Object.entries(snapshot)) {
++ if (typeof v !== 'number' || !Number.isFinite(v)) continue
++ const name = `games_${k.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9_]/g, '_').toLowerCase()}`
++ lines.push(`# TYPE ${name} gauge`, `${name} ${v}`)
++ }
++ return `${lines.join('\n')}\n`
++}
++
++/** Does this scrape ask for the text format (Prometheus sends text/plain or OpenMetrics in Accept)? */
++export function wantsPrometheus(req: IncomingMessage): boolean {
++ const accept = String(req.headers.accept ?? '')
++ const q = (req.url ?? '').split('?')[1] ?? ''
++ return /text\/plain|openmetrics/i.test(accept) || /(^|&)format=prometheus(&|$)/.test(q)
++}