feat: add gameplay lifecycle telemetry and admin analytics#153
feat: add gameplay lifecycle telemetry and admin analytics#153tuxerrante wants to merge 2 commits into
Conversation
Track started, completed, and abandoned sessions as append-only telemetry so admin analytics reflect real gameplay outcomes instead of leaderboard submissions alone. Expose the resulting aggregates through a new admin API and UI while keeping the JSON and MSSQL storage paths aligned. Signed-off-by: Alessandro Affinito <aaffinit@redhat.com> Made-with: Cursor
There was a problem hiding this comment.
Pull request overview
Adds an append-only gameplay lifecycle telemetry model (started/completed/abandoned) across frontend + backend, persists it to JSON/MSSQL metrics storage, and exposes an admin analytics API + UI for aggregated insights.
Changes:
- Introduces shared telemetry/analytics types and frontend runtime telemetry capture + emission.
- Adds backend
/api/gameplayingestion +/api/gameplay/adminanalytics, with MSSQL migration and store implementations. - Adds an
/adminanalytics page and updates docs to describe the new routes and storage behavior.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| shared/types/gameplay.ts | Adds shared telemetry + analytics response types. |
| frontend/src/lib/gameplayTelemetry.ts | Builds/sends gameplay lifecycle telemetry payloads. |
| frontend/src/lib/gameplayTelemetry.test.ts | Unit tests for telemetry payload helpers. |
| frontend/src/components/scoring/ScoreBreakdown.tsx | Reuses shared grade calculation helper. |
| frontend/src/app/page.tsx | Adds navigation link to admin analytics page. |
| frontend/src/app/game/page.tsx | Emits completed/abandoned lifecycle telemetry from gameplay flow. |
| frontend/src/app/admin/page.tsx | Adds admin analytics UI consuming /api/gameplay/admin. |
| docs/ARCHITECTURE.md | Documents new gameplay telemetry/admin endpoints and storage behavior. |
| backend/src/routes/scenario.ts | Records started lifecycle event when creating a session. |
| backend/src/routes/scenario.test.ts | Tests that session creation records a started lifecycle event. |
| backend/src/routes/gameplay.ts | Adds telemetry ingestion route + admin analytics API. |
| backend/src/routes/gameplay.test.ts | Tests ingestion + analytics summarization behavior. |
| backend/src/lib/storage/types.ts | Extends storage interfaces for lifecycle analytics + session lookup. |
| backend/src/lib/storage/mssql-stores.test.ts | Updates MSSQL unit tests for new fields + analytics mapping. |
| backend/src/lib/storage/mssql-session-store.ts | Adds get() for non-consuming session lookup. |
| backend/src/lib/storage/mssql-metrics-store.ts | Persists lifecycle fields + implements aggregate analytics queries. |
| backend/src/lib/storage/migrations/002_gameplay_lifecycle_events.sql | Adds lifecycle/aggregate-friendly columns + indexes. |
| backend/src/lib/storage/json-session-store.ts | Adds get() for JSON session backend. |
| backend/src/lib/storage/json-metrics-store.ts | Stores lifecycle events in-memory and computes analytics. |
| backend/src/integration/mssql-stores.integration.test.ts | Extends integration coverage for lifecycle + analytics. |
| backend/src/index.ts | Wires new /api/gameplay router into the backend app. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await getMetricsStore().recordGameplay({ | ||
| sessionToken, | ||
| difficulty, | ||
| scenarioTitle: scenario.title, | ||
| lifecycleState: "started", | ||
| completed: false, | ||
| metadata: { source: "scenario" }, | ||
| }); |
There was a problem hiding this comment.
recordGameplay is awaited during scenario creation in mock mode; if metrics storage is unavailable or errors, this will fail the entire /api/scenario request (even though telemetry is ancillary). Consider making the telemetry write best-effort (catch/log and still return the scenario/session token).
| await getMetricsStore().recordGameplay({ | |
| sessionToken, | |
| difficulty, | |
| scenarioTitle: scenario.title, | |
| lifecycleState: "started", | |
| completed: false, | |
| metadata: { source: "scenario" }, | |
| }); | |
| try { | |
| await getMetricsStore().recordGameplay({ | |
| sessionToken, | |
| difficulty, | |
| scenarioTitle: scenario.title, | |
| lifecycleState: "started", | |
| completed: false, | |
| metadata: { source: "scenario" }, | |
| }); | |
| } catch (error) { | |
| console.error("Failed to record scenario gameplay metrics", { | |
| sessionToken, | |
| difficulty, | |
| error, | |
| }); | |
| } |
| await getMetricsStore().recordGameplay({ | ||
| sessionToken, | ||
| difficulty, | ||
| scenarioTitle: scenario.title, | ||
| lifecycleState: "started", | ||
| completed: false, | ||
| metadata: { source: "scenario" }, | ||
| }); |
There was a problem hiding this comment.
Same as above: in the non-mock flow, a failure to persist gameplay telemetry will currently cause /api/scenario to return 500 after the AI call and session creation. If telemetry is intended to be best-effort, wrap the metrics write in its own try/catch so scenario generation remains reliable.
| await getMetricsStore().recordGameplay({ | |
| sessionToken, | |
| difficulty, | |
| scenarioTitle: scenario.title, | |
| lifecycleState: "started", | |
| completed: false, | |
| metadata: { source: "scenario" }, | |
| }); | |
| try { | |
| await getMetricsStore().recordGameplay({ | |
| sessionToken, | |
| difficulty, | |
| scenarioTitle: scenario.title, | |
| lifecycleState: "started", | |
| completed: false, | |
| metadata: { source: "scenario" }, | |
| }); | |
| } catch (error) { | |
| console.warn("Failed to record scenario gameplay telemetry", { | |
| sessionToken, | |
| difficulty, | |
| scenarioTitle: scenario.title, | |
| error, | |
| }); | |
| } |
| navigator.sendBeacon("/api/gameplay", blob); | ||
| return; |
There was a problem hiding this comment.
navigator.sendBeacon() returns a boolean indicating whether the data was successfully queued. The current code returns immediately even if sendBeacon returns false, which can drop telemetry silently. Consider checking the return value and falling back to fetch when it’s false.
| navigator.sendBeacon("/api/gameplay", blob); | |
| return; | |
| const queued = navigator.sendBeacon("/api/gameplay", blob); | |
| if (queued) { | |
| return; | |
| } |
| <Link href="/admin" className="hover:text-zinc-200 transition-colors"> | ||
| Admin Analytics | ||
| </Link> | ||
| <span className="mx-2">·</span> |
There was a problem hiding this comment.
This adds a globally visible navigation link to an admin analytics page. Given the backend admin analytics endpoint is unauthenticated, this effectively exposes internal telemetry to all users. If the intent is for admins only, consider gating the link and the page behind an auth check or an environment/feature flag.
|
|
||
| const records: GameplayRecord[] = []; |
There was a problem hiding this comment.
The JSON metrics backend keeps all GameplayRecords in a process-global records array with no cap/TTL. In long-running json-backend deployments (or dev sessions), this can grow without bound and increase memory usage over time. Consider adding a retention limit (e.g., keep last N records) and/or TTL-based cleanup.
| const records: GameplayRecord[] = []; | |
| const MAX_RECORDS = 10000; | |
| function trimRecords(items: GameplayRecord[]): void { | |
| if (items.length <= MAX_RECORDS) return; | |
| items.splice(0, items.length - MAX_RECORDS); | |
| } | |
| const records: GameplayRecord[] = []; | |
| const originalPush = records.push.bind(records); | |
| records.push = (...items: GameplayRecord[]): number => { | |
| const length = originalPush(...items); | |
| trimRecords(records); | |
| return length > MAX_RECORDS ? MAX_RECORDS : length; | |
| }; |
| gameplayRouter.get("/admin", async (_req: Request, res: Response) => { | ||
| try { | ||
| const analytics = await getMetricsStore().getGameplayAnalytics(); | ||
| res.json(analytics); | ||
| } catch (error) { | ||
| const message = | ||
| error instanceof Error ? error.message : "Failed to fetch gameplay analytics"; | ||
| res.status(500).json({ error: message }); | ||
| } |
There was a problem hiding this comment.
GET /api/gameplay/admin returns session telemetry (including nicknames and scenario titles) without any authentication/authorization. Since this is an admin-facing endpoint, it should be protected (e.g., require an admin secret header/JWT, restrict by env/cluster network policy, or disable entirely unless explicitly enabled).
| await getMetricsStore().recordGameplay({ | ||
| sessionToken: body.sessionToken, | ||
| nickname: typeof body.nickname === "string" ? body.nickname.trim() || undefined : undefined, | ||
| difficulty: session.difficulty, | ||
| scenarioTitle: session.scenarioTitle, | ||
| lifecycleState: body.lifecycleState, | ||
| commandCount: body.commandCount, | ||
| commandsExecuted: Array.isArray(body.commandsExecuted) ? body.commandsExecuted : [], | ||
| scoringEvents: Array.isArray(body.scoringEvents) ? body.scoringEvents : [], | ||
| chatMessageCount: body.chatMessageCount, | ||
| durationMs: body.durationMs, | ||
| scoreTotal: body.scoreTotal, | ||
| grade: body.grade, | ||
| completed: body.lifecycleState === "completed", | ||
| metadata: body.metadata ?? {}, | ||
| }); |
There was a problem hiding this comment.
The POST body fields forwarded into recordGameplay aren’t type/size validated. For example, nickname can exceed the NVARCHAR(20) DB column limit and numeric fields like commandCount/durationMs/scoreTotal may be non-numbers, which can cause SQL errors and turn best-effort telemetry into 500s. Consider clamping lengths, validating/coercing numbers (or dropping invalid values), and returning 202 even when optional fields are invalid.
Summary
started,completed,abandoned) in both JSON and MSSQL storage, including a new admin analytics API and SQL migration for aggregate-friendly fieldsstarted, the game client sendscompleted/abandoned, and the frontend now includes an/adminanalytics viewTest plan
make validatemake testmake test-integrationlocalhost:1433aftermake test-mssqlwas blocked by an existing port bindingDB_SECRET_NAME=sre-sql-creds make e2e-azure-routesre-manual-e2e-20260419-090333200Made with Cursor