Skip to content

Commit 90a8fec

Browse files
feat(audience): typed track() surface for v1 event catalogue
Adds the v1 typed event surface to @imtbl/audience: 11 events (sign_up, sign_in, wishlist_add, wishlist_remove, purchase, game_launch, progression, resource, email_acquired, game_page_viewed, link_clicked), each with a property interface and compile-time enforcement on the track() call site. Uses a single generic track<E extends string>(event, ...args) signature backed by a PropsFor<E> conditional type. Traditional overloads fall through to the string catch-all when the properties object is incomplete (TypeScript picks the more-specific overload's first arg match, sees the second arg is not assignable, then silently picks the less-specific overload that accepts any object). The generic approach does not fall through and produces a real error for missing, wrong-type, and extra properties. A variadic-tuple conditional ({} extends PropsFor<E>) makes the properties argument required for events that have required fields and optional for events where every field is optional. Extended events (email_acquired, game_page_viewed, link_clicked) carry over from immutable/play#5151 with two normalisations: - isLoggedIn is dropped from all three shapes. The SDK already knows logged-in state via identify() + consent level; pipeline consumers should derive is_logged_in from userId IS NOT NULL on the events table rather than accept a duplicated property from every call site. - source is a free string. Play keeps its AudienceSource enum locally and passes its values into the SDK as strings. Runtime is unchanged modulo one type cast in the enqueue call (widening PropsFor<E> to Record<string, unknown>). Bundle size unchanged. The track() method body's runtime logic is identical to baseline. Type safety is asserted by a new sdk.test-d.ts file that uses @ts-expect-error to prove negative cases fail typecheck — missing required properties, extra properties, wrong value types, wrong enum values, and zero-argument calls on required-props events. The file is included in tsc but skipped by Jest (its .test-d.ts suffix does not match the default test pattern). Runtime coverage: five new it blocks in sdk.test.ts exercise sign_up, game_launch, progression, resource, and wishlist_add end-to-end through the queue flush path. 54 tests passing (up from 49). Independent of the feat/audience-web-sdk-demo branch and the Phase 0 audience-foundations work. No shared files; merges in any order. Spec: docs/superpowers/specs/2026-04-13-websdk-typed-events-design.md Plan: docs/superpowers/plans/2026-04-13-websdk-typed-events.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fc9f900 commit 90a8fec

5 files changed

Lines changed: 366 additions & 3 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
export const AudienceEvents = {
2+
SIGN_UP: 'sign_up',
3+
SIGN_IN: 'sign_in',
4+
WISHLIST_ADD: 'wishlist_add',
5+
WISHLIST_REMOVE: 'wishlist_remove',
6+
PURCHASE: 'purchase',
7+
8+
GAME_LAUNCH: 'game_launch',
9+
PROGRESSION: 'progression',
10+
RESOURCE: 'resource',
11+
12+
EMAIL_ACQUIRED: 'email_acquired',
13+
GAME_PAGE_VIEWED: 'game_page_viewed',
14+
LINK_CLICKED: 'link_clicked',
15+
} as const;
16+
17+
export interface SignUpProperties {
18+
method?: string;
19+
}
20+
21+
export interface SignInProperties {
22+
method?: string;
23+
}
24+
25+
export interface WishlistAddProperties {
26+
gameId: string;
27+
source?: string;
28+
platform?: string;
29+
}
30+
31+
export interface WishlistRemoveProperties {
32+
gameId: string;
33+
}
34+
35+
export interface PurchaseProperties {
36+
currency: string;
37+
value: number;
38+
itemId?: string;
39+
itemName?: string;
40+
quantity?: number;
41+
transactionId?: string;
42+
}
43+
44+
export interface GameLaunchProperties {
45+
platform?: string;
46+
version?: string;
47+
buildId?: string;
48+
}
49+
50+
export type ProgressionStatus = 'start' | 'complete' | 'fail';
51+
52+
export interface ProgressionProperties {
53+
status: ProgressionStatus;
54+
world?: string;
55+
level?: string;
56+
stage?: string;
57+
score?: number;
58+
durationSec?: number;
59+
}
60+
61+
export type ResourceFlow = 'sink' | 'source';
62+
63+
export interface ResourceProperties {
64+
flow: ResourceFlow;
65+
currency: string;
66+
amount: number;
67+
itemType?: string;
68+
itemId?: string;
69+
}
70+
71+
export interface EmailAcquiredProperties {
72+
source?: string;
73+
}
74+
75+
export interface GamePageViewedProperties {
76+
gameId: string;
77+
gameName?: string;
78+
slug?: string;
79+
}
80+
81+
export interface LinkClickedProperties {
82+
url: string;
83+
label?: string;
84+
source?: string;
85+
gameId?: string;
86+
}
87+
88+
interface EventPropsMap {
89+
sign_up: SignUpProperties;
90+
sign_in: SignInProperties;
91+
wishlist_add: WishlistAddProperties;
92+
wishlist_remove: WishlistRemoveProperties;
93+
purchase: PurchaseProperties;
94+
game_launch: GameLaunchProperties;
95+
progression: ProgressionProperties;
96+
resource: ResourceProperties;
97+
email_acquired: EmailAcquiredProperties;
98+
game_page_viewed: GamePageViewedProperties;
99+
link_clicked: LinkClickedProperties;
100+
}
101+
102+
export type PropsFor<E extends string> =
103+
E extends keyof EventPropsMap ? EventPropsMap[E] : Record<string, unknown>;

packages/audience/sdk/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11
export { Audience } from './sdk';
2+
export { AudienceEvents } from './events';
23
export { IdentityType } from '@imtbl/audience-core';
34
export type { AudienceConfig } from './types';
5+
export type {
6+
EmailAcquiredProperties,
7+
GameLaunchProperties,
8+
GamePageViewedProperties,
9+
LinkClickedProperties,
10+
ProgressionProperties,
11+
ProgressionStatus,
12+
PurchaseProperties,
13+
ResourceFlow,
14+
ResourceProperties,
15+
SignInProperties,
16+
SignUpProperties,
17+
WishlistAddProperties,
18+
WishlistRemoveProperties,
19+
} from './events';
420
export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core';
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { Audience, AudienceEvents } from './index';
2+
3+
declare const sdk: Audience;
4+
5+
sdk.track('sign_up');
6+
sdk.track('sign_up', { method: 'email' });
7+
sdk.track('sign_in');
8+
sdk.track('sign_in', { method: 'passport' });
9+
sdk.track('wishlist_add', { gameId: 'abc' });
10+
sdk.track('wishlist_add', { gameId: 'abc', source: 'game_page', platform: 'steam' });
11+
sdk.track('wishlist_remove', { gameId: 'abc' });
12+
sdk.track('purchase', { currency: 'USD', value: 9.99 });
13+
sdk.track('purchase', {
14+
currency: 'USD',
15+
value: 9.99,
16+
itemId: 'sku_1',
17+
itemName: 'Gold Pack',
18+
quantity: 2,
19+
transactionId: 'txn_42',
20+
});
21+
sdk.track('game_launch');
22+
sdk.track('game_launch', { platform: 'webgl', version: '1.2.0', buildId: 'ci-42' });
23+
sdk.track('progression', { status: 'start' });
24+
sdk.track('progression', { status: 'complete', world: 'tutorial' });
25+
sdk.track('progression', {
26+
status: 'fail',
27+
world: 'forest',
28+
level: '5',
29+
stage: 'boss',
30+
score: 420,
31+
durationSec: 87,
32+
});
33+
sdk.track('resource', { flow: 'sink', currency: 'gold', amount: 50 });
34+
sdk.track('resource', {
35+
flow: 'source',
36+
currency: 'USD',
37+
amount: 9.99,
38+
itemType: 'iap',
39+
itemId: 'sku_1',
40+
});
41+
sdk.track('email_acquired');
42+
sdk.track('email_acquired', { source: 'linked_account_steam' });
43+
sdk.track('game_page_viewed', { gameId: 'abc' });
44+
sdk.track('game_page_viewed', { gameId: 'abc', gameName: 'Devilfish', slug: 'devilfish' });
45+
sdk.track('link_clicked', { url: 'https://example.com' });
46+
sdk.track('link_clicked', {
47+
url: 'https://example.com',
48+
label: 'Play Now',
49+
source: 'game_page',
50+
gameId: 'abc',
51+
});
52+
53+
sdk.track(AudienceEvents.SIGN_UP, { method: 'email' });
54+
sdk.track(AudienceEvents.PURCHASE, { currency: 'USD', value: 9.99 });
55+
sdk.track(AudienceEvents.PROGRESSION, { status: 'complete' });
56+
57+
// @ts-expect-error — missing required 'value'
58+
sdk.track('purchase', { currency: 'USD' });
59+
60+
// @ts-expect-error — missing required 'gameId'
61+
sdk.track('wishlist_add', {});
62+
63+
// @ts-expect-error — missing required 'status'
64+
sdk.track('progression', { world: 'tutorial' });
65+
66+
// @ts-expect-error — missing required 'flow', 'currency', 'amount'
67+
sdk.track('resource', { itemType: 'iap' });
68+
69+
// @ts-expect-error — missing required 'url'
70+
sdk.track('link_clicked', { label: 'Play Now' });
71+
72+
// @ts-expect-error — missing required 'gameId'
73+
sdk.track('game_page_viewed', {});
74+
75+
// @ts-expect-error — purchase requires properties
76+
sdk.track('purchase');
77+
78+
// @ts-expect-error — wishlist_add requires properties
79+
sdk.track('wishlist_add');
80+
81+
// @ts-expect-error — wishlist_remove requires properties
82+
sdk.track('wishlist_remove');
83+
84+
// @ts-expect-error — progression requires properties
85+
sdk.track('progression');
86+
87+
// @ts-expect-error — resource requires properties
88+
sdk.track('resource');
89+
90+
// @ts-expect-error — game_page_viewed requires properties
91+
sdk.track('game_page_viewed');
92+
93+
// @ts-expect-error — link_clicked requires properties
94+
sdk.track('link_clicked');
95+
96+
// @ts-expect-error — unknown property 'currenyc'
97+
sdk.track('purchase', { currenyc: 'USD', value: 9.99 });
98+
99+
// @ts-expect-error — unknown property 'extra'
100+
sdk.track('purchase', { currency: 'USD', value: 9.99, extra: 1 });
101+
102+
// @ts-expect-error — unknown property 'isLoggedIn'
103+
sdk.track('link_clicked', { url: 'x', isLoggedIn: true });
104+
105+
// @ts-expect-error — 'value' must be number
106+
sdk.track('purchase', { currency: 'USD', value: '9.99' });
107+
108+
// @ts-expect-error — 'status' must be a ProgressionStatus
109+
sdk.track('progression', { status: 'done' });
110+
111+
// @ts-expect-error — 'flow' must be a ResourceFlow
112+
sdk.track('resource', { flow: 'both', currency: 'gold', amount: 1 });
113+
114+
sdk.track('beta_key_redeemed', { source: 'influencer' });
115+
sdk.track('discord_joined');
116+
sdk.track('trailer_watched', { duration: 45, platform: 'youtube' });
117+
118+
declare const dynamicName: string;
119+
sdk.track(dynamicName, { anything: 'goes' });
120+
sdk.track(dynamicName);

packages/audience/sdk/src/sdk.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,123 @@ describe('Audience', () => {
235235

236236
sdk.shutdown();
237237
});
238+
239+
it('enqueues sign_up with method property', async () => {
240+
const sdk = createSDK();
241+
242+
sdk.track('sign_up', { method: 'email' });
243+
await sdk.flush();
244+
245+
const msg = sentMessages().find(
246+
(m: any) => m.type === 'track' && m.eventName === 'sign_up',
247+
);
248+
expect(msg).toBeDefined();
249+
expect(msg.properties).toEqual({ method: 'email' });
250+
251+
sdk.shutdown();
252+
});
253+
254+
it('enqueues game_launch with optional fields', async () => {
255+
const sdk = createSDK();
256+
257+
sdk.track('game_launch', {
258+
platform: 'webgl',
259+
version: '1.2.0',
260+
buildId: 'ci-42',
261+
});
262+
await sdk.flush();
263+
264+
const msg = sentMessages().find(
265+
(m: any) => m.type === 'track' && m.eventName === 'game_launch',
266+
);
267+
expect(msg).toBeDefined();
268+
expect(msg.properties).toEqual({
269+
platform: 'webgl',
270+
version: '1.2.0',
271+
buildId: 'ci-42',
272+
});
273+
274+
sdk.shutdown();
275+
});
276+
277+
it('enqueues progression with status and optional gameplay fields', async () => {
278+
const sdk = createSDK();
279+
280+
sdk.track('progression', {
281+
status: 'complete',
282+
world: 'forest',
283+
level: '5',
284+
stage: 'boss',
285+
score: 420,
286+
durationSec: 87,
287+
});
288+
await sdk.flush();
289+
290+
const msg = sentMessages().find(
291+
(m: any) => m.type === 'track' && m.eventName === 'progression',
292+
);
293+
expect(msg).toBeDefined();
294+
expect(msg.properties).toEqual({
295+
status: 'complete',
296+
world: 'forest',
297+
level: '5',
298+
stage: 'boss',
299+
score: 420,
300+
durationSec: 87,
301+
});
302+
303+
sdk.shutdown();
304+
});
305+
306+
it('enqueues resource with flow, currency, and amount', async () => {
307+
const sdk = createSDK();
308+
309+
sdk.track('resource', {
310+
flow: 'source',
311+
currency: 'gold',
312+
amount: 50,
313+
itemType: 'quest_reward',
314+
itemId: 'quest_42',
315+
});
316+
await sdk.flush();
317+
318+
const msg = sentMessages().find(
319+
(m: any) => m.type === 'track' && m.eventName === 'resource',
320+
);
321+
expect(msg).toBeDefined();
322+
expect(msg.properties).toEqual({
323+
flow: 'source',
324+
currency: 'gold',
325+
amount: 50,
326+
itemType: 'quest_reward',
327+
itemId: 'quest_42',
328+
});
329+
330+
sdk.shutdown();
331+
});
332+
333+
it('enqueues wishlist_add with gameId and optional fields', async () => {
334+
const sdk = createSDK();
335+
336+
sdk.track('wishlist_add', {
337+
gameId: 'devilfish',
338+
source: 'game_page',
339+
platform: 'steam',
340+
});
341+
await sdk.flush();
342+
343+
const msg = sentMessages().find(
344+
(m: any) => m.type === 'track' && m.eventName === 'wishlist_add',
345+
);
346+
expect(msg).toBeDefined();
347+
expect(msg.properties).toEqual({
348+
gameId: 'devilfish',
349+
source: 'game_page',
350+
platform: 'steam',
351+
});
352+
353+
sdk.shutdown();
354+
});
238355
});
239356

240357
describe('page', () => {
@@ -537,7 +654,7 @@ describe('Audience', () => {
537654
expect(document.cookie).toContain(`${COOKIE_NAME}=`);
538655

539656
sdk.identify(TEST_USER.id, TEST_USER.identityType);
540-
sdk.track('purchase', { value: 9.99 });
657+
sdk.track('purchase', { currency: 'USD', value: 9.99 });
541658
await sdk.flush();
542659

543660
const trackMsg = sentMessages().find(

0 commit comments

Comments
 (0)