From d82a8f77b997dbcaa6b3b16965ac3e086bd0e52d Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 26 Jul 2026 15:10:15 -0400 Subject: [PATCH 01/10] feat(fps-exporter): parse PresentMon streamed CSV lines --- scripts/fps-exporter/presentmon-csv.js | 19 ++++++++++++ scripts/fps-exporter/presentmon-csv.test.js | 32 +++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 scripts/fps-exporter/presentmon-csv.js create mode 100644 scripts/fps-exporter/presentmon-csv.test.js diff --git a/scripts/fps-exporter/presentmon-csv.js b/scripts/fps-exporter/presentmon-csv.js new file mode 100644 index 0000000..d106e9f --- /dev/null +++ b/scripts/fps-exporter/presentmon-csv.js @@ -0,0 +1,19 @@ +'use strict'; + +function parseHeader(line) { + return line.split(',').map((s) => s.trim()); +} + +function parseRow(headerCols, line) { + const values = line.split(','); + if (values.length !== headerCols.length) { + throw new Error(`column count mismatch: expected ${headerCols.length}, got ${values.length}`); + } + const row = {}; + for (let i = 0; i < headerCols.length; i++) { + row[headerCols[i]] = values[i]; + } + return row; +} + +module.exports = { parseHeader, parseRow }; diff --git a/scripts/fps-exporter/presentmon-csv.test.js b/scripts/fps-exporter/presentmon-csv.test.js new file mode 100644 index 0000000..dfb22ab --- /dev/null +++ b/scripts/fps-exporter/presentmon-csv.test.js @@ -0,0 +1,32 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseHeader, parseRow } = require('./presentmon-csv.js'); + +test('parseHeader: splits and trims column names', () => { + const cols = parseHeader('Application,ProcessID,MsBetweenPresents,MsBetweenDisplayChange,DisplayedTime'); + assert.deepEqual(cols, ['Application', 'ProcessID', 'MsBetweenPresents', 'MsBetweenDisplayChange', 'DisplayedTime']); +}); + +test('parseRow: zips values with header into an object', () => { + const cols = ['Application', 'ProcessID', 'MsBetweenPresents', 'MsBetweenDisplayChange', 'DisplayedTime']; + const row = parseRow(cols, 'iRacingSim64DX11.exe,12345,16.683,16.683,16.683'); + assert.deepEqual(row, { + Application: 'iRacingSim64DX11.exe', + ProcessID: '12345', + MsBetweenPresents: '16.683', + MsBetweenDisplayChange: '16.683', + DisplayedTime: '16.683', + }); +}); + +test('parseRow: preserves NA for dropped frames', () => { + const cols = ['Application', 'DisplayedTime']; + const row = parseRow(cols, 'chrome.exe,NA'); + assert.equal(row.DisplayedTime, 'NA'); +}); + +test('parseRow: throws on column/value count mismatch', () => { + const cols = ['Application', 'ProcessID']; + assert.throws(() => parseRow(cols, 'chrome.exe'), /column count mismatch/); +}); From 248750fc89d1ca56f5a11c390b8e0024999cc686 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 26 Jul 2026 15:10:59 -0400 Subject: [PATCH 02/10] feat(fps-exporter): rolling-window FPS aggregation + Prometheus rendering --- scripts/fps-exporter/metrics-aggregator.js | 82 +++++++++++++++++++ .../fps-exporter/metrics-aggregator.test.js | 55 +++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 scripts/fps-exporter/metrics-aggregator.js create mode 100644 scripts/fps-exporter/metrics-aggregator.test.js diff --git a/scripts/fps-exporter/metrics-aggregator.js b/scripts/fps-exporter/metrics-aggregator.js new file mode 100644 index 0000000..5d946e3 --- /dev/null +++ b/scripts/fps-exporter/metrics-aggregator.js @@ -0,0 +1,82 @@ +'use strict'; + +const WINDOW_MS = 5000; + +class MetricsAggregator { + constructor(allowlist) { + this.allowlist = new Set(allowlist); + this.presents = new Map(); + this.displayed = new Map(); + this.dropped = new Map(); + for (const proc of allowlist) { + this.presents.set(proc, []); + this.displayed.set(proc, []); + this.dropped.set(proc, 0); + } + } + + recordRow(row, now) { + const proc = row.Application; + if (!this.allowlist.has(proc)) return; + + const msBetweenPresents = parseFloat(row.MsBetweenPresents); + if (Number.isFinite(msBetweenPresents)) { + this.presents.get(proc).push({ ts: now, ms: msBetweenPresents }); + } + + if (row.DisplayedTime === 'NA') { + this.dropped.set(proc, this.dropped.get(proc) + 1); + } else { + const msBetweenDisplayChange = parseFloat(row.MsBetweenDisplayChange); + if (Number.isFinite(msBetweenDisplayChange)) { + this.displayed.get(proc).push({ ts: now, ms: msBetweenDisplayChange }); + } + } + + this._prune(proc, now); + } + + _prune(proc, now) { + const cutoff = now - WINDOW_MS; + this.presents.set(proc, this.presents.get(proc).filter((e) => e.ts >= cutoff)); + this.displayed.set(proc, this.displayed.get(proc).filter((e) => e.ts >= cutoff)); + } + + _avgFps(entries) { + if (entries.length === 0) return null; + const avgMs = entries.reduce((sum, e) => sum + e.ms, 0) / entries.length; + if (avgMs <= 0) return null; + return 1000 / avgMs; + } + + renderPrometheusText(now) { + const gameFpsLines = []; + const displayFpsLines = []; + const droppedLines = []; + + for (const proc of this.allowlist) { + this._prune(proc, now); + + const gameFps = this._avgFps(this.presents.get(proc)); + if (gameFps !== null) { + gameFpsLines.push(`game_fps{process="${proc}"} ${gameFps.toFixed(2)}`); + } + + const displayFps = this._avgFps(this.displayed.get(proc)); + if (displayFps !== null) { + displayFpsLines.push(`display_fps{process="${proc}"} ${displayFps.toFixed(2)}`); + } + + droppedLines.push(`frames_dropped_total{process="${proc}"} ${this.dropped.get(proc)}`); + } + + const lines = []; + if (gameFpsLines.length > 0) lines.push('# TYPE game_fps gauge', ...gameFpsLines); + if (displayFpsLines.length > 0) lines.push('# TYPE display_fps gauge', ...displayFpsLines); + lines.push('# TYPE frames_dropped_total counter', ...droppedLines); + + return lines.join('\n') + '\n'; + } +} + +module.exports = { MetricsAggregator, WINDOW_MS }; diff --git a/scripts/fps-exporter/metrics-aggregator.test.js b/scripts/fps-exporter/metrics-aggregator.test.js new file mode 100644 index 0000000..01fb973 --- /dev/null +++ b/scripts/fps-exporter/metrics-aggregator.test.js @@ -0,0 +1,55 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { MetricsAggregator } = require('./metrics-aggregator.js'); + +test('recordRow + renderPrometheusText: computes game_fps from MsBetweenPresents', () => { + const agg = new MetricsAggregator(['iRacingSim64DX11.exe']); + for (let i = 0; i < 10; i++) { + agg.recordRow({ + Application: 'iRacingSim64DX11.exe', + MsBetweenPresents: '16.667', + MsBetweenDisplayChange: '16.667', + DisplayedTime: '16.667', + }, 1000 + i * 10); + } + const text = agg.renderPrometheusText(1100); + const match = text.match(/game_fps\{process="iRacingSim64DX11\.exe"\} ([\d.]+)/); + assert.ok(match, 'game_fps line present'); + assert.ok(Math.abs(parseFloat(match[1]) - 60) < 0.5, `expected ~60 fps, got ${match[1]}`); +}); + +test('renderPrometheusText: omits game_fps/display_fps for a process with no recent data', () => { + const agg = new MetricsAggregator(['iRacingSim64DX11.exe']); + const text = agg.renderPrometheusText(1000); + assert.ok(!text.includes('game_fps{process="iRacingSim64DX11.exe"}')); + assert.ok(!text.includes('display_fps{process="iRacingSim64DX11.exe"}')); +}); + +test('renderPrometheusText: still emits frames_dropped_total 0 for a process with no drops yet', () => { + const agg = new MetricsAggregator(['chrome.exe']); + const text = agg.renderPrometheusText(1000); + assert.ok(text.includes('frames_dropped_total{process="chrome.exe"} 0')); +}); + +test('recordRow: DisplayedTime "NA" counts as a dropped frame and is excluded from display_fps', () => { + const agg = new MetricsAggregator(['chrome.exe']); + agg.recordRow({ Application: 'chrome.exe', MsBetweenPresents: '10', MsBetweenDisplayChange: '10', DisplayedTime: '10' }, 1000); + agg.recordRow({ Application: 'chrome.exe', MsBetweenPresents: '10', MsBetweenDisplayChange: 'NA', DisplayedTime: 'NA' }, 1010); + const text = agg.renderPrometheusText(1020); + assert.ok(text.includes('frames_dropped_total{process="chrome.exe"} 1')); +}); + +test('recordRow: ignores processes not in the allowlist', () => { + const agg = new MetricsAggregator(['iRacingSim64DX11.exe']); + agg.recordRow({ Application: 'notepad.exe', MsBetweenPresents: '16.667', MsBetweenDisplayChange: '16.667', DisplayedTime: '16.667' }, 1000); + const text = agg.renderPrometheusText(1000); + assert.ok(!text.includes('notepad.exe')); +}); + +test('recordRow + renderPrometheusText: prunes entries older than the 5s window', () => { + const agg = new MetricsAggregator(['iRacingSim64DX11.exe']); + agg.recordRow({ Application: 'iRacingSim64DX11.exe', MsBetweenPresents: '16.667', MsBetweenDisplayChange: '16.667', DisplayedTime: '16.667' }, 1000); + const text = agg.renderPrometheusText(7001); + assert.ok(!text.includes('game_fps{process="iRacingSim64DX11.exe"}')); +}); From 5fccbd256edff6ec2d2608ee1f48b1d890fd7e6b Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 26 Jul 2026 15:11:28 -0400 Subject: [PATCH 03/10] feat(fps-exporter): PresentMon respawn backoff logic --- scripts/fps-exporter/backoff.js | 18 ++++++++++++++++++ scripts/fps-exporter/backoff.test.js | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 scripts/fps-exporter/backoff.js create mode 100644 scripts/fps-exporter/backoff.test.js diff --git a/scripts/fps-exporter/backoff.js b/scripts/fps-exporter/backoff.js new file mode 100644 index 0000000..aafb1aa --- /dev/null +++ b/scripts/fps-exporter/backoff.js @@ -0,0 +1,18 @@ +'use strict'; + +const INITIAL_BACKOFF_MS = 5000; +const MAX_BACKOFF_MS = 60000; +const HEALTHY_RUN_RESET_MS = 60000; + +function nextBackoffMs(currentBackoffMs) { + return Math.min(currentBackoffMs * 2, MAX_BACKOFF_MS); +} + +function backoffAfterExit(currentBackoffMs, runDurationMs) { + if (runDurationMs >= HEALTHY_RUN_RESET_MS) { + return INITIAL_BACKOFF_MS; + } + return nextBackoffMs(currentBackoffMs); +} + +module.exports = { INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, HEALTHY_RUN_RESET_MS, nextBackoffMs, backoffAfterExit }; diff --git a/scripts/fps-exporter/backoff.test.js b/scripts/fps-exporter/backoff.test.js new file mode 100644 index 0000000..beaffc9 --- /dev/null +++ b/scripts/fps-exporter/backoff.test.js @@ -0,0 +1,19 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, backoffAfterExit } = require('./backoff.js'); + +test('backoffAfterExit: doubles backoff after a short-lived run (crash loop)', () => { + const next = backoffAfterExit(INITIAL_BACKOFF_MS, 1000); + assert.equal(next, INITIAL_BACKOFF_MS * 2); +}); + +test('backoffAfterExit: caps backoff at MAX_BACKOFF_MS', () => { + const next = backoffAfterExit(MAX_BACKOFF_MS, 1000); + assert.equal(next, MAX_BACKOFF_MS); +}); + +test('backoffAfterExit: resets to INITIAL_BACKOFF_MS after a healthy long run', () => { + const next = backoffAfterExit(MAX_BACKOFF_MS, 120000); + assert.equal(next, INITIAL_BACKOFF_MS); +}); From 90c673d17b4f87adce89c003b45b3284470e20b8 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 26 Jul 2026 15:12:45 -0400 Subject: [PATCH 04/10] feat(fps-exporter): spawn PresentMon and serve Prometheus /metrics --- scripts/fps-exporter/fps-exporter.js | 114 +++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 scripts/fps-exporter/fps-exporter.js diff --git a/scripts/fps-exporter/fps-exporter.js b/scripts/fps-exporter/fps-exporter.js new file mode 100644 index 0000000..7008a52 --- /dev/null +++ b/scripts/fps-exporter/fps-exporter.js @@ -0,0 +1,114 @@ +'use strict'; + +const { spawn } = require('node:child_process'); +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { parseHeader, parseRow } = require('./presentmon-csv.js'); +const { MetricsAggregator } = require('./metrics-aggregator.js'); +const { INITIAL_BACKOFF_MS, backoffAfterExit } = require('./backoff.js'); + +const ALLOWLIST = ['iRacingSim64DX11.exe', 'chrome.exe']; +const PRESENTMON_PATH = path.join(os.homedir(), 'Tools', 'PresentMon', 'PresentMon.exe'); +const METRICS_PORT = 9101; +const METRICS_HOST = '127.0.0.1'; +const LOG_DIR = path.join(process.env.LOCALAPPDATA || os.tmpdir(), 'FpsExporter'); +const LOG_FILE = path.join(LOG_DIR, 'fps-exporter.log'); + +const aggregator = new MetricsAggregator(ALLOWLIST); + +function log(message) { + const line = `${new Date().toISOString()} ${message}\n`; + process.stdout.write(line); + try { + fs.mkdirSync(LOG_DIR, { recursive: true }); + fs.appendFileSync(LOG_FILE, line); + } catch (err) { + process.stderr.write(`failed to write log file: ${err.message}\n`); + } +} + +function startPresentMon(backoffMs) { + log(`starting PresentMon.exe (backoff was ${backoffMs}ms)`); + const startedAt = Date.now(); + const child = spawn(PRESENTMON_PATH, [ + '--process_name', 'iRacingSim64DX11.exe', + '--process_name', 'chrome.exe', + '--output_stdout', + '--no_csv', + '--no_console_stats', + ]); + + let headerCols = null; + let carry = ''; + + child.stdout.on('data', (chunk) => { + carry += chunk.toString('utf8'); + const lines = carry.split('\n'); + carry = lines.pop(); + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line.length === 0) continue; + + if (headerCols === null) { + headerCols = parseHeader(line); + continue; + } + + try { + const row = parseRow(headerCols, line); + aggregator.recordRow(row, Date.now()); + } catch (err) { + log(`skipping unparseable row: ${err.message}`); + } + } + }); + + child.stderr.on('data', (chunk) => { + log(`PresentMon stderr: ${chunk.toString('utf8').trim()}`); + }); + + child.on('exit', (code, signal) => { + const runDurationMs = Date.now() - startedAt; + log(`PresentMon.exe exited (code=${code}, signal=${signal}, ran for ${runDurationMs}ms)`); + const nextBackoff = backoffAfterExit(backoffMs, runDurationMs); + setTimeout(() => startPresentMon(nextBackoff), nextBackoff); + }); + + child.on('error', (err) => { + log(`failed to spawn PresentMon.exe: ${err.message}`); + }); +} + +function startMetricsServer() { + const server = http.createServer((req, res) => { + if (req.url === '/metrics') { + const body = aggregator.renderPrometheusText(Date.now()); + res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' }); + res.end(body); + } else { + res.writeHead(404); + res.end(); + } + }); + + server.on('error', (err) => { + log(`metrics server failed to start: ${err.message}`); + process.exit(1); + }); + + server.listen(METRICS_PORT, METRICS_HOST, () => { + log(`metrics endpoint listening on http://${METRICS_HOST}:${METRICS_PORT}/metrics`); + }); +} + +if (!fs.existsSync(PRESENTMON_PATH)) { + log(`PresentMon.exe not found at ${PRESENTMON_PATH}`); + process.exit(1); +} + +startMetricsServer(); +startPresentMon(INITIAL_BACKOFF_MS); From 30c196163a84d6924e5d1b710e99d3fe00f0603b Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 26 Jul 2026 15:13:16 -0400 Subject: [PATCH 05/10] feat(fps-exporter): install as a Windows Service via NSSM --- scripts/fps-exporter/install-service.ps1 | 68 ++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 scripts/fps-exporter/install-service.ps1 diff --git a/scripts/fps-exporter/install-service.ps1 b/scripts/fps-exporter/install-service.ps1 new file mode 100644 index 0000000..2f4b5f4 --- /dev/null +++ b/scripts/fps-exporter/install-service.ps1 @@ -0,0 +1,68 @@ +<# +.SYNOPSIS + Install fps-exporter as a Windows Service (LocalSystem) via NSSM, so PresentMon's + admin/ETW requirement is satisfied once at install time instead of on every run. + +.EXAMPLE + # From an elevated PowerShell: + .\scripts\fps-exporter\install-service.ps1 +#> +param( + [string]$ServiceName = "FpsExporter" +) + +$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() +$isAdmin = (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + Write-Error "This script installs a Windows Service and must run from an elevated (Administrator) PowerShell." + exit 1 +} + +$nodePath = (Get-Command node -ErrorAction SilentlyContinue).Source +if (-not $nodePath) { + Write-Error "node.exe not found on PATH. Install Node.js first." + exit 1 +} + +function Find-Nssm { + Get-ChildItem -Path "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Filter "nssm.exe" -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match 'win64' } | Select-Object -First 1 -ExpandProperty FullName +} + +$nssmPath = Find-Nssm +if (-not $nssmPath) { + Write-Output "NSSM not found, installing via winget..." + winget install --id NSSM.NSSM -e --accept-package-agreements --accept-source-agreements + $nssmPath = Find-Nssm +} + +if (-not $nssmPath) { + Write-Error "NSSM install via winget did not produce nssm.exe under $env:LOCALAPPDATA\Microsoft\WinGet\Packages. Install NSSM manually and re-run." + exit 1 +} + +Write-Output "Using NSSM at: $nssmPath" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$entryScript = Join-Path $scriptDir "fps-exporter.js" + +if (-not (Test-Path $entryScript)) { + Write-Error "fps-exporter.js not found at $entryScript" + exit 1 +} + +New-Item -ItemType Directory -Force -Path "$env:LOCALAPPDATA\FpsExporter" | Out-Null + +& $nssmPath install $ServiceName $nodePath $entryScript +& $nssmPath set $ServiceName AppDirectory $scriptDir +& $nssmPath set $ServiceName Start SERVICE_AUTO_START +& $nssmPath set $ServiceName AppStdout "$env:LOCALAPPDATA\FpsExporter\service-stdout.log" +& $nssmPath set $ServiceName AppStderr "$env:LOCALAPPDATA\FpsExporter\service-stderr.log" +& $nssmPath set $ServiceName AppRotateFiles 1 +& $nssmPath set $ServiceName AppRotateBytes 10485760 + +& $nssmPath start $ServiceName + +Write-Output "Service '$ServiceName' installed and started." +Write-Output "Check status: nssm status $ServiceName (or Get-Service $ServiceName)" +Write-Output "Verify metrics: curl http://127.0.0.1:9101/metrics" From c09138a53f1082cd1d033ea80c42e0adab5a44a2 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 26 Jul 2026 15:15:36 -0400 Subject: [PATCH 06/10] fix(fps-exporter): drop --no_csv, conflicts with --output_stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresentMon logs a warning on every start/respawn since these two flags are mutually exclusive (--output_stdout silently wins) — harmless but would spam the log file forever on an always-on service. Found during the fps-exporter-agent's smoke test. Co-Authored-By: Claude Sonnet 5 --- scripts/fps-exporter/fps-exporter.js | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/fps-exporter/fps-exporter.js b/scripts/fps-exporter/fps-exporter.js index 7008a52..7038e25 100644 --- a/scripts/fps-exporter/fps-exporter.js +++ b/scripts/fps-exporter/fps-exporter.js @@ -37,7 +37,6 @@ function startPresentMon(backoffMs) { '--process_name', 'iRacingSim64DX11.exe', '--process_name', 'chrome.exe', '--output_stdout', - '--no_csv', '--no_console_stats', ]); From 0f16c52cc810dcad4b82b56280e6a25983c3748c Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 26 Jul 2026 15:40:23 -0400 Subject: [PATCH 07/10] feat(fps-exporter): break FPS down by video output, not just process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit game_fps/display_fps/frames_dropped_total now carry an output="" label from PresentMon's VidPnSourceId (enabled via --write_display_metadata) alongside the existing process="..." label, so the same process across two monitors reads as two series instead of one blended average. Also adds an optional connector label (HDMI/DisplayPort/DVI/...) via a small connector-map.json that fps-exporter reads once at startup. That map can't be produced by fps-exporter itself — it runs as a LocalSystem service in Session 0, which has no real monitor topology to query — so dump-display-topology.ps1 is a separate script the user runs from their own interactive desktop session to generate it. Verified the P/Invoke QueryDisplayConfig code compiles and runs cleanly (correctly reported "no real connector" for the current RDP/remote session's virtual display, rather than crashing or fabricating data) — the real topology dump still needs to happen at the user's own keyboard. Co-Authored-By: Claude Sonnet 5 --- .../fps-exporter/dump-display-topology.ps1 | 198 ++++++++++++++++++ scripts/fps-exporter/fps-exporter.js | 27 ++- scripts/fps-exporter/metrics-aggregator.js | 69 ++++-- .../fps-exporter/metrics-aggregator.test.js | 69 ++++-- 4 files changed, 325 insertions(+), 38 deletions(-) create mode 100644 scripts/fps-exporter/dump-display-topology.ps1 diff --git a/scripts/fps-exporter/dump-display-topology.ps1 b/scripts/fps-exporter/dump-display-topology.ps1 new file mode 100644 index 0000000..16b30f7 --- /dev/null +++ b/scripts/fps-exporter/dump-display-topology.ps1 @@ -0,0 +1,198 @@ +<# +.SYNOPSIS + Map PresentMon's numeric VidPnSourceId (the "output" label on game_fps/display_fps/ + frames_dropped_total) to each monitor's real connector type (HDMI/DisplayPort/DVI/...) + and friendly name, via the Windows QueryDisplayConfig API. + +.DESCRIPTION + MUST be run from your normal interactive desktop session — not elevated, not over RDP, + not from a Windows Service. QueryDisplayConfig reports the topology of whichever session + calls it; the fps-exporter service runs in Session 0 (non-interactive, no real monitors + attached in the WDDM sense), and an RDP session sees RDP's own virtual "Remote Display + Adapter" instead of your physical monitors — confirmed empirically earlier the same day + this script was written, via a completely different tool (EnumDisplaySettings) hitting the + exact same problem. Run this at your own keyboard, on the physical console session. + + Writes connector-map.json next to this script, e.g.: + {"0": "DisplayPort", "1": "HDMI"} + fps-exporter.js reads that file once at startup (see loadConnectorMap() in fps-exporter.js) + and attaches a connector="..." label wherever the output ID matches. Missing or stale is + harmless — panels just fall back to the numeric output ID with no connector label. + + UNTESTED IN THIS REPO'S DEV ENVIRONMENT — the struct layouts below are hand-written from + the documented Win32 DisplayConfig API (stable, unchanged in the SDK for years), but no + execution of this exact script has been verified end-to-end. Sanity-check the output: + cross-reference monitorFriendlyDeviceName in the console output against Settings > System + > Display > Advanced display > "Display information" for each monitor. If a friendly name + comes back garbled or empty, don't trust that entry's connector type either — the win32 + call may have partially failed for that target even though it returned SUCCESS overall. + +.EXAMPLE + .\scripts\fps-exporter\dump-display-topology.ps1 +#> + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Collections.Generic; + +public class DisplayTopology { + [StructLayout(LayoutKind.Sequential)] + public struct LUID { public uint LowPart; public int HighPart; } + + [StructLayout(LayoutKind.Sequential)] + public struct DISPLAYCONFIG_RATIONAL { public uint Numerator; public uint Denominator; } + + [StructLayout(LayoutKind.Sequential)] + public struct DISPLAYCONFIG_PATH_SOURCE_INFO { + public LUID adapterId; + public uint id; + public uint modeInfoIdx; + public uint statusFlags; + } + + [StructLayout(LayoutKind.Sequential)] + public struct DISPLAYCONFIG_PATH_TARGET_INFO { + public LUID adapterId; + public uint id; + public uint modeInfoIdx; + public uint outputTechnology; + public uint rotation; + public uint scaling; + public DISPLAYCONFIG_RATIONAL refreshRate; + public uint scanLineOrdering; + public int targetAvailable; + public uint statusFlags; + } + + [StructLayout(LayoutKind.Sequential)] + public struct DISPLAYCONFIG_PATH_INFO { + public DISPLAYCONFIG_PATH_SOURCE_INFO sourceInfo; + public DISPLAYCONFIG_PATH_TARGET_INFO targetInfo; + public uint flags; + } + + [StructLayout(LayoutKind.Sequential)] + public struct DISPLAYCONFIG_MODE_INFO { + public uint infoType; + public uint id; + public LUID adapterId; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 48)] + public byte[] modeInfo; + } + + [StructLayout(LayoutKind.Sequential)] + public struct DISPLAYCONFIG_DEVICE_INFO_HEADER { + public uint type; + public uint size; + public LUID adapterId; + public uint id; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct DISPLAYCONFIG_TARGET_DEVICE_NAME { + public DISPLAYCONFIG_DEVICE_INFO_HEADER header; + public uint flags; + public uint outputTechnology; + public ushort edidManufactureId; + public ushort edidProductCodeId; + public uint connectorInstance; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string monitorFriendlyDeviceName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string monitorDevicePath; + } + + const uint QDC_ONLY_ACTIVE_PATHS = 0x00000002; + const int DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME = 2; + const int ERROR_SUCCESS = 0; + + [DllImport("user32.dll")] + static extern int GetDisplayConfigBufferSizes(uint flags, out uint numPathArrayElements, out uint numModeInfoArrayElements); + + [DllImport("user32.dll")] + static extern int QueryDisplayConfig(uint flags, ref uint numPathArrayElements, [Out] DISPLAYCONFIG_PATH_INFO[] pathArray, + ref uint numModeInfoArrayElements, [Out] DISPLAYCONFIG_MODE_INFO[] modeInfoArray, IntPtr currentTopologyId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + static extern int DisplayConfigGetDeviceInfo(ref DISPLAYCONFIG_TARGET_DEVICE_NAME requestPacket); + + public static string TechName(uint tech) { + switch (tech) { + case 0: return "VGA"; + case 1: return "SVideo"; + case 2: return "CompositeVideo"; + case 3: return "ComponentVideo"; + case 4: return "DVI"; + case 5: return "HDMI"; + case 6: return "LVDS"; + case 8: return "D_JPN"; + case 9: return "SDI"; + case 10: return "DisplayPort"; + case 11: return "DisplayPort (embedded)"; + case 12: return "UDI"; + case 13: return "UDI (embedded)"; + case 14: return "SDTVDongle"; + case 15: return "Miracast"; + case 16: return "IndirectWired"; + case 17: return "IndirectVirtual"; + case 0x80000000: return "Internal"; + default: return "Unknown(" + tech + ")"; + } + } + + public static string Run() { + uint pathCount, modeCount; + int rc = GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, out pathCount, out modeCount); + if (rc != ERROR_SUCCESS) return "ERROR: GetDisplayConfigBufferSizes failed, code " + rc; + + var paths = new DISPLAYCONFIG_PATH_INFO[pathCount]; + var modes = new DISPLAYCONFIG_MODE_INFO[modeCount]; + rc = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, ref pathCount, paths, ref modeCount, modes, IntPtr.Zero); + if (rc != ERROR_SUCCESS) return "ERROR: QueryDisplayConfig failed, code " + rc; + + var sb = new System.Text.StringBuilder(); + for (uint i = 0; i < pathCount; i++) { + var p = paths[i]; + var nameReq = new DISPLAYCONFIG_TARGET_DEVICE_NAME(); + nameReq.header.type = (uint)DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME; + nameReq.header.size = (uint)Marshal.SizeOf(typeof(DISPLAYCONFIG_TARGET_DEVICE_NAME)); + nameReq.header.adapterId = p.targetInfo.adapterId; + nameReq.header.id = p.targetInfo.id; + string friendlyName = ""; + if (DisplayConfigGetDeviceInfo(ref nameReq) == ERROR_SUCCESS) { + friendlyName = string.IsNullOrEmpty(nameReq.monitorFriendlyDeviceName) ? "" : nameReq.monitorFriendlyDeviceName; + } + sb.AppendLine(p.sourceInfo.id + "\t" + TechName(p.targetInfo.outputTechnology) + "\t" + friendlyName); + } + return sb.ToString(); + } +} +"@ + +Write-Output "=== Raw topology: sourceId connector friendly name ===" +Write-Output "Sanity-check the friendly names against Settings > System > Display > Advanced display before trusting the map below." +Write-Output "" +$raw = [DisplayTopology]::Run() +if ($raw -like "ERROR:*") { + Write-Error $raw + exit 1 +} +Write-Output $raw + +$map = @{} +$rows = $raw -split "`n" | Where-Object { $_.Trim() -ne "" } +foreach ($row in $rows) { + $parts = $row -split "`t" + if ($parts.Count -ge 2) { + $map[$parts[0].Trim()] = $parts[1].Trim() + } +} + +$outPath = Join-Path $PSScriptRoot "connector-map.json" +$map | ConvertTo-Json | Set-Content -Path $outPath -Encoding utf8 +Write-Output "" +Write-Output "Wrote $outPath :" +Get-Content $outPath +Write-Output "" +Write-Output "Restart the FpsExporter service (or just fps-exporter.js if running manually) to pick this up — it's only read at startup." diff --git a/scripts/fps-exporter/fps-exporter.js b/scripts/fps-exporter/fps-exporter.js index 7038e25..697f524 100644 --- a/scripts/fps-exporter/fps-exporter.js +++ b/scripts/fps-exporter/fps-exporter.js @@ -12,13 +12,12 @@ const { INITIAL_BACKOFF_MS, backoffAfterExit } = require('./backoff.js'); const ALLOWLIST = ['iRacingSim64DX11.exe', 'chrome.exe']; const PRESENTMON_PATH = path.join(os.homedir(), 'Tools', 'PresentMon', 'PresentMon.exe'); +const CONNECTOR_MAP_PATH = path.join(__dirname, 'connector-map.json'); const METRICS_PORT = 9101; const METRICS_HOST = '127.0.0.1'; const LOG_DIR = path.join(process.env.LOCALAPPDATA || os.tmpdir(), 'FpsExporter'); const LOG_FILE = path.join(LOG_DIR, 'fps-exporter.log'); -const aggregator = new MetricsAggregator(ALLOWLIST); - function log(message) { const line = `${new Date().toISOString()} ${message}\n`; process.stdout.write(line); @@ -30,6 +29,29 @@ function log(message) { } } +function loadConnectorMap() { + // Optional: {"0":"DisplayPort","1":"HDMI"} keyed by PresentMon's VidPnSourceId, produced by + // dump-display-topology.ps1 run from the interactive desktop session (this service runs in + // Session 0, which cannot see real monitor topology itself — see that script's header comment). + // Read once at startup; the physical monitor layout doesn't change often enough to warrant + // watching the file, and a stale/missing map just means output IDs render without a connector + // label rather than with a wrong one. + if (!fs.existsSync(CONNECTOR_MAP_PATH)) { + log(`no connector map at ${CONNECTOR_MAP_PATH} — outputs will be labeled by numeric ID only`); + return {}; + } + try { + const map = JSON.parse(fs.readFileSync(CONNECTOR_MAP_PATH, 'utf8')); + log(`loaded connector map: ${JSON.stringify(map)}`); + return map; + } catch (err) { + log(`failed to parse connector map at ${CONNECTOR_MAP_PATH}: ${err.message} — ignoring it`); + return {}; + } +} + +const aggregator = new MetricsAggregator(ALLOWLIST, loadConnectorMap()); + function startPresentMon(backoffMs) { log(`starting PresentMon.exe (backoff was ${backoffMs}ms)`); const startedAt = Date.now(); @@ -38,6 +60,7 @@ function startPresentMon(backoffMs) { '--process_name', 'chrome.exe', '--output_stdout', '--no_console_stats', + '--write_display_metadata', ]); let headerCols = null; diff --git a/scripts/fps-exporter/metrics-aggregator.js b/scripts/fps-exporter/metrics-aggregator.js index 5d946e3..2159368 100644 --- a/scripts/fps-exporter/metrics-aggregator.js +++ b/scripts/fps-exporter/metrics-aggregator.js @@ -1,45 +1,62 @@ 'use strict'; const WINDOW_MS = 5000; +const DEFAULT_OUTPUT = '0'; + +function seriesKey(process, output) { + return process + '|' + output; +} class MetricsAggregator { - constructor(allowlist) { + constructor(allowlist, connectorMap) { this.allowlist = new Set(allowlist); + this.connectorMap = connectorMap || {}; this.presents = new Map(); this.displayed = new Map(); this.dropped = new Map(); - for (const proc of allowlist) { - this.presents.set(proc, []); - this.displayed.set(proc, []); - this.dropped.set(proc, 0); + this.seen = new Map(); // seriesKey -> { process, output } + } + + _ensure(key, process, output) { + if (!this.seen.has(key)) { + this.seen.set(key, { process, output }); + this.presents.set(key, []); + this.displayed.set(key, []); + this.dropped.set(key, 0); } } recordRow(row, now) { - const proc = row.Application; - if (!this.allowlist.has(proc)) return; + const process = row.Application; + if (!this.allowlist.has(process)) return; + + const output = row.VidPnSourceId !== undefined && row.VidPnSourceId !== '' && row.VidPnSourceId !== 'NA' + ? row.VidPnSourceId + : DEFAULT_OUTPUT; + const key = seriesKey(process, output); + this._ensure(key, process, output); const msBetweenPresents = parseFloat(row.MsBetweenPresents); if (Number.isFinite(msBetweenPresents)) { - this.presents.get(proc).push({ ts: now, ms: msBetweenPresents }); + this.presents.get(key).push({ ts: now, ms: msBetweenPresents }); } if (row.DisplayedTime === 'NA') { - this.dropped.set(proc, this.dropped.get(proc) + 1); + this.dropped.set(key, this.dropped.get(key) + 1); } else { const msBetweenDisplayChange = parseFloat(row.MsBetweenDisplayChange); if (Number.isFinite(msBetweenDisplayChange)) { - this.displayed.get(proc).push({ ts: now, ms: msBetweenDisplayChange }); + this.displayed.get(key).push({ ts: now, ms: msBetweenDisplayChange }); } } - this._prune(proc, now); + this._prune(key, now); } - _prune(proc, now) { + _prune(key, now) { const cutoff = now - WINDOW_MS; - this.presents.set(proc, this.presents.get(proc).filter((e) => e.ts >= cutoff)); - this.displayed.set(proc, this.displayed.get(proc).filter((e) => e.ts >= cutoff)); + this.presents.set(key, this.presents.get(key).filter((e) => e.ts >= cutoff)); + this.displayed.set(key, this.displayed.get(key).filter((e) => e.ts >= cutoff)); } _avgFps(entries) { @@ -49,31 +66,39 @@ class MetricsAggregator { return 1000 / avgMs; } + _labels(process, output) { + const connector = this.connectorMap[output]; + return connector + ? `process="${process}",output="${output}",connector="${connector}"` + : `process="${process}",output="${output}"`; + } + renderPrometheusText(now) { const gameFpsLines = []; const displayFpsLines = []; const droppedLines = []; - for (const proc of this.allowlist) { - this._prune(proc, now); + for (const [key, { process, output }] of this.seen) { + this._prune(key, now); + const labels = this._labels(process, output); - const gameFps = this._avgFps(this.presents.get(proc)); + const gameFps = this._avgFps(this.presents.get(key)); if (gameFps !== null) { - gameFpsLines.push(`game_fps{process="${proc}"} ${gameFps.toFixed(2)}`); + gameFpsLines.push(`game_fps{${labels}} ${gameFps.toFixed(2)}`); } - const displayFps = this._avgFps(this.displayed.get(proc)); + const displayFps = this._avgFps(this.displayed.get(key)); if (displayFps !== null) { - displayFpsLines.push(`display_fps{process="${proc}"} ${displayFps.toFixed(2)}`); + displayFpsLines.push(`display_fps{${labels}} ${displayFps.toFixed(2)}`); } - droppedLines.push(`frames_dropped_total{process="${proc}"} ${this.dropped.get(proc)}`); + droppedLines.push(`frames_dropped_total{${labels}} ${this.dropped.get(key)}`); } const lines = []; if (gameFpsLines.length > 0) lines.push('# TYPE game_fps gauge', ...gameFpsLines); if (displayFpsLines.length > 0) lines.push('# TYPE display_fps gauge', ...displayFpsLines); - lines.push('# TYPE frames_dropped_total counter', ...droppedLines); + if (droppedLines.length > 0) lines.push('# TYPE frames_dropped_total counter', ...droppedLines); return lines.join('\n') + '\n'; } diff --git a/scripts/fps-exporter/metrics-aggregator.test.js b/scripts/fps-exporter/metrics-aggregator.test.js index 01fb973..128beaf 100644 --- a/scripts/fps-exporter/metrics-aggregator.test.js +++ b/scripts/fps-exporter/metrics-aggregator.test.js @@ -3,53 +3,94 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { MetricsAggregator } = require('./metrics-aggregator.js'); -test('recordRow + renderPrometheusText: computes game_fps from MsBetweenPresents', () => { +test('recordRow + renderPrometheusText: computes game_fps from MsBetweenPresents, labeled by output', () => { const agg = new MetricsAggregator(['iRacingSim64DX11.exe']); for (let i = 0; i < 10; i++) { agg.recordRow({ Application: 'iRacingSim64DX11.exe', + VidPnSourceId: '0', MsBetweenPresents: '16.667', MsBetweenDisplayChange: '16.667', DisplayedTime: '16.667', }, 1000 + i * 10); } const text = agg.renderPrometheusText(1100); - const match = text.match(/game_fps\{process="iRacingSim64DX11\.exe"\} ([\d.]+)/); - assert.ok(match, 'game_fps line present'); + const match = text.match(/game_fps\{process="iRacingSim64DX11\.exe",output="0"\} ([\d.]+)/); + assert.ok(match, 'game_fps line present with output label'); assert.ok(Math.abs(parseFloat(match[1]) - 60) < 0.5, `expected ~60 fps, got ${match[1]}`); }); -test('renderPrometheusText: omits game_fps/display_fps for a process with no recent data', () => { +test('recordRow: missing VidPnSourceId falls back to output "0"', () => { + const agg = new MetricsAggregator(['chrome.exe']); + agg.recordRow({ Application: 'chrome.exe', MsBetweenPresents: '16.667', MsBetweenDisplayChange: '16.667', DisplayedTime: '16.667' }, 1000); + const text = agg.renderPrometheusText(1000); + assert.ok(text.includes('frames_dropped_total{process="chrome.exe",output="0"} 0')); +}); + +test('recordRow: two outputs for the same process produce two distinct series', () => { + const agg = new MetricsAggregator(['chrome.exe']); + for (let i = 0; i < 5; i++) { + agg.recordRow({ Application: 'chrome.exe', VidPnSourceId: '0', MsBetweenPresents: '10', MsBetweenDisplayChange: '10', DisplayedTime: '10' }, 1000 + i * 10); + agg.recordRow({ Application: 'chrome.exe', VidPnSourceId: '1', MsBetweenPresents: '20', MsBetweenDisplayChange: '20', DisplayedTime: '20' }, 1000 + i * 10); + } + const text = agg.renderPrometheusText(1100); + assert.ok(text.includes('game_fps{process="chrome.exe",output="0"} 100.00')); + assert.ok(text.includes('game_fps{process="chrome.exe",output="1"} 50.00')); +}); + +test('renderPrometheusText: omits game_fps/display_fps for a process/output with no recent data', () => { const agg = new MetricsAggregator(['iRacingSim64DX11.exe']); const text = agg.renderPrometheusText(1000); - assert.ok(!text.includes('game_fps{process="iRacingSim64DX11.exe"}')); - assert.ok(!text.includes('display_fps{process="iRacingSim64DX11.exe"}')); + assert.ok(!text.includes('game_fps{')); + assert.ok(!text.includes('display_fps{')); }); -test('renderPrometheusText: still emits frames_dropped_total 0 for a process with no drops yet', () => { +test('renderPrometheusText: frames_dropped_total is not fabricated for a process/output never seen', () => { const agg = new MetricsAggregator(['chrome.exe']); const text = agg.renderPrometheusText(1000); - assert.ok(text.includes('frames_dropped_total{process="chrome.exe"} 0')); + assert.ok(!text.includes('frames_dropped_total{'), 'no series should exist until at least one row is seen for that (process, output)'); }); test('recordRow: DisplayedTime "NA" counts as a dropped frame and is excluded from display_fps', () => { const agg = new MetricsAggregator(['chrome.exe']); - agg.recordRow({ Application: 'chrome.exe', MsBetweenPresents: '10', MsBetweenDisplayChange: '10', DisplayedTime: '10' }, 1000); - agg.recordRow({ Application: 'chrome.exe', MsBetweenPresents: '10', MsBetweenDisplayChange: 'NA', DisplayedTime: 'NA' }, 1010); + agg.recordRow({ Application: 'chrome.exe', VidPnSourceId: '0', MsBetweenPresents: '10', MsBetweenDisplayChange: '10', DisplayedTime: '10' }, 1000); + agg.recordRow({ Application: 'chrome.exe', VidPnSourceId: '0', MsBetweenPresents: '10', MsBetweenDisplayChange: 'NA', DisplayedTime: 'NA' }, 1010); const text = agg.renderPrometheusText(1020); - assert.ok(text.includes('frames_dropped_total{process="chrome.exe"} 1')); + assert.ok(text.includes('frames_dropped_total{process="chrome.exe",output="0"} 1')); }); test('recordRow: ignores processes not in the allowlist', () => { const agg = new MetricsAggregator(['iRacingSim64DX11.exe']); - agg.recordRow({ Application: 'notepad.exe', MsBetweenPresents: '16.667', MsBetweenDisplayChange: '16.667', DisplayedTime: '16.667' }, 1000); + agg.recordRow({ Application: 'notepad.exe', VidPnSourceId: '0', MsBetweenPresents: '16.667', MsBetweenDisplayChange: '16.667', DisplayedTime: '16.667' }, 1000); const text = agg.renderPrometheusText(1000); assert.ok(!text.includes('notepad.exe')); }); test('recordRow + renderPrometheusText: prunes entries older than the 5s window', () => { const agg = new MetricsAggregator(['iRacingSim64DX11.exe']); - agg.recordRow({ Application: 'iRacingSim64DX11.exe', MsBetweenPresents: '16.667', MsBetweenDisplayChange: '16.667', DisplayedTime: '16.667' }, 1000); + agg.recordRow({ Application: 'iRacingSim64DX11.exe', VidPnSourceId: '0', MsBetweenPresents: '16.667', MsBetweenDisplayChange: '16.667', DisplayedTime: '16.667' }, 1000); const text = agg.renderPrometheusText(7001); - assert.ok(!text.includes('game_fps{process="iRacingSim64DX11.exe"}')); + assert.ok(!text.includes('game_fps{')); +}); + +test('connector map: attaches a connector label when the output ID is known', () => { + const agg = new MetricsAggregator(['iRacingSim64DX11.exe'], { '0': 'DisplayPort', '1': 'HDMI' }); + agg.recordRow({ Application: 'iRacingSim64DX11.exe', VidPnSourceId: '1', MsBetweenPresents: '10', MsBetweenDisplayChange: '10', DisplayedTime: '10' }, 1000); + const text = agg.renderPrometheusText(1000); + assert.ok(text.includes('frames_dropped_total{process="iRacingSim64DX11.exe",output="1",connector="HDMI"} 0')); +}); + +test('connector map: omits the connector label when the output ID is unknown to the map', () => { + const agg = new MetricsAggregator(['iRacingSim64DX11.exe'], { '0': 'DisplayPort' }); + agg.recordRow({ Application: 'iRacingSim64DX11.exe', VidPnSourceId: '7', MsBetweenPresents: '10', MsBetweenDisplayChange: '10', DisplayedTime: '10' }, 1000); + const text = agg.renderPrometheusText(1000); + assert.ok(text.includes('frames_dropped_total{process="iRacingSim64DX11.exe",output="7"} 0')); + assert.ok(!text.includes('connector=')); +}); + +test('connector map: absent entirely means no connector label anywhere', () => { + const agg = new MetricsAggregator(['iRacingSim64DX11.exe']); + agg.recordRow({ Application: 'iRacingSim64DX11.exe', VidPnSourceId: '0', MsBetweenPresents: '10', MsBetweenDisplayChange: '10', DisplayedTime: '10' }, 1000); + const text = agg.renderPrometheusText(1000); + assert.ok(!text.includes('connector=')); }); From 98c1c7817fd9c980bc7189e2ae5d2edcfbaf7a99 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 26 Jul 2026 15:49:15 -0400 Subject: [PATCH 08/10] feat(fps-exporter): one-shot elevated setup script Combines the display-topology map, service install, and Alloy scrape-target config change into a single script run from an elevated PowerShell on the real console session, in the order that matters (topology map before service start, so connector-map.json is present on first read). Co-Authored-By: Claude Sonnet 5 --- scripts/fps-exporter/setup-all.ps1 | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 scripts/fps-exporter/setup-all.ps1 diff --git a/scripts/fps-exporter/setup-all.ps1 b/scripts/fps-exporter/setup-all.ps1 new file mode 100644 index 0000000..aab9415 --- /dev/null +++ b/scripts/fps-exporter/setup-all.ps1 @@ -0,0 +1,71 @@ +<# +.SYNOPSIS + One-shot elevated setup: display-topology map, FpsExporter service install, and the Alloy + scrape-target config change — everything needed to get game_fps/display_fps/frames_dropped_total + flowing into Grafana Cloud that can't be done from a non-elevated or remote session. + +.DESCRIPTION + Run this from an elevated PowerShell, on your own physical console session (not RDP) — the + display-topology step specifically needs a real interactive desktop to see real monitors. + + Order matters: the display-topology map is generated before the service starts, so the + service picks it up on its first read (connector-map.json is only read once at startup). + +.EXAMPLE + cd C:\Users\winth\dev\sim-steward\simhub-plugin + .\scripts\fps-exporter\setup-all.ps1 +#> + +$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() +$isAdmin = (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + Write-Error "Run this from an elevated (Administrator) PowerShell." + exit 1 +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +Write-Output "=== 1/4: Mapping monitors to connector types ===" +& (Join-Path $scriptDir "dump-display-topology.ps1") + +Write-Output "`n=== 2/4: Installing fps-exporter as a Windows Service ===" +& (Join-Path $scriptDir "install-service.ps1") + +Write-Output "`n=== 3/4: Verifying the service ===" +Get-Service FpsExporter +try { + $resp = Invoke-WebRequest -Uri "http://127.0.0.1:9101/metrics" -TimeoutSec 5 -UseBasicParsing + Write-Output "metrics endpoint: HTTP $($resp.StatusCode)" + Write-Output $resp.Content +} catch { + Write-Warning "metrics endpoint not reachable yet: $($_.Exception.Message)" +} + +Write-Output "`n=== 4/4: Adding the Alloy scrape target ===" +$alloyConfig = "C:\Program Files\GrafanaLabs\Alloy\config.alloy" +if (-not (Test-Path $alloyConfig)) { + Write-Warning "Alloy config not found at $alloyConfig — skipping this step, add it manually." +} elseif (Select-String -Path $alloyConfig -Pattern 'fps_exporter' -Quiet) { + Write-Output "fps_exporter scrape block already present in config.alloy, skipping." +} else { + Copy-Item $alloyConfig "$alloyConfig.bak-$(Get-Date -Format yyyyMMdd-HHmmss)" + Add-Content -Path $alloyConfig -Value @' + +prometheus.scrape "fps_exporter" { + targets = [ + {"__address__" = "127.0.0.1:9101", "instance" = "Win-Pc"}, + ] + scrape_interval = "5s" + forward_to = [prometheus.remote_write.grafana_cloud.receiver] +} +'@ + Write-Output "config.alloy updated (backup saved alongside it)." + Restart-Service Alloy + Get-Service Alloy +} + +Write-Output "" +Write-Output "Done. Launch iRacing or the pit-wall browser, wait ~10s, then check:" +Write-Output " curl.exe http://127.0.0.1:9101/metrics" +Write-Output "Real game_fps/display_fps values (not just frames_dropped_total) confirm the whole pipeline works." +Write-Output "Not yet verified anywhere in this session: killing the PresentMon child process and confirming it respawns within ~5s. Worth doing once, manually, via Task Manager + %LOCALAPPDATA%\FpsExporter\fps-exporter.log." From 4d9d400c9abf2a29649011c518352d9e2257933d Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 26 Jul 2026 15:55:43 -0400 Subject: [PATCH 09/10] fix(fps-exporter): hardcode paths instead of os.homedir()/%LOCALAPPDATA% The service runs as LocalSystem (install-service.ps1), under which both resolve to C:\Windows\system32\config\systemprofile rather than the interactive user's profile. This sent PresentMon.exe lookup to a path that never existed, crashing the process immediately on every service start until NSSM's crash-loop protection paused the service entirely. Confirmed via NSSM's own stdout capture after the first live deploy attempt: "PresentMon.exe not found at C:\Windows\system32\config\systemprofile\Tools\PresentMon\PresentMon.exe". Single-user personal machine, so a fixed absolute path is the correct fix, not environment-derived resolution. Co-Authored-By: Claude Sonnet 5 --- scripts/fps-exporter/fps-exporter.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/fps-exporter/fps-exporter.js b/scripts/fps-exporter/fps-exporter.js index 697f524..5613718 100644 --- a/scripts/fps-exporter/fps-exporter.js +++ b/scripts/fps-exporter/fps-exporter.js @@ -4,18 +4,21 @@ const { spawn } = require('node:child_process'); const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); -const os = require('node:os'); const { parseHeader, parseRow } = require('./presentmon-csv.js'); const { MetricsAggregator } = require('./metrics-aggregator.js'); const { INITIAL_BACKOFF_MS, backoffAfterExit } = require('./backoff.js'); +// Hardcoded, not derived from os.homedir()/%LOCALAPPDATA% — this runs as a LocalSystem +// Windows Service (see install-service.ps1), and under that account both resolve to +// C:\Windows\system32\config\systemprofile, not the interactive user's profile. This is a +// single-user personal machine, so a fixed absolute path is correct here, not a compromise. const ALLOWLIST = ['iRacingSim64DX11.exe', 'chrome.exe']; -const PRESENTMON_PATH = path.join(os.homedir(), 'Tools', 'PresentMon', 'PresentMon.exe'); +const PRESENTMON_PATH = 'C:\\Users\\winth\\Tools\\PresentMon\\PresentMon.exe'; const CONNECTOR_MAP_PATH = path.join(__dirname, 'connector-map.json'); const METRICS_PORT = 9101; const METRICS_HOST = '127.0.0.1'; -const LOG_DIR = path.join(process.env.LOCALAPPDATA || os.tmpdir(), 'FpsExporter'); +const LOG_DIR = 'C:\\Users\\winth\\AppData\\Local\\FpsExporter'; const LOG_FILE = path.join(LOG_DIR, 'fps-exporter.log'); function log(message) { From 59dab8c100d4448c26a9dc7bdd1a4fd89f69f245 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 26 Jul 2026 15:57:23 -0400 Subject: [PATCH 10/10] chore(fps-exporter): commit connector-map.json placeholder before Windows reinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Still the placeholder "Unknown" value from a remote/RDP session run of dump-display-topology.ps1, not real topology data — that still needs to be regenerated from the physical console after reinstall. Committing now purely so nothing is lost in the wipe; safe to overwrite later. Co-Authored-By: Claude Sonnet 5 --- scripts/fps-exporter/connector-map.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 scripts/fps-exporter/connector-map.json diff --git a/scripts/fps-exporter/connector-map.json b/scripts/fps-exporter/connector-map.json new file mode 100644 index 0000000..a9c7f67 --- /dev/null +++ b/scripts/fps-exporter/connector-map.json @@ -0,0 +1,3 @@ +{ + "0": "Unknown(4294967295)" +}