Skip to content

Commit 387a936

Browse files
committed
Expand telemetry: anonymous IDs, D1 storage, new events
- Anonymous persistent client ID (localStorage UUID) attached to all telemetry and error payloads - New events: scenario_browsed, turn_completed (with per-phase timing), tutorial_started/completed/skipped - D1 database for persistent event storage with IP hash and UA - Server uses waitUntil for non-blocking D1 writes - Both /telemetry and /error endpoints write to D1 - 1120 tests across 66 suites
1 parent 2aceca8 commit 387a936

File tree

10 files changed

+490
-42
lines changed

10 files changed

+490
-42
lines changed

migrations/0001_create_events.sql

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Telemetry and error events table
2+
CREATE TABLE IF NOT EXISTS events (
3+
id INTEGER PRIMARY KEY AUTOINCREMENT,
4+
ts INTEGER NOT NULL,
5+
anon_id TEXT,
6+
event TEXT NOT NULL,
7+
props TEXT,
8+
ip_hash TEXT,
9+
ua TEXT,
10+
created TEXT NOT NULL DEFAULT (datetime('now'))
11+
);
12+
13+
CREATE INDEX idx_events_ts ON events(ts);
14+
CREATE INDEX idx_events_event ON events(event);
15+
CREATE INDEX idx_events_anon ON events(anon_id);

src/client/main.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ class GameClient {
163163
private connection: ConnectionManager;
164164
private turnTimer!: TurnTimerManager;
165165

166+
// Phase timing for telemetry
167+
private phaseStartedAt: number | null = null;
168+
private turnStartedAt: number | null = null;
169+
private phaseDurations: Record<string, number> = {};
170+
private lastTurnNumber = -1;
171+
166172
// Presentation orchestration deps
167173
// (lazy — renderer/ui available after constructor)
168174
private _presentationDeps: PresentationDeps | null = null;
@@ -284,6 +290,7 @@ class GameClient {
284290
);
285291
this.ui = new UIManager();
286292
this.tutorial = new Tutorial();
293+
this.tutorial.onTelemetry = (evt) => track(evt);
287294
this.tooltipEl = byId('shipTooltip');
288295

289296
this.connection = createConnectionManager({
@@ -393,8 +400,16 @@ class GameClient {
393400
}
394401

395402
private setState(newState: ClientState) {
403+
const prevState = this.ctx.state;
396404
this.ctx.state = newState;
397405

406+
// Accumulate phase duration for telemetry
407+
this.recordPhaseDuration(prevState);
408+
409+
if (newState.startsWith('playing_')) {
410+
this.phaseStartedAt = Date.now();
411+
}
412+
398413
// Hide tooltip on state changes
399414
hide(this.tooltipEl);
400415

@@ -568,6 +583,10 @@ class GameClient {
568583
this.ctx.scenario = scenario;
569584
this.ctx.playerId = 0;
570585
this.lastLoggedTurn = -1;
586+
this.lastTurnNumber = -1;
587+
this.turnStartedAt = null;
588+
this.phaseStartedAt = null;
589+
this.phaseDurations = {};
571590
this.renderer.setPlayerId(0);
572591
this.ctx.transport = this.createLocalTransport();
573592

@@ -851,6 +870,10 @@ class GameClient {
851870
this.dispatch({ type: 'exitToMenu' });
852871
return;
853872

873+
case 'backToMenu':
874+
track('scenario_browsed');
875+
return;
876+
854877
case 'selectShip':
855878
this.dispatch({
856879
type: 'selectShip',
@@ -1092,6 +1115,37 @@ class GameClient {
10921115
}
10931116
}
10941117

1118+
// --- Phase timing telemetry ---
1119+
1120+
private recordPhaseDuration(prevState: ClientState) {
1121+
if (this.phaseStartedAt !== null && prevState.startsWith('playing_')) {
1122+
const phase = prevState.replace('playing_', '');
1123+
// Skip non-player phases
1124+
if (phase !== 'opponentTurn' && phase !== 'movementAnim') {
1125+
const elapsed = Date.now() - this.phaseStartedAt;
1126+
this.phaseDurations[phase] =
1127+
(this.phaseDurations[phase] ?? 0) + elapsed;
1128+
}
1129+
this.phaseStartedAt = null;
1130+
}
1131+
}
1132+
1133+
private emitTurnCompleted() {
1134+
if (this.turnStartedAt === null) return;
1135+
1136+
const totalMs = Date.now() - this.turnStartedAt;
1137+
track('turn_completed', {
1138+
turn: this.lastTurnNumber,
1139+
totalMs,
1140+
phases: { ...this.phaseDurations },
1141+
scenario: this.ctx.scenario,
1142+
mode: this.ctx.isLocalGame ? 'local' : 'multiplayer',
1143+
});
1144+
1145+
this.phaseDurations = {};
1146+
this.turnStartedAt = Date.now();
1147+
}
1148+
10951149
// --- Game actions ---
10961150

10971151
private onAnimationComplete() {
@@ -1113,6 +1167,15 @@ class GameClient {
11131167
);
11141168

11151169
if (transition.turnLogNumber !== null && transition.turnLogPlayerLabel) {
1170+
// Emit turn_completed for the previous turn
1171+
if (this.lastTurnNumber > 0) {
1172+
this.emitTurnCompleted();
1173+
} else {
1174+
// First turn — just start timing
1175+
this.turnStartedAt = Date.now();
1176+
}
1177+
1178+
this.lastTurnNumber = transition.turnLogNumber;
11161179
this.lastLoggedTurn = transition.turnLogNumber;
11171180
this.ui.logTurn(transition.turnLogNumber, transition.turnLogPlayerLabel);
11181181
}

src/client/telemetry.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getOrCreateAnonId, type StorageLike } from './telemetry';
4+
5+
const mockStorage = (initial: Record<string, string> = {}): StorageLike => {
6+
const store = { ...initial };
7+
return {
8+
getItem: (key: string) => store[key] ?? null,
9+
setItem: (key: string, value: string) => {
10+
store[key] = value;
11+
},
12+
};
13+
};
14+
15+
describe('getOrCreateAnonId', () => {
16+
it('generates a UUID when storage is empty', () => {
17+
const storage = mockStorage();
18+
const id = getOrCreateAnonId(storage);
19+
20+
expect(id).toMatch(
21+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
22+
);
23+
});
24+
25+
it('persists the ID to storage', () => {
26+
const storage = mockStorage();
27+
const id = getOrCreateAnonId(storage);
28+
29+
expect(storage.getItem('deltav_anon_id')).toBe(id);
30+
});
31+
32+
it('returns existing ID from storage', () => {
33+
const storage = mockStorage({
34+
deltav_anon_id: 'existing-id',
35+
});
36+
const id = getOrCreateAnonId(storage);
37+
38+
expect(id).toBe('existing-id');
39+
});
40+
41+
it('returns same ID on repeated calls', () => {
42+
const storage = mockStorage();
43+
const first = getOrCreateAnonId(storage);
44+
const second = getOrCreateAnonId(storage);
45+
46+
expect(first).toBe(second);
47+
});
48+
49+
it('falls back to random ID when storage throws', () => {
50+
const storage: StorageLike = {
51+
getItem: () => {
52+
throw new Error('blocked');
53+
},
54+
setItem: () => {
55+
throw new Error('blocked');
56+
},
57+
};
58+
const id = getOrCreateAnonId(storage);
59+
60+
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/);
61+
});
62+
});

src/client/telemetry.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,48 @@
22
//
33
// Both track() and reportError() fire-and-forget POST
44
// to server endpoints. No PII is collected. Payloads are
5-
// structured JSON logged via console.* on the server,
6-
// captured automatically by Cloudflare Workers Logs.
5+
// structured JSON logged via console.* on the server
6+
// and stored in D1 for querying.
7+
8+
export interface StorageLike {
9+
getItem(key: string): string | null;
10+
setItem(key: string, value: string): void;
11+
}
12+
13+
const ANON_ID_KEY = 'deltav_anon_id';
14+
15+
export const getOrCreateAnonId = (storage: StorageLike): string => {
16+
try {
17+
const existing = storage.getItem(ANON_ID_KEY);
18+
if (existing) return existing;
19+
20+
const id = crypto.randomUUID();
21+
storage.setItem(ANON_ID_KEY, id);
22+
return id;
23+
} catch {
24+
// Fallback for incognito / storage disabled
25+
return crypto.randomUUID();
26+
}
27+
};
28+
29+
// Eagerly resolve on module load
30+
let anonId: string;
31+
try {
32+
anonId = getOrCreateAnonId(localStorage);
33+
} catch {
34+
anonId = crypto.randomUUID();
35+
}
736

837
const post = (path: string, body: Record<string, unknown>): void => {
938
try {
1039
fetch(path, {
1140
method: 'POST',
1241
headers: { 'Content-Type': 'application/json' },
13-
body: JSON.stringify(body),
42+
body: JSON.stringify({
43+
...body,
44+
anonId,
45+
ts: Date.now(),
46+
}),
1447
keepalive: true,
1548
}).catch(() => {});
1649
} catch {
@@ -21,11 +54,7 @@ const post = (path: string, body: Record<string, unknown>): void => {
2154
// --- Telemetry ---
2255

2356
export const track = (event: string, props?: Record<string, unknown>): void => {
24-
post('/telemetry', {
25-
event,
26-
...props,
27-
ts: Date.now(),
28-
});
57+
post('/telemetry', { event, ...props });
2958
};
3059

3160
// --- Error reporting ---
@@ -37,7 +66,6 @@ export const reportError = (
3766
post('/error', {
3867
error,
3968
...context,
40-
ts: Date.now(),
4169
url: window.location.href,
4270
ua: navigator.userAgent,
4371
});

src/client/tutorial.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ export class Tutorial {
6565
private progressEl: HTMLElement;
6666
private activeStepId: string | null = null;
6767

68+
/** External callback for telemetry events */
69+
onTelemetry: ((event: string) => void) | null = null;
70+
6871
constructor() {
6972
this.tipEl = byId('tutorialTip');
7073
this.textEl = byId('tutorialTipText');
@@ -114,6 +117,10 @@ export class Tutorial {
114117
}
115118

116119
private showStep(step: TutorialStep) {
120+
if (this.shownSteps.size === 0) {
121+
this.onTelemetry?.('tutorial_started');
122+
}
123+
117124
this.activeStepId = step.id;
118125
this.textEl.textContent = step.text;
119126
show(this.tipEl, 'block');
@@ -143,13 +150,15 @@ export class Tutorial {
143150

144151
// Check if all steps are shown
145152
if (this.shownSteps.size >= STEPS.length) {
153+
this.onTelemetry?.('tutorial_completed');
146154
this.complete();
147155
}
148156

149157
this.hideTip();
150158
}
151159

152160
private skip() {
161+
this.onTelemetry?.('tutorial_skipped');
153162
this.complete();
154163
this.hideTip();
155164
}

src/client/ui/events.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,6 @@ export type UIEvent =
2222
| { type: 'rematch' }
2323
| { type: 'exit' }
2424
| { type: 'selectShip'; shipId: string }
25-
| { type: 'chat'; text: string };
25+
| { type: 'chat'; text: string }
26+
// Navigation
27+
| { type: 'backToMenu' };

src/client/ui/ui.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export class UIManager {
189189
}
190190

191191
byId('backBtn').addEventListener('click', () => {
192+
this.onEvent?.({ type: 'backToMenu' });
192193
this.showMenu();
193194
});
194195

0 commit comments

Comments
 (0)