Files
stern-api/PLAY_HISTORY_STACK.md

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_SECONDS therefore 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_SECONDS in .env controls 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_PASSWORD and 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 down stops the stack. Add -v only 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