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
Changes
@@@ -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@@@ -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@@@ -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@@@ -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@@@ -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)++ })})@@@ -103,3 +103,24 @@ export function isDirectLoopback(req: IncomingMessage): boolean {const h = req.headersreturn 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)++}
@@ -1 +1 @@
@@ -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
\ 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
\ No newline at end of file
@@ -31,4 +31,11 @@ export declare function buildRelease(root: string, { env, now }?: {
@@ -31,4 +31,11 @@ export declare function buildRelease(root: string, { env, now }?: {
export declare function releaseHandler(manifest: ReleaseManifest): (req: IncomingMessage, res: ServerResponse) => boolean;
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). */
/** 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;
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
//# sourceMappingURL=release.d.ts.map
\ No newline at end of file
\ No newline at end of file
@@ -1 +1 @@
@@ -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
\ 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
\ No newline at end of file
@@ -4,7 +4,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
@@ -4,7 +4,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, extname, join, normalize, resolve } from 'node:path'
import { dirname, extname, join, normalize, resolve } from 'node:path'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
import type { ServerMetrics } from '../observability/metrics.js'
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 { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
import {
import {
LOGIN_STATE_COOKIE,
LOGIN_STATE_COOKIE,
@@ -621,6 +621,11 @@ export function createHttpServer(
@@ -621,6 +621,11 @@ export function createHttpServer(
notFound()
notFound()
return
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.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(metrics.snapshot()))
res.end(JSON.stringify(metrics.snapshot()))
return
return
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join } from 'node:path'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { describe, expect, it } from 'vitest'
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)
const SHA = 'a'.repeat(40)
function checkout(kind: 'loose' | 'packed' | 'detached'): string {
function checkout(kind: 'loose' | 'packed' | 'detached'): string {
@@ -54,4 +54,14 @@ describe('release manifest (D43)', () => {
@@ -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('127.0.0.1', { 'x-real-ip': '1.2.3.4' }))).toBe(false)
expect(isDirectLoopback(req('203.0.113.9'))).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)
+ })
})
})
@@ -103,3 +103,24 @@ export function isDirectLoopback(req: IncomingMessage): boolean {
@@ -103,3 +103,24 @@ export function isDirectLoopback(req: IncomingMessage): boolean {
const h = req.headers
const h = req.headers
return loop && !h['x-forwarded-for'] && !h['x-real-ip'] && !h['cf-connecting-ip']
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)
+}