pulse-updates 1.3.5 → 1.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/init.ts CHANGED
@@ -14,9 +14,10 @@
14
14
  * - the urls, from the base url and the slug, which is what stops the two halves
15
15
  * from ever addressing different apps;
16
16
  * - platform and OS version, from React Native;
17
- * - the device id, minted and persisted on first launch — an app that has a stable
18
- * identifier of its own (the vendor id, an installation id) should pass it, and an
19
- * app that has none should not have to invent one to be measurable.
17
+ * - the config/experiment id, minted and persisted on first launch — an app that has
18
+ * a stable identifier of its own may pass it. Event tracking keeps the original
19
+ * context identity by default; privacy-sensitive campaign events can explicitly
20
+ * opt into a separate random app-scoped analytics installation UUID.
20
21
  *
21
22
  * Deliberately not automatic: the app version. Nothing here can read the store build
22
23
  * reliably on both platforms, and a version guessed wrong aims a rollout at the wrong
@@ -92,6 +93,13 @@ export interface InitPulseOptions {
92
93
  eventPropertyAllowlist?: readonly string[];
93
94
  redactEventProperties?: readonly string[];
94
95
 
96
+ /**
97
+ * Event-envelope identity. Defaults to the backwards-compatible config context.
98
+ * Use `anonymous_installation` for events that carry their own experiment/variant
99
+ * dimensions and must never transmit the app's device or account identifier.
100
+ */
101
+ eventIdentityMode?: 'context' | 'anonymous_installation';
102
+
95
103
  /** Signed config uses the same Ed25519 public key as OTA updates. */
96
104
  signingPublicKey?: string;
97
105
  signingKeyId?: string;
@@ -156,7 +164,7 @@ export interface AsyncPulseStorage {
156
164
  let activeClient: PulseClient | null = null;
157
165
  let stopActiveRefresh: (() => void) | null = null;
158
166
 
159
- /** Wires config and events, and returns the context the two share. */
167
+ /** Wires config and events with an explicit event-envelope identity policy. */
160
168
  export function initPulse(opts: InitPulseOptions): PulseClient {
161
169
  // Reconfiguration is synchronous. Calling the old async dispose without awaiting
162
170
  // it lets its eventual flush tear down the brand-new tracker — exactly the race a
@@ -216,6 +224,7 @@ export function initPulse(opts: InitPulseOptions): PulseClient {
216
224
  configureTracking({
217
225
  url: trackUrl,
218
226
  getContext,
227
+ identityMode: opts.eventIdentityMode,
219
228
  flushIntervalMs: opts.flushIntervalMs,
220
229
  storage: opts.storage,
221
230
  storageKey: `pulse.${slug}.events.v1`,
@@ -300,8 +309,9 @@ export function initPulse(opts: InitPulseOptions): PulseClient {
300
309
  }
301
310
 
302
311
  /**
303
- * AsyncStorage-friendly one-call setup. It preloads only Pulse's three app-scoped
304
- * records, then exposes the same synchronous hot-path API as initPulse.
312
+ * AsyncStorage-friendly one-call setup. It preloads only Pulse's app-scoped config,
313
+ * event, analytics-installation and exposure records, then exposes the same
314
+ * synchronous hot-path API as initPulse.
305
315
  */
306
316
  export async function initPulseAsync(
307
317
  opts: Omit<InitPulseOptions, 'storage'> & { storage: AsyncPulseStorage },
@@ -311,6 +321,7 @@ export async function initPulseAsync(
311
321
  `pulse.${slug}.device-id`,
312
322
  `pulse.${slug}.config.v1`,
313
323
  `pulse.${slug}.events.v1`,
324
+ `pulse.${slug}.events.v1.analytics-installation-id`,
314
325
  `pulse.${slug}.exposures.v1`,
315
326
  DEVICE_ID_KEY,
316
327
  ];
package/src/links.ts CHANGED
@@ -98,12 +98,30 @@ export interface DeferredLinkOutcomeEvent {
98
98
  occurredAt: string;
99
99
  }
100
100
 
101
+ export type AnonymousFirstOpenDeviceType =
102
+ | 'phone'
103
+ | 'tablet'
104
+ | 'tv'
105
+ | 'desktop'
106
+ | 'gaming_console'
107
+ | 'unknown';
108
+
101
109
  export interface AnonymousFirstOpenContext {
102
110
  appBundleId: string;
103
111
  locale: string;
104
112
  platform: 'ios';
105
113
  /** Native first-install timestamp. Matching is refused when recency cannot be proven. */
106
114
  installedAt: number;
115
+ /** Ephemeral release/runtime dimensions. Invalid values are omitted from the request. */
116
+ appVersion?: string;
117
+ osVersion?: string;
118
+ /** Hardware model code (for example iPhone17,2), never IDFV/IDFA/a unique device id. */
119
+ deviceModelCode?: string;
120
+ deviceType?: AnonymousFirstOpenDeviceType;
121
+ distribution?: string;
122
+ /** JavaScript Date#getTimezoneOffset semantics, bounded to real-world UTC offsets. */
123
+ timezoneOffsetMinutes?: number;
124
+ isEmulator?: boolean;
107
125
  }
108
126
 
109
127
  export type AnonymousFirstOpenResult =
@@ -247,6 +265,17 @@ const MAX_APPLIED_IDS = 32;
247
265
  const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
248
266
  const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
249
267
  const MAX_PERSISTED_BYTES = 131_072;
268
+ const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
269
+ const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
270
+ const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
271
+ const DEVICE_TYPES = new Set<AnonymousFirstOpenDeviceType>([
272
+ 'phone',
273
+ 'tablet',
274
+ 'tv',
275
+ 'desktop',
276
+ 'gaming_console',
277
+ 'unknown',
278
+ ]);
250
279
 
251
280
  interface QueuedResolverOutcome {
252
281
  /** Non-reversible local transition key; never sent over the network. */
@@ -258,6 +287,8 @@ interface QueuedResolverOutcome {
258
287
  occurredAt: string;
259
288
  matchBasis: DeferredLinkMatchBasis;
260
289
  confidence: number;
290
+ /** Correlates a later deterministic truth to an earlier anonymous first-open attempt. */
291
+ installAttemptId?: string;
261
292
  attempts: number;
262
293
  nextRetryAt: number;
263
294
  }
@@ -606,8 +637,18 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
606
637
  const transitionKey = stableOutcomeKey(link.id, name);
607
638
  if (state.notifiedOutcomes.includes(transitionKey)) return;
608
639
 
609
- const occurredAt = new Date(now()).toISOString();
640
+ const outcomeTimestamp = now();
641
+ const occurredAt = new Date(outcomeTimestamp).toISOString();
610
642
  const shouldReportToResolver = (options.reportResolverOutcomes ?? true) && isPublicToken(link.id);
643
+ const firstOpenAttemptIsRecent = state.firstOpen.attemptedAt > 0
644
+ && state.firstOpen.attemptedAt <= outcomeTimestamp
645
+ && outcomeTimestamp - state.firstOpen.attemptedAt <= recentInstallMaxAgeMs;
646
+ const correlatedInstallAttemptId = firstOpenAttemptIsRecent
647
+ && link.matchGuaranteed
648
+ && link.matchBasis !== 'unique_probabilistic'
649
+ && link.matchBasis !== 'unmatched'
650
+ ? state.firstOpen.installAttemptId
651
+ : null;
611
652
  const queued: QueuedResolverOutcome | null = shouldReportToResolver ? {
612
653
  transitionKey,
613
654
  token: link.id,
@@ -619,6 +660,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
619
660
  occurredAt,
620
661
  matchBasis: link.matchBasis,
621
662
  confidence: link.confidence,
663
+ ...(correlatedInstallAttemptId ? { installAttemptId: correlatedInstallAttemptId } : {}),
622
664
  attempts: 0,
623
665
  nextRetryAt: 0,
624
666
  } : null;
@@ -963,6 +1005,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
963
1005
  locale: normalizeLocale(context.locale),
964
1006
  firstOpen: true,
965
1007
  installAttemptId,
1008
+ ...normalizeAnonymousFirstOpenSignals(context),
966
1009
  };
967
1010
  try {
968
1011
  const response = await withTimeout(
@@ -1243,6 +1286,57 @@ function normalizeLocale(locale: string): string {
1243
1286
  return locale.trim().replace(/_/g, '-');
1244
1287
  }
1245
1288
 
1289
+ function normalizeAnonymousFirstOpenSignals(
1290
+ context: AnonymousFirstOpenContext,
1291
+ ): Partial<Omit<AnonymousFirstOpenContext, 'appBundleId' | 'locale' | 'platform' | 'installedAt'>> {
1292
+ const appVersion = normalizeSignalString(context.appVersion, VERSION_SIGNAL, 64);
1293
+ const osVersion = normalizeSignalString(context.osVersion, VERSION_SIGNAL, 64);
1294
+ const deviceModelCode = normalizeSignalString(context.deviceModelCode, DEVICE_MODEL_CODE, 80);
1295
+ const distribution = normalizeDistributionSignal(context.distribution);
1296
+ const normalizedDeviceType = typeof context.deviceType === 'string'
1297
+ ? context.deviceType.trim().toLowerCase()
1298
+ : '';
1299
+ const deviceType = DEVICE_TYPES.has(normalizedDeviceType as AnonymousFirstOpenDeviceType)
1300
+ ? normalizedDeviceType as AnonymousFirstOpenDeviceType
1301
+ : undefined;
1302
+ const timezoneOffsetMinutes = typeof context.timezoneOffsetMinutes === 'number'
1303
+ && Number.isFinite(context.timezoneOffsetMinutes)
1304
+ && Number.isInteger(context.timezoneOffsetMinutes)
1305
+ && context.timezoneOffsetMinutes >= -840
1306
+ && context.timezoneOffsetMinutes <= 840
1307
+ ? context.timezoneOffsetMinutes
1308
+ : undefined;
1309
+ const isEmulator = typeof context.isEmulator === 'boolean' ? context.isEmulator : undefined;
1310
+ return {
1311
+ ...(appVersion ? { appVersion } : {}),
1312
+ ...(osVersion ? { osVersion } : {}),
1313
+ ...(deviceModelCode ? { deviceModelCode } : {}),
1314
+ ...(deviceType ? { deviceType } : {}),
1315
+ ...(distribution ? { distribution } : {}),
1316
+ ...(timezoneOffsetMinutes !== undefined ? { timezoneOffsetMinutes } : {}),
1317
+ ...(isEmulator !== undefined ? { isEmulator } : {}),
1318
+ };
1319
+ }
1320
+
1321
+ function normalizeDistributionSignal(value: unknown): string | undefined {
1322
+ if (typeof value !== 'string') return undefined;
1323
+ const normalized = value.trim().toLowerCase().replace(/[ -]+/g, '_');
1324
+ const canonical = normalized === 'appstore' ? 'app_store' : normalized;
1325
+ return normalizeSignalString(canonical, DISTRIBUTION_SIGNAL, 24);
1326
+ }
1327
+
1328
+ function normalizeSignalString(
1329
+ value: unknown,
1330
+ grammar: RegExp,
1331
+ maxLength: number,
1332
+ ): string | undefined {
1333
+ if (typeof value !== 'string') return undefined;
1334
+ const normalized = value.trim();
1335
+ return normalized.length > 0 && normalized.length <= maxLength && grammar.test(normalized)
1336
+ ? normalized
1337
+ : undefined;
1338
+ }
1339
+
1246
1340
  function makeInstallAttemptId(custom?: () => string): string {
1247
1341
  try {
1248
1342
  const candidate = custom?.()
@@ -1322,6 +1416,7 @@ async function postResolverOutcome(
1322
1416
  occurredAt: outcome.occurredAt,
1323
1417
  matchBasis: outcome.matchBasis,
1324
1418
  confidence: outcome.confidence,
1419
+ ...(outcome.installAttemptId ? { installAttemptId: outcome.installAttemptId } : {}),
1325
1420
  }),
1326
1421
  },
1327
1422
  timeoutMs,
@@ -1357,6 +1452,11 @@ function readResolverOutcomeQueue(raw: unknown): QueuedResolverOutcome[] {
1357
1452
  ? clampConfidence(value.confidence, 0)
1358
1453
  : null;
1359
1454
  if (confidence === null) continue;
1455
+ const installAttemptId = matchBasis !== 'unique_probabilistic' && matchBasis !== 'unmatched'
1456
+ && typeof value.installAttemptId === 'string'
1457
+ && INSTALL_ATTEMPT_ID.test(value.installAttemptId)
1458
+ ? value.installAttemptId.toLowerCase()
1459
+ : undefined;
1360
1460
  seenTransitions.add(transitionKey);
1361
1461
  seenEvents.add(eventId);
1362
1462
  queue.unshift({
@@ -1367,6 +1467,7 @@ function readResolverOutcomeQueue(raw: unknown): QueuedResolverOutcome[] {
1367
1467
  occurredAt,
1368
1468
  matchBasis,
1369
1469
  confidence,
1470
+ ...(installAttemptId ? { installAttemptId } : {}),
1370
1471
  attempts: safeInteger(value.attempts, 100_000),
1371
1472
  nextRetryAt: safeTimestamp(value.nextRetryAt),
1372
1473
  });
package/src/track.ts CHANGED
@@ -31,9 +31,21 @@ export interface TrackOptions {
31
31
  */
32
32
  url: string;
33
33
 
34
- /** The same context the config uses — identity travels with the batch, once. */
34
+ /**
35
+ * Request context shared with config. `context` identity mode reads deviceId/userId
36
+ * for backwards-compatible experiment metrics; `anonymous_installation` ignores
37
+ * them and reads only platform/appVersion.
38
+ */
35
39
  getContext?: () => ConfigContext;
36
40
 
41
+ /**
42
+ * `context` preserves the original experiment-event contract and sends the context
43
+ * deviceId/userId. `anonymous_installation` is an explicit privacy mode: it ignores
44
+ * both and sends only an SDK-minted app-scoped random UUID under the legacy wire
45
+ * field `deviceId`. Defaults to `context` for backwards compatibility.
46
+ */
47
+ identityMode?: 'context' | 'anonymous_installation';
48
+
37
49
  /** How often a non-empty queue is sent. Default 10s. */
38
50
  flushIntervalMs?: number;
39
51
 
@@ -95,9 +107,12 @@ const DEFAULT_MAX_QUEUE = 500;
95
107
  const DEFAULT_TIMEOUT_MS = 10_000;
96
108
  const DEFAULT_MAX_ATTEMPTS = 10;
97
109
  const DEFAULT_STORAGE_KEY = 'pulse.events.v1';
110
+ const ANALYTICS_INSTALLATION_ID_SUFFIX = '.analytics-installation-id';
111
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
98
112
 
99
113
  let options: TrackOptions | null = null;
100
114
  let queue: QueuedEvent[] = [];
115
+ let analyticsInstallationId = '';
101
116
  let timer: ReturnType<typeof setInterval> | null = null;
102
117
  let sending = false;
103
118
  let nextAttemptAt = 0;
@@ -116,6 +131,9 @@ export function configureTracking(opts: TrackOptions): void {
116
131
  disposeTracking();
117
132
  options = opts;
118
133
  queue = readQueue(opts);
134
+ analyticsInstallationId = opts.identityMode === 'anonymous_installation'
135
+ ? resolveAnalyticsInstallationId(opts)
136
+ : '';
119
137
  nextAttemptAt = 0;
120
138
  startTimer();
121
139
  lifecycleSubscription = opts.appState?.addEventListener('change', (state) => {
@@ -170,11 +188,12 @@ export async function flushEvents(): Promise<number> {
170
188
  const opts = options;
171
189
  const requestGeneration = trackingGeneration;
172
190
  const ctx = opts.getContext?.() ?? {};
191
+ const anonymousInstallation = opts.identityMode === 'anonymous_installation';
173
192
 
174
- // Without a device id the server cannot put the event in an arm, and an empty id
175
- // would pool every such install into one. Held rather than dropped: the id usually
176
- // arrives a moment after boot.
177
- if (!ctx.deviceId) return 0;
193
+ // The legacy/default mode intentionally preserves the original experiment-event
194
+ // contract. Anonymous campaign events opt into a separate identity explicitly.
195
+ if (!anonymousInstallation && !ctx.deviceId) return 0;
196
+ const requestDeviceId = anonymousInstallation ? analyticsInstallationId : ctx.deviceId;
178
197
 
179
198
  const batch = queue.slice(0, opts.maxBatch ?? DEFAULT_MAX_BATCH);
180
199
  queue = queue.slice(batch.length);
@@ -191,8 +210,10 @@ export async function flushEvents(): Promise<number> {
191
210
  method: 'POST',
192
211
  headers: { 'Content-Type': 'application/json' },
193
212
  body: JSON.stringify({
194
- deviceId: ctx.deviceId,
195
- userId: ctx.userId,
213
+ deviceId: requestDeviceId,
214
+ // Anonymous mode deliberately omits the property rather than serializing an
215
+ // empty identifier. Context mode remains backwards compatible.
216
+ ...(anonymousInstallation ? {} : { userId: ctx.userId }),
196
217
  platform: ctx.platform,
197
218
  appVersion: ctx.appVersion,
198
219
  events: batch.map((e) => ({ id: e.id, event: e.event, time: e.time, props: e.props })),
@@ -257,6 +278,7 @@ export function stopTracking(): void {
257
278
  disposeTracking();
258
279
  options = null;
259
280
  queue = [];
281
+ analyticsInstallationId = '';
260
282
  sending = false;
261
283
  nextAttemptAt = 0;
262
284
  }
@@ -329,6 +351,31 @@ function storageKey(opts: TrackOptions): string {
329
351
  return opts.storageKey?.trim() || DEFAULT_STORAGE_KEY;
330
352
  }
331
353
 
354
+ function analyticsInstallationIdStorageKey(opts: TrackOptions): string {
355
+ return `${storageKey(opts)}${ANALYTICS_INSTALLATION_ID_SUFFIX}`;
356
+ }
357
+
358
+ function resolveAnalyticsInstallationId(opts: TrackOptions): string {
359
+ if (opts.storage) {
360
+ try {
361
+ const stored = opts.storage.getString(analyticsInstallationIdStorageKey(opts))?.trim();
362
+ if (stored && UUID_PATTERN.test(stored)) return stored.toLowerCase();
363
+ } catch {
364
+ // A telemetry identifier can stay memory-only when storage is unavailable.
365
+ }
366
+ }
367
+
368
+ const generated = randomAnalyticsInstallationId();
369
+ if (opts.storage) {
370
+ try {
371
+ opts.storage.set(analyticsInstallationIdStorageKey(opts), generated);
372
+ } catch {
373
+ // A telemetry identifier can stay memory-only when storage is unavailable.
374
+ }
375
+ }
376
+ return generated;
377
+ }
378
+
332
379
  function readQueue(opts: TrackOptions): QueuedEvent[] {
333
380
  if (!opts.storage) return [];
334
381
  try {
@@ -360,3 +407,19 @@ function randomEventId(): string {
360
407
  if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
361
408
  return `evt-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
362
409
  }
410
+
411
+ function randomAnalyticsInstallationId(): string {
412
+ const cryptoLike = globalThis.crypto as {
413
+ randomUUID?: () => string;
414
+ getRandomValues?: (values: Uint8Array) => Uint8Array;
415
+ } | undefined;
416
+ if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID().toLowerCase();
417
+
418
+ const bytes = new Uint8Array(16);
419
+ if (typeof cryptoLike?.getRandomValues === 'function') cryptoLike.getRandomValues(bytes);
420
+ else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
421
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
422
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
423
+ const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
424
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
425
+ }