Skip to content

FPS capture (game + display + per-monitor) into Grafana via Alloy - #67

Open
simsteward wants to merge 10 commits into
mainfrom
feat/fps-capture-grafana
Open

FPS capture (game + display + per-monitor) into Grafana via Alloy#67
simsteward wants to merge 10 commits into
mainfrom
feat/fps-capture-grafana

Conversation

@simsteward

Copy link
Copy Markdown
Owner

Summary

  • New standalone fps-exporter service (scripts/fps-exporter/): streams PresentMon's live frame-timing output, computes rolling 5s-window FPS per tracked process (iRacingSim64DX11.exe, chrome.exe) and per video output (VidPnSourceId, via --write_display_metadata), and serves game_fps/display_fps/frames_dropped_total as Prometheus metrics on 127.0.0.1:9101
  • game_fps = frames the app hands to the driver; display_fps = frames that actually reach the screen (excludes dropped frames) — when they diverge for the same process/output, that's a display/compositor-side stutter, not the app itself hitching
  • Optional connector label (HDMI/DisplayPort/DVI/...) via connector-map.json, produced by dump-display-topology.ps1 — run from the user's own interactive desktop session, since the service itself runs in Session 0 and can't see real monitor topology
  • install-service.ps1 registers it as a Windows Service (NSSM via winget, LocalSystem) so PresentMon's admin/ETW requirement is satisfied once at install time
  • Grafana: new "Frame Rate" row + at-a-glance stat tiles integrated into the existing "Desktop Telemetry" dashboard (not a new dashboard), matching its established color system and conventions — done live against Grafana Cloud, not checked into this repo (no provisioned dashboard JSON exists for this dashboard)
  • Design rationale (why standalone service, not embedded in SimHub or Docker) in docs/superpowers/specs/2026-07-26-fps-capture-grafana-design.md; task-by-task plan in docs/superpowers/plans/2026-07-26-fps-capture-grafana.md

Test plan

  • node --test "scripts/fps-exporter/*.test.js" — 18/18 pass
  • node --check on fps-exporter.js — no syntax errors
  • Non-elevated smoke test of fps-exporter.js: HTTP 200, correct zero-state Prometheus output, no fabricated game_fps/display_fps
  • dump-display-topology.ps1: verified the P/Invoke QueryDisplayConfig code compiles and runs without crashing (correctly reported "no real topology" for the current remote/RDP session's virtual display rather than fabricating data)
  • Elevated: install-service.ps1 actually run, FpsExporter service confirmed Running
  • Elevated: live smoke test with iRacing/Chrome actively rendering — confirm game_fps/display_fps populate with plausible values and gap out (not 0) after the app closes
  • Elevated: kill the PresentMon child process once, confirm the exporter detects the exit and respawns within the backoff window — this path has never been exercised live
  • dump-display-topology.ps1 run from the real desktop session (not RDP) to produce a real connector-map.json
  • Alloy config.alloy scrape-target block added + Restart-Service Alloy, confirm game_fps etc. actually land in Grafana Cloud

🤖 Generated with Claude Code

wgutmann and others added 10 commits July 26, 2026 15:10
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 <noreply@anthropic.com>
game_fps/display_fps/frames_dropped_total now carry an output="<id>"
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…dows reinstall

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants