5.2 KiB
Play-history Grafana stack
What it does
docker compose runs three services:
- collector logs in, polls the per-title score feeds, and records each individual game it sees.
- PostgreSQL stores individual games (
game_event) and per-visit rollups (play_session). - Grafana provisions a PostgreSQL datasource and a dashboard of individual scores, personal bests over time, and capture coverage.
How individual games are captured, and what that costs
The Stern API has no per-game history endpoint. This is the central constraint, and it shapes everything else.
The endpoint this project originally used, /api/v1/portal/my_activity/,
returns a daily rollup: one row per (date, machine model, location) carrying
only max_score and total_plays. A day with 21 games on one machine collapses
to a single number, and the other 20 scores are simply not in the response.
Individual scores are exposed in exactly one place — as the most recent game:
| Endpoint | Gives |
|---|---|
/api/v1/portal/user_title_stats/?user_id=<pk>&title_id=<id> |
most_recent_score + most_recent_date for that title |
/api/v1/portal/user_highlights/?user_id=<username> |
the most recent game overall |
Those are real single-game scores, not maxima. A title's most recent score, its all-time best, and a visit's best are three different values; the collector uses the first one.
So the collector polls and captures, rather than fetching history. Each poll
reads every title you have played and stores any (title_id, played_at) pair it
has not seen. The play timestamp is the primary key, so re-observing the same
game is idempotent.
Two consequences follow directly, and the dashboard reports both rather than hiding them:
- Games played before the collector started cannot be recovered. They are not retrievable from any endpoint.
- Two games on the same title within one poll interval yield only the later
one.
POLL_SECONDStherefore bounds fidelity; 60s against a 2-5 minute game is comfortable, 300s is not.
The capture_coverage view and the dashboard's top panel compare games captured
against the play count the account reports, so the gap is always visible.
play_session keeps the per-visit rollup from /api/v1/portal/user_activities/
(location, timestamp, play count, visit high score). It is aggregate data, but it
is the only record of pre-collector history and it supplies the play counts that
make the coverage number meaningful.
Endpoints that do not work
The /api/v4/ tier (user/activity/, recent/played_games/,
player/model_scores/, stats/model_scores/, user/info/) returns HTTP 500 on
this server for every parameter combination tried, with both a /api/v2/token/
JWT and a /api/v4/auth/login/ JWT. Only /api/v4/stats/scores/ responds, and
it returns per-title bests, ignoring its parameters.
/api/v1/portal/user_activities_from_session_details/ returns the same aggregate
shape as user_activities and ignores score_key.
Start
cp .env.example .env
# Edit .env: set API_USERNAME, API_PASSWORD, POSTGRES_PASSWORD, and GRAFANA_ADMIN_PASSWORD.
docker compose up --build -d
Open http://localhost:3000, log in with GRAFANA_ADMIN_USER and
GRAFANA_ADMIN_PASSWORD, then open Dashboards → Play History → Stern Play
History.
Expect the coverage panel to show a large uncaptured count at first — that is accurate, and it shrinks only in the sense that newly played games are captured from here on.
Verify
python3 -m unittest discover -s tests -v # parsing tests, no DB or network needed
docker compose ps
docker compose logs collector --tail=50
docker compose exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-c 'SELECT * FROM capture_coverage;' \
-c 'SELECT played_at, title_name, score FROM game_event ORDER BY played_at DESC LIMIT 10;'
The first poll captures one game per title you have played (each title's most recent), plus the session rollup. Subsequent polls insert only genuinely new games.
Operations
POLL_SECONDSin.envcontrols capture fidelity; the collector enforces a 30-second minimum.- Grafana is bound to localhost by default. Do not expose it publicly without TLS and a stronger access-control layer.
API_PASSWORDand JWTs are never stored in PostgreSQL or written to the logs.- The collector authenticates once per poll cycle. Very short intervals mean frequent logins; the server appears to throttle repeated authentication, so staying at or above the 30-second floor matters.
docker compose downstops the stack. Add-vonly if you intentionally want to delete stored history — captured games cannot be re-fetched.
Schema
game_event(title_id, played_at, score, title_name, title_code, first_seen_at, source)
PRIMARY KEY (title_id, played_at) -- one row per game actually played
play_session(score_key, session_number, session_at, location_id, location_name,
title_name, model_type, num_plays, high_score, updated_at)
PRIMARY KEY (score_key) -- one row per title per visit
collection_run(id, observed_at, events_captured, sessions_upserted, reported_total_plays)
capture_coverage -- view: games_captured vs games_played_reported