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/README.md +28 -4
- package/lib/commonjs/init.js +10 -7
- package/lib/commonjs/init.js.map +1 -1
- package/lib/commonjs/links.js +65 -3
- package/lib/commonjs/links.js.map +1 -1
- package/lib/commonjs/track.js +48 -6
- package/lib/commonjs/track.js.map +1 -1
- package/lib/module/init.js +10 -7
- package/lib/module/init.js.map +1 -1
- package/lib/module/links.js +65 -3
- package/lib/module/links.js.map +1 -1
- package/lib/module/track.js +48 -6
- package/lib/module/track.js.map +1 -1
- package/lib/typescript/init.d.ts +14 -6
- package/lib/typescript/init.d.ts.map +1 -1
- package/lib/typescript/links.d.ts +11 -0
- package/lib/typescript/links.d.ts.map +1 -1
- package/lib/typescript/track.d.ts +12 -1
- package/lib/typescript/track.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/init.ts +17 -6
- package/src/links.ts +102 -1
- package/src/track.ts +70 -7
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
|
|
18
|
-
* identifier of its own
|
|
19
|
-
*
|
|
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
|
|
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
|
|
304
|
-
* records, then exposes the same
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
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:
|
|
195
|
-
|
|
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
|
+
}
|