Skip to content

feat: add gameplay lifecycle telemetry and admin analytics#153

Open
tuxerrante wants to merge 2 commits into
mainfrom
feat/gameplay-lifecycle-telemetry
Open

feat: add gameplay lifecycle telemetry and admin analytics#153
tuxerrante wants to merge 2 commits into
mainfrom
feat/gameplay-lifecycle-telemetry

Conversation

@tuxerrante
Copy link
Copy Markdown
Owner

Summary

  • record append-only gameplay lifecycle events (started, completed, abandoned) in both JSON and MSSQL storage, including a new admin analytics API and SQL migration for aggregate-friendly fields
  • emit lifecycle telemetry from real runtime flow: scenario creation writes started, the game client sends completed/abandoned, and the frontend now includes an /admin analytics view
  • document the new telemetry/admin routes and storage behavior so future work can build on the same event model

Test plan

Made with Cursor

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
Copilot AI review requested due to automatic review settings April 19, 2026 08:25
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/gameplay ingestion + /api/gameplay/admin analytics, with MSSQL migration and store implementations.
  • Adds an /admin analytics 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.

Comment on lines +33 to +40
await getMetricsStore().recordGameplay({
sessionToken,
difficulty,
scenarioTitle: scenario.title,
lifecycleState: "started",
completed: false,
metadata: { source: "scenario" },
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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,
});
}

Copilot uses AI. Check for mistakes.
Comment on lines +143 to +150
await getMetricsStore().recordGameplay({
sessionToken,
difficulty,
scenarioTitle: scenario.title,
lifecycleState: "started",
completed: false,
metadata: { source: "scenario" },
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
});
}

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +83
navigator.sendBeacon("/api/gameplay", blob);
return;
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
navigator.sendBeacon("/api/gameplay", blob);
return;
const queued = navigator.sendBeacon("/api/gameplay", blob);
if (queued) {
return;
}

Copilot uses AI. Check for mistakes.
Comment thread frontend/src/app/page.tsx
Comment on lines +242 to +245
<Link href="/admin" className="hover:text-zinc-200 transition-colors">
Admin Analytics
</Link>
<span className="mx-2">&middot;</span>
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 4 to +5

const records: GameplayRecord[] = [];
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
};

Copilot uses AI. Check for mistakes.
Comment on lines +77 to +85
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 });
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +67
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 ?? {},
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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