Skip to content

Commit cab425c

Browse files
docs(audience): audit follow-up — README cohesion and runtime helper consumption
Follow-up to SDK-49's sample-app PR after a cross-product documentation audit comparing the audience and pixel READMEs. Fixes a cluster of correctness, cohesion, and DX gaps the audit flagged. sdk/README.md: - Add "Which Immutable event-tracking product is this?" near the top so studios landing on either @imtbl/audience or @imtbl/pixel can pick the right one. Cross-links to ../pixel/README.md without requiring any change to the pixel package itself. - Swap "identity resolution" for "player identity" in the opening tagline so a game studio dev doesn't have to parse marketing-analytics jargon to understand what the SDK does. - Expand the CDN quickstart to destructure the full runtime surface (Audience, AudienceError, AudienceEvents, IdentityType, canTrack, canIdentify, version) so no symbol reachable in ESM is quietly absent from the CDN snippet. Adds one short paragraph stating that the two paths have parity. - Replace the relative markdown link to the sample-app package with an absolute GitHub blob URL. Relative links break on npmjs.com after publish; studios reading the package page would have seen a dead link. sdk-sample-app/README.md: - Add a "Why vanilla JavaScript?" paragraph so a reviewer browsing the monorepo understands why this sample app isn't React + Next.js + biom3 like the four sibling sdk-sample-apps. The short answer: demonstrating <script>-tag loading is the point; a React wrapper would obscure it. - Add a pre-release warning matching the one on sdk/README.md so the sample app's SDK dependency status is visible on either landing page. - Remove the fabricated `pk_imapik-test-sample` fixture key; there is no shared fixture key. Point users at Immutable Hub to provision their own test key instead. - Rewrite the AudienceEvents catalogue section: both ESM and CDN now expose the constant, so show the destructure-from-window pattern alongside the import pattern. Explain the drift-check the sample app runs at bootstrap. - Rewrite the canTrack / canIdentify section the same way, and correct a load-bearing falsehood: the previous version claimed the sample app "gates its Identity and Alias buttons on this rule locally". It does not — the buttons stay enabled and the handlers call canIdentify before invoking the SDK, logging a "skipped" line when consent is lower. The README now matches the code. - Walkthrough step 4 includes a positional hint ("5th row in the accordion") so readers don't hunt through 11 events. sdk-sample-app/sample-app.js: - Destructure AudienceEvents, canTrack, canIdentify off window.Immutable- Audience at the top of the IIFE. Previously these weren't on the CDN window surface and the sample app had to re-implement the consent rules as magic strings. - Replace three `currentConsent !== 'full'` magic-string checks in onIdentify / onIdentifyTraits / onAlias with `!canIdentify(current- Consent)` calls to the SDK helper. Single source of truth, semantic log messages. - Add a validateEventCatalogue() drift check that runs at bootstrap and compares the local AUDIENCE_EVENTS field-metadata array's event names against window.ImmutableAudience.AudienceEvents. Logs a `drift warn` entry when the two lists diverge so a new SDK event doesn't silently disappear from the Typed Events panel. Refs SDK-49 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c50bc33 commit cab425c

File tree

3 files changed

+133
-37
lines changed

3 files changed

+133
-37
lines changed

packages/audience/sdk-sample-app/README.md

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ typed `track()` event, and every reachable `AudienceErrorCode` has a
55
dedicated UI control so you can sanity-check SDK changes end-to-end
66
against the real sandbox backend and copy working call sites.
77

8+
> **Pre-release.** Depends on `@imtbl/audience@0.0.0`. The SDK API is
9+
> stabilising but breaking changes may still land before the first
10+
> published release.
11+
12+
## Why vanilla JavaScript?
13+
14+
Every other sample app in this monorepo (`packages/passport/sdk-sample-app`,
15+
`packages/checkout/sdk-sample-app`, `packages/internal/dex/sdk-sample-app`,
16+
`packages/internal/bridge/bridge-sample-app`) is React + Next.js with the
17+
`@biom3` design system. This one is plain ES2020 served by a ~90-line Node
18+
stdlib HTTP server. Intentional: the whole point is to demonstrate how
19+
`@imtbl/audience` loads via a plain `<script>` tag — the pattern real
20+
studios use when they drop the SDK into existing pages. A React wrapper
21+
would hide `window.ImmutableAudience` behind JSX abstractions and require
22+
a build step, obscuring the loading pattern we're demonstrating. The SDK
23+
itself is framework-agnostic — wrap these calls in your framework of
24+
choice when you ship.
25+
826
## Run it
927

1028
```sh
@@ -15,24 +33,24 @@ Open http://localhost:3456/. The `dev` script builds `@imtbl/audience`
1533
first (which produces `dist/cdn/imtbl-audience.global.js`), then serves
1634
this package's files plus the CDN bundle over a small Node server.
1735

18-
## Test keys
19-
20-
These are publishable-key fixtures safe for local use:
36+
## Publishable keys
2137

22-
- **Sandbox (default):** `pk_imapik-test-sample` — talks to `api.sandbox.immutable.com`
23-
- **Prod:** any non-`pk_imapik-test-` key — talks to `api.immutable.com`
38+
You need a real publishable key from [Immutable Hub](https://hub.immutable.com/)
39+
there is no shared fixture key. Test keys start with `pk_imapik-test-` and
40+
route to `api.sandbox.immutable.com`; any other prefix routes to
41+
`api.immutable.com` (prod).
2442

2543
For dev-environment access, leave the key as a test key and set
26-
**Advanced → Base URL override** to `https://api.dev.immutable.com`.
27-
The SDK auto-derives sandbox vs prod from the key prefix; dev is not a
44+
**Advanced → Base URL override** to `https://api.dev.immutable.com`. The
45+
SDK auto-derives sandbox vs prod from the key prefix; dev is not a
2846
first-class environment and must be reached via explicit override.
2947

3048
## Ten-step walkthrough
3149

3250
1. Paste a test key into **Setup**, leave Initial Consent at `none`, click **Init**.
3351
2. Open the **Consent** panel, click **anonymous**. Status bar updates.
3452
3. Click **Lifecycle → page()**. A page message is queued.
35-
4. Expand **Typed Events → purchase**, fill in `currency=USD`, `value=9.99`, click **Send**. Watch the live TS snippet mirror the form as you type.
53+
4. Expand **Typed Events → purchase** (5th row in the accordion), fill in `currency=USD`, `value=9.99`, click **Send**. Watch the live TS snippet mirror the form as you type.
3654
5. Set Consent to **full**.
3755
6. In **Identity → Named identify**, enter `user@example.com`, type `email`, traits `{"name":"Jane"}`, click.
3856
7. In **Identity → Traits-only identify**, enter `{"plan":"pro"}`, click.
@@ -43,13 +61,15 @@ first-class environment and must be reached via explicit override.
4361
## `AudienceEvents` catalogue
4462

4563
These are the 11 predefined event names and their typed property shapes.
46-
Because `AudienceEvents` is not attached to `window.ImmutableAudience` by
47-
the CDN bundle, the sample app hardcodes the names as a string array in
48-
`sample-app.js`. For TypeScript projects, import the constant instead:
64+
Both the CDN bundle and the ESM package expose them:
4965

5066
```ts
67+
// ESM
5168
import { AudienceEvents } from '@imtbl/audience';
5269

70+
// CDN
71+
const { AudienceEvents } = window.ImmutableAudience;
72+
5373
audience.track(AudienceEvents.PURCHASE, {
5474
currency: 'USD',
5575
value: 9.99,
@@ -58,6 +78,11 @@ audience.track(AudienceEvents.PURCHASE, {
5878
});
5979
```
6080

81+
The sample app reads event NAMES from `window.ImmutableAudience.AudienceEvents`
82+
at bootstrap and cross-checks them against the field-metadata array in
83+
`sample-app.js`. If the SDK grows a new event that the sample app hasn't
84+
picked up yet, the event log shows a `drift warn` entry.
85+
6186
| Event | Required props | Optional props |
6287
|---|---|---|
6388
| `sign_up` || `method` |
@@ -80,13 +105,16 @@ audience.track('my_custom_event', { foo: 'bar' });
80105

81106
## Consent-aware UIs with `canTrack` / `canIdentify`
82107

83-
The helpers `canTrack(level)` and `canIdentify(level)` are exported from
84-
`@imtbl/audience` but not attached to `window.ImmutableAudience`. TypeScript
85-
projects can import them:
108+
`canTrack(level)` and `canIdentify(level)` are the SDK's canonical consent
109+
predicates. Both the CDN bundle and the ESM package expose them:
86110

87111
```ts
112+
// ESM
88113
import { canTrack, canIdentify } from '@imtbl/audience';
89114

115+
// CDN
116+
const { canTrack, canIdentify } = window.ImmutableAudience;
117+
90118
if (canTrack(currentConsent)) {
91119
renderAnalyticsDashboard();
92120
}
@@ -95,12 +123,17 @@ if (canIdentify(currentConsent)) {
95123
}
96124
```
97125

98-
From a plain-JavaScript CDN-only context, the rules are:
99-
100-
- `canTrack(level)` is true iff `level !== 'none'`
101-
- `canIdentify(level)` is true iff `level === 'full'`
102-
103-
The sample app gates its Identity and Alias buttons on this rule locally.
126+
The rules themselves are simple:
127+
- `canTrack(level)` returns `true` iff `level !== 'none'`
128+
- `canIdentify(level)` returns `true` iff `level === 'full'`
129+
130+
The sample app's Identity and Alias buttons stay enabled whenever the SDK
131+
is initialised, but the handlers call `canIdentify(currentConsent)` before
132+
invoking `identify()` / `alias()`. When it returns `false`, the handler
133+
logs a `skipped — canIdentify(...) is false` line and returns without
134+
calling the SDK. This mirrors how the SDK itself handles these calls at
135+
lower consent levels: it no-ops rather than throwing, so the sample app
136+
avoids a misleading "ok" log entry for a call that did nothing.
104137

105138
## Error codes
106139

packages/audience/sdk-sample-app/sample-app.js

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@
1515
var Audience = window.ImmutableAudience.Audience;
1616
var AudienceError = window.ImmutableAudience.AudienceError;
1717
var IdentityType = window.ImmutableAudience.IdentityType;
18+
var SdkAudienceEvents = window.ImmutableAudience.AudienceEvents;
19+
var canTrack = window.ImmutableAudience.canTrack;
20+
var canIdentify = window.ImmutableAudience.canIdentify;
1821
var SDK_VERSION = window.ImmutableAudience.version || 'unknown';
1922

20-
// Hardcoded event catalogue. AudienceEvents is exported from @imtbl/audience
21-
// but not attached to window.ImmutableAudience by the CDN bundle, so sample
22-
// apps that load via <script> tag can't reach it at runtime. These strings
23-
// are the canonical values from events.ts; see the README for the typed
24-
// property shape of each one.
23+
// Field metadata for the Typed Events accordion. The event NAMES come
24+
// from window.ImmutableAudience.AudienceEvents (the SDK's canonical
25+
// constant — cross-checked at bootstrap via validateEventCatalogue).
26+
// The field shapes below are not exposed by the SDK at runtime, so they
27+
// stay here as the sample app's own form-generation source of truth.
2528
var AUDIENCE_EVENTS = [
2629
{ name: 'sign_up', fields: [{ key: 'method', type: 'string', optional: true }] },
2730
{ name: 'sign_in', fields: [{ key: 'method', type: 'string', optional: true }] },
@@ -303,13 +306,43 @@
303306
text($('sdk-version'), 'SDK version: ' + SDK_VERSION);
304307
}
305308

309+
// --- Event catalogue drift check ---
310+
//
311+
// The sample app keeps field metadata locally because the SDK doesn't
312+
// expose field shapes at runtime — but the event NAMES are authoritative
313+
// on window.ImmutableAudience.AudienceEvents. Compare the two at bootstrap
314+
// and log a warning if they diverge, so a new SDK event doesn't silently
315+
// disappear from the Typed Events panel.
316+
317+
function validateEventCatalogue() {
318+
if (!SdkAudienceEvents) return;
319+
var sdkNames = Object.keys(SdkAudienceEvents).map(function (k) {
320+
return SdkAudienceEvents[k];
321+
});
322+
var localNames = AUDIENCE_EVENTS.map(function (e) { return e.name; });
323+
var missingLocally = sdkNames.filter(function (n) {
324+
return localNames.indexOf(n) === -1;
325+
});
326+
var extraLocally = localNames.filter(function (n) {
327+
return sdkNames.indexOf(n) === -1;
328+
});
329+
if (missingLocally.length > 0 || extraLocally.length > 0) {
330+
log('drift', {
331+
sdkHasButSampleAppMissing: missingLocally,
332+
sampleAppHasButSdkMissing: extraLocally,
333+
}, 'warn');
334+
}
335+
}
336+
306337
// --- Bootstrap ---
307338

308339
function bootstrap() {
309340
// Install the SDK console mirror before any SDK code runs so we don't
310341
// miss early debug output.
311342
installConsoleMirror();
312343

344+
validateEventCatalogue();
345+
313346
// Populate IdentityType dropdowns
314347
var identityTypeOptions = Object.keys(IdentityType || {});
315348
['id-type', 'alias-from-type', 'alias-to-type'].forEach(function (selectId) {
@@ -558,8 +591,8 @@
558591

559592
function onIdentify() {
560593
if (!audience) return;
561-
if (currentConsent !== 'full') {
562-
log('identify()', 'skipped — requires consent: full (current: ' + currentConsent + ')', 'info');
594+
if (!canIdentify(currentConsent)) {
595+
log('identify()', 'skipped — canIdentify(' + currentConsent + ') is false; requires consent: full', 'info');
563596
return;
564597
}
565598
var id = $('id-id').value.trim();
@@ -581,8 +614,8 @@
581614

582615
function onIdentifyTraits() {
583616
if (!audience) return;
584-
if (currentConsent !== 'full') {
585-
log('identify()', 'skipped — requires consent: full (current: ' + currentConsent + ')', 'info');
617+
if (!canIdentify(currentConsent)) {
618+
log('identify()', 'skipped — canIdentify(' + currentConsent + ') is false; requires consent: full', 'info');
586619
return;
587620
}
588621
var traits;
@@ -602,8 +635,8 @@
602635

603636
function onAlias() {
604637
if (!audience) return;
605-
if (currentConsent !== 'full') {
606-
log('alias()', 'skipped — requires consent: full (current: ' + currentConsent + ')', 'info');
638+
if (!canIdentify(currentConsent)) {
639+
log('alias()', 'skipped — canIdentify(' + currentConsent + ') is false; requires consent: full', 'info');
607640
return;
608641
}
609642
var fromId = $('alias-from-id').value.trim();

packages/audience/sdk/README.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
11
# @imtbl/audience
22

3-
Consent-aware event tracking and identity resolution for Immutable
4-
studios. Ships as an ESM/CJS package for bundled apps and as a single-file
5-
CDN IIFE for `<script>`-tag loading.
3+
Consent-aware event tracking and player identity for Immutable studios.
4+
Ships as an ESM/CJS package for bundled apps and as a single-file CDN
5+
IIFE for `<script>`-tag loading.
66

77
> **Pre-release.** This package is at version `0.0.0`. The API is
88
> stabilising but breaking changes may still land before the first
99
> published release.
1010
11+
## Which Immutable event-tracking product is this?
12+
13+
Immutable ships two complementary event-tracking products in this
14+
monorepo. Pick based on your integration shape:
15+
16+
- **`@imtbl/audience`** (this package) — the programmatic SDK. You call
17+
`Audience.init()` from your app code and explicitly track events
18+
(`track('purchase', {...})`, `identify()`, `setConsent()`, etc.).
19+
Pick this when you need fine-grained control, typed events, player
20+
identity, or explicit consent state machines.
21+
- **`@imtbl/pixel`** ([sibling package](../pixel/README.md)) — a drop-in
22+
`<script>` snippet that captures page views, device signals, and
23+
attribution data passively. Zero configuration beyond a publishable
24+
key. Pick this for marketing sites, landing pages, and web shops
25+
where you want to measure campaign performance without writing
26+
tracking code.
27+
28+
The two share the same backend pipeline, the same anonymous-id cookie
29+
(`imtbl_anon_id`), and the same publishable-key format — they're
30+
designed to coexist on a single site if you need both at once.
31+
1132
## Install
1233

1334
```sh
@@ -49,16 +70,25 @@ audience.shutdown();
4970
```html
5071
<script src="https://cdn.jsdelivr.net/npm/@imtbl/audience@<version>/dist/cdn/imtbl-audience.global.js"></script>
5172
<script>
52-
const { Audience, AudienceError, IdentityType } = window.ImmutableAudience;
73+
const {
74+
Audience, AudienceError, AudienceEvents,
75+
IdentityType, canTrack, canIdentify,
76+
} = window.ImmutableAudience;
5377
const audience = Audience.init({
5478
publishableKey: 'pk_imapik-test-...',
5579
consent: 'anonymous',
5680
onError: (err) => console.error(err.code, err.message),
5781
});
5882
audience.page();
83+
audience.track(AudienceEvents.PURCHASE, { currency: 'USD', value: 9.99 });
5984
</script>
6085
```
6186

87+
The CDN bundle attaches the same runtime symbols that ESM consumers
88+
import — `Audience`, `AudienceError`, `AudienceEvents`, `IdentityType`,
89+
`canTrack`, `canIdentify`, and `version` — so no snippet is reachable
90+
on one path but not the other.
91+
6292
## Error handling
6393

6494
`AudienceError.code` is a closed union — `'FLUSH_FAILED'`,
@@ -90,8 +120,8 @@ bad handler can't wedge the queue.
90120

91121
For a live harness that exercises every public method, every typed
92122
`track()` event, and every reachable `AudienceErrorCode` against the
93-
real sandbox backend, see
94-
[`packages/audience/sdk-sample-app`](../sdk-sample-app/README.md).
123+
real sandbox backend, see [`packages/audience/sdk-sample-app`](https://github.com/immutable/ts-immutable-sdk/tree/main/packages/audience/sdk-sample-app)
124+
in the `ts-immutable-sdk` monorepo.
95125

96126
## License
97127

0 commit comments

Comments
 (0)