@joryio/web-sdk 0.0.0-stage → 1.3.0
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/CHANGELOG.md +5 -0
- package/LICENSE +54 -0
- package/README.md +497 -2
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/device.d.ts +14 -0
- package/dist/core/device.d.ts.map +1 -0
- package/dist/core/ecommerce.d.ts +67 -0
- package/dist/core/ecommerce.d.ts.map +1 -0
- package/dist/core/identity.d.ts +36 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/inapp.d.ts +169 -0
- package/dist/core/inapp.d.ts.map +1 -0
- package/dist/core/network.d.ts +158 -0
- package/dist/core/network.d.ts.map +1 -0
- package/dist/core/outbox.d.ts +79 -0
- package/dist/core/outbox.d.ts.map +1 -0
- package/dist/core/pending-attributes.d.ts +43 -0
- package/dist/core/pending-attributes.d.ts.map +1 -0
- package/dist/core/push.d.ts +59 -0
- package/dist/core/push.d.ts.map +1 -0
- package/dist/core/queue.d.ts +107 -0
- package/dist/core/queue.d.ts.map +1 -0
- package/dist/core/sdk.d.ts +169 -0
- package/dist/core/sdk.d.ts.map +1 -0
- package/dist/core/session.d.ts +36 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/storage.d.ts +17 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/user.d.ts +28 -0
- package/dist/core/user.d.ts.map +1 -0
- package/dist/core/utm.d.ts +39 -0
- package/dist/core/utm.d.ts.map +1 -0
- package/dist/core/values.d.ts +3 -0
- package/dist/core/values.d.ts.map +1 -0
- package/dist/core/write-retry.d.ts +66 -0
- package/dist/core/write-retry.d.ts.map +1 -0
- package/dist/index.d.mts +711 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/joryio-sw.js +6 -0
- package/dist/joryio.cjs.js +7265 -0
- package/dist/joryio.min.js +7 -0
- package/dist/joryio.min.js.map +1 -0
- package/dist/joryio.mjs +7254 -0
- package/dist/joryio.umd.js +7271 -0
- package/dist/utils/bucket-hash.d.ts +2 -0
- package/dist/utils/bucket-hash.d.ts.map +1 -0
- package/dist/utils/logger.d.ts +14 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/uuid.d.ts +3 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/package.json +90 -4
- package/src/core/config.ts +362 -0
- package/src/core/device.ts +50 -0
- package/src/core/ecommerce.ts +482 -0
- package/src/core/identity.ts +251 -0
- package/src/core/inapp.ts +2419 -0
- package/src/core/network.ts +1261 -0
- package/src/core/outbox.ts +707 -0
- package/src/core/pending-attributes.ts +350 -0
- package/src/core/push.ts +667 -0
- package/src/core/queue.ts +894 -0
- package/src/core/sdk.ts +3108 -0
- package/src/core/session.ts +311 -0
- package/src/core/storage.ts +157 -0
- package/src/core/user.ts +203 -0
- package/src/core/utm.ts +304 -0
- package/src/core/values.ts +85 -0
- package/src/core/write-retry.ts +136 -0
- package/src/index.ts +305 -0
- package/src/snippet/loader.js +134 -0
- package/src/utils/bucket-hash.ts +44 -0
- package/src/utils/logger.ts +58 -0
- package/src/utils/uuid.ts +29 -0
- package/src/worker/joryio-sw.js +218 -0
package/src/core/sdk.ts
ADDED
|
@@ -0,0 +1,3108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Joryio Web SDK
|
|
3
|
+
* Main SDK class
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { InitConfig, ResolvedConfig, resolveConfig, SDK_VERSION } from './config';
|
|
7
|
+
import { StorageManager } from './storage';
|
|
8
|
+
import { SessionManager, SESSION_ID_KEY } from './session';
|
|
9
|
+
import { IdentityManager } from './identity';
|
|
10
|
+
import { QueueManager, QueuedEvent, PendingIdentify, FlushOutcome, QUEUE_KEY, INFLIGHT_KEY, PENDING_IDENTIFY_KEY } from './queue';
|
|
11
|
+
import {
|
|
12
|
+
NetworkClient,
|
|
13
|
+
NetworkResponse,
|
|
14
|
+
AttributeOps,
|
|
15
|
+
SdkAuthFailureCallback,
|
|
16
|
+
utf8Length,
|
|
17
|
+
} from './network';
|
|
18
|
+
import { UTMManager } from './utm';
|
|
19
|
+
import { InAppMessagingManager, InAppCampaign } from './inapp';
|
|
20
|
+
import { User } from './user';
|
|
21
|
+
import { Logger } from '../utils/logger';
|
|
22
|
+
import { getDeviceProperties } from './device';
|
|
23
|
+
import { generateUUID } from '../utils/uuid';
|
|
24
|
+
import { EcommerceTracker, EcommerceConfig, createEcommerceTracker } from './ecommerce';
|
|
25
|
+
import { PushManager, PENDING_UNREGISTER_KEY } from './push';
|
|
26
|
+
import {
|
|
27
|
+
WriteRecord,
|
|
28
|
+
WriteOwner,
|
|
29
|
+
isWithdrawal,
|
|
30
|
+
classifyWriteResponse,
|
|
31
|
+
WRITE_MAX_ATTEMPTS,
|
|
32
|
+
} from './write-retry';
|
|
33
|
+
import { WriteOutbox, OutboxEntry, OUTBOX_KEY } from './outbox';
|
|
34
|
+
import { valueProblem, withoutUnsendableValues } from './values';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The visitor's own opt-out, persisted so it survives a reload. It used to
|
|
38
|
+
* live only in memory: a visitor who opted out was tracked again on the very
|
|
39
|
+
* next page view unless every page passed `optOut: true` at init.
|
|
40
|
+
*/
|
|
41
|
+
const OPT_OUT_KEY = 'tracking_opted_out';
|
|
42
|
+
/**
|
|
43
|
+
* A `denied` consent answer, persisted (contract item 24). `granted` is not
|
|
44
|
+
* stored - it is the absence of a denial - and `pending` never is: it holds
|
|
45
|
+
* for one page view.
|
|
46
|
+
*/
|
|
47
|
+
const CONSENT_KEY = 'tracking_consent';
|
|
48
|
+
/**
|
|
49
|
+
* Written (a timestamp) on every explicit grant, so another tab holding in
|
|
50
|
+
* 'pending' - which stores nothing, and so hears no change of CONSENT_KEY -
|
|
51
|
+
* follows the grant. Never read at init: 'pending' is per page view.
|
|
52
|
+
*/
|
|
53
|
+
const CONSENT_GRANT_SIGNAL_KEY = 'tracking_consent_granted_at';
|
|
54
|
+
/**
|
|
55
|
+
* When the stored denial / opt-out was DECIDED, and when the visitor last
|
|
56
|
+
* opted in (rule 77, scan 7 N1). A tab that stores its denial again after
|
|
57
|
+
* another tab's localStorage.clear() writes the decision's own time, so an
|
|
58
|
+
* explicit grant or opt-in made in the clearing tab right after the clear -
|
|
59
|
+
* newer than that decision - is not undone by it: a stored "no" counts only
|
|
60
|
+
* while no newer explicit "yes" is stored.
|
|
61
|
+
*/
|
|
62
|
+
const CONSENT_DENIED_AT_KEY = 'tracking_consent_denied_at';
|
|
63
|
+
const OPT_OUT_AT_KEY = 'tracking_opted_out_at';
|
|
64
|
+
const OPT_IN_SIGNAL_KEY = 'tracking_opt_in_at';
|
|
65
|
+
/** The `tracking_opted_out` value the server ACKNOWLEDGED (contract item 33). */
|
|
66
|
+
const REPORTED_OPT_OUT_KEY = 'reported_tracking_opted_out';
|
|
67
|
+
/**
|
|
68
|
+
* A `tracking_opted_out` value the server refused for good, or that failed
|
|
69
|
+
* too often (contract item 40): not re-sent on every page view until the
|
|
70
|
+
* switch changes again.
|
|
71
|
+
*/
|
|
72
|
+
const OPT_OUT_MARKER_GIVEN_UP_KEY = 'tracking_opted_out_marker_given_up';
|
|
73
|
+
/** The session whose Session Start has been queued (so a later grant or opt-in sends one, once). */
|
|
74
|
+
const SESSION_START_SENT_KEY = 'session_start_sent';
|
|
75
|
+
/**
|
|
76
|
+
* A user id given to identify() while tracking was off (consent denied or
|
|
77
|
+
* opted out) - owner decision D-b. Kept on this device only and used ONLY so
|
|
78
|
+
* withdrawals (an unsubscribe, the opt-out marker) reach the right person;
|
|
79
|
+
* the normal identify runs when tracking is allowed again.
|
|
80
|
+
*/
|
|
81
|
+
const WITHDRAWAL_USER_KEY = 'withdrawal_user_id';
|
|
82
|
+
/** Logged-out users' SDK-auth tokens, kept for their queued items (D-a). */
|
|
83
|
+
const RETIRED_SDK_AUTH_TOKENS_KEY = 'retired_sdk_auth_tokens';
|
|
84
|
+
/** Stored queues that may hold a logged-out user's items (by any tab of the site). */
|
|
85
|
+
const PERSISTED_ITEM_KEYS = [QUEUE_KEY, INFLIGHT_KEY, PENDING_IDENTIFY_KEY, OUTBOX_KEY, PENDING_UNREGISTER_KEY, 'held_attribute_writes'];
|
|
86
|
+
|
|
87
|
+
/** Every `userId` string anywhere in a stored value (formats differ per queue). */
|
|
88
|
+
function collectUserIds(value: unknown, into: Set<string>, depth: number = 0): void {
|
|
89
|
+
if (!value || typeof value !== 'object' || depth > 6) return;
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
for (const item of value) collectUserIds(item, into, depth + 1);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
95
|
+
if (key === 'userId' && typeof item === 'string' && item) into.add(item);
|
|
96
|
+
else collectUserIds(item, into, depth + 1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The privacy state each SDK started from, before the snippet's queued
|
|
101
|
+
* decisions were applied: the snippet replay drops data calls queued while
|
|
102
|
+
* tracking was off (rule 73). Internal - not exported from the package.
|
|
103
|
+
*/
|
|
104
|
+
export const PRE_INIT_BASELINE = new WeakMap<object, { optedOut: boolean; consent: TrackingConsent }>();
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Why a replayed ready() callback is data-gated (rule 92): queued while the
|
|
108
|
+
* visitor was opted out (lifted only by an optIn() the callback makes),
|
|
109
|
+
* while consent was denied (lifted only by its own grant), or before a
|
|
110
|
+
* queued optOut() / denial (the pre-init purge - never lifted from inside:
|
|
111
|
+
* that decision came after the callback).
|
|
112
|
+
*/
|
|
113
|
+
export type DataGateReason = 'optedOut' | 'denied' | 'purged';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Run `run` with the SDK's data calls dropped (rule 83): the snippet replays
|
|
117
|
+
* a ready() callback queued while tracking was off through this. Internal -
|
|
118
|
+
* not exported from the package.
|
|
119
|
+
*/
|
|
120
|
+
export function runWithDataCallsDropped(sdk: JoryioSDK, reasons: Iterable<DataGateReason>, run: () => void): void {
|
|
121
|
+
const gate = sdk as unknown as { dataGate: Set<DataGateReason> };
|
|
122
|
+
const previous = gate.dataGate;
|
|
123
|
+
gate.dataGate = new Set([...previous, ...reasons]);
|
|
124
|
+
try {
|
|
125
|
+
run();
|
|
126
|
+
} finally {
|
|
127
|
+
gate.dataGate = previous;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Identity keys another tab may change (reset(), identify()). */
|
|
132
|
+
const ANONYMOUS_ID_STORAGE_KEY = 'anonymous_id';
|
|
133
|
+
const USER_ID_STORAGE_KEY = 'user_id';
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* SDK keys of another device platform. The backend issues
|
|
137
|
+
* `jry_sdk_<platform>_<random>`; a web page runs web keys and the store
|
|
138
|
+
* integrations' keys (`shopify`, `woocommerce`, `magento`), never a mobile
|
|
139
|
+
* app's (rule 60).
|
|
140
|
+
*/
|
|
141
|
+
const FOREIGN_PLATFORM_KEY_PREFIXES = ['jry_sdk_ios_', 'jry_sdk_android_'];
|
|
142
|
+
|
|
143
|
+
function isOffline(): boolean {
|
|
144
|
+
return typeof navigator !== 'undefined' && (navigator as any).onLine === false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The profile attribute recording the opt-out. NO `$` prefix: the API strips
|
|
149
|
+
* `$`-prefixed attribute keys from SDK writes, so the old
|
|
150
|
+
* `$tracking_opted_out` never reached a profile on any platform. The same key
|
|
151
|
+
* on all five SDKs (contract addendum 14).
|
|
152
|
+
*/
|
|
153
|
+
export const TRACKING_OPTED_OUT_ATTRIBUTE = 'tracking_opted_out';
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Attribute writes not acknowledged yet, kept in storage so a navigation does
|
|
157
|
+
* not lose them (contract item 37) - the same durability the mobile SDKs'
|
|
158
|
+
* databases give. Removed entry by entry as the server acknowledges them.
|
|
159
|
+
* (The key name predates 1.3.0, when only writes held by pending consent
|
|
160
|
+
* were stored.)
|
|
161
|
+
*/
|
|
162
|
+
const ATTRIBUTE_WRITES_KEY = 'held_attribute_writes';
|
|
163
|
+
/** Leave room in the browser's 64 KiB keepalive budget for profile writes at unload. */
|
|
164
|
+
const UNLOAD_WRITES_BUDGET_BYTES = 8 * 1024;
|
|
165
|
+
/** The push_permission value the server ACKNOWLEDGED (written on ack, not on send). */
|
|
166
|
+
const REPORTED_PUSH_PERMISSION_KEY = 'joryio_reported_push_permission';
|
|
167
|
+
const PUSH_PERMISSION_ATTRIBUTE = 'push_permission';
|
|
168
|
+
|
|
169
|
+
/** The backend's `@MaxLength(255)` on event type and userId. */
|
|
170
|
+
const MAX_NAME_LENGTH = 255;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The API's limits on one event's properties (backend
|
|
174
|
+
* `validate-event-properties.util.ts`). An event over them is refused for
|
|
175
|
+
* good, so it is dropped at track() with a log line rather than sent, refused
|
|
176
|
+
* and - before `rejectedIndices` existed - retried forever (contract item 29).
|
|
177
|
+
*/
|
|
178
|
+
export const EVENT_PROPERTY_LIMITS = {
|
|
179
|
+
/**
|
|
180
|
+
* JSON.stringify(properties).length, in characters - exactly what the
|
|
181
|
+
* backend measures (SDK_EVENT_LIMITS.propertiesMaxJsonChars). Not UTF-8
|
|
182
|
+
* bytes: those count Hebrew or emoji text 2-4 times and refused events the
|
|
183
|
+
* server accepts.
|
|
184
|
+
*/
|
|
185
|
+
maxJsonChars: 51200,
|
|
186
|
+
/** Top-level keys. */
|
|
187
|
+
maxKeys: 200,
|
|
188
|
+
/** Object/array nesting, the properties object itself being level 1. */
|
|
189
|
+
maxDepth: 5,
|
|
190
|
+
} as const;
|
|
191
|
+
|
|
192
|
+
/** Why these properties would be refused by the API, or null when they are fine. */
|
|
193
|
+
export function eventPropertiesProblem(properties: Record<string, any>): string | null {
|
|
194
|
+
const keys = Object.keys(properties).length;
|
|
195
|
+
if (keys > EVENT_PROPERTY_LIMITS.maxKeys) {
|
|
196
|
+
return `${keys} property keys (the limit is ${EVENT_PROPERTY_LIMITS.maxKeys})`;
|
|
197
|
+
}
|
|
198
|
+
const tooDeep = (value: any, depth: number): boolean => {
|
|
199
|
+
if (depth > EVENT_PROPERTY_LIMITS.maxDepth) return true;
|
|
200
|
+
if (value === null || typeof value !== 'object') return false;
|
|
201
|
+
const children = Array.isArray(value) ? value : Object.values(value);
|
|
202
|
+
return children.some((child) => child !== null && typeof child === 'object' && tooDeep(child, depth + 1));
|
|
203
|
+
};
|
|
204
|
+
if (tooDeep(properties, 1)) return `properties nested deeper than ${EVENT_PROPERTY_LIMITS.maxDepth} levels`;
|
|
205
|
+
let json: string;
|
|
206
|
+
try {
|
|
207
|
+
json = JSON.stringify(properties);
|
|
208
|
+
} catch {
|
|
209
|
+
return 'properties that cannot be serialized (a circular reference?)';
|
|
210
|
+
}
|
|
211
|
+
if (json.length > EVENT_PROPERTY_LIMITS.maxJsonChars) {
|
|
212
|
+
return `${json.length} characters of properties (the limit is ${EVENT_PROPERTY_LIMITS.maxJsonChars})`;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type TrackingConsent = 'granted' | 'denied' | 'pending';
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A privacy decision made before the SDK started (the async snippet queues
|
|
221
|
+
* calls until the bundle loads). Applied BEFORE any side effect of init - no
|
|
222
|
+
* Session Start, in-app sync, push re-registration or attribute write happens
|
|
223
|
+
* under the state the decision replaces (contract item 25).
|
|
224
|
+
* @internal
|
|
225
|
+
*/
|
|
226
|
+
export type PreInitDecision =
|
|
227
|
+
| { method: 'optOut' }
|
|
228
|
+
| { method: 'optIn' }
|
|
229
|
+
| { method: 'updateConsent'; consent: TrackingConsent }
|
|
230
|
+
| { method: 'wipeData' };
|
|
231
|
+
|
|
232
|
+
export class JoryioSDK {
|
|
233
|
+
private static instance: JoryioSDK | null = null;
|
|
234
|
+
|
|
235
|
+
private config!: ResolvedConfig;
|
|
236
|
+
private logger!: Logger;
|
|
237
|
+
private storage!: StorageManager;
|
|
238
|
+
private session!: SessionManager;
|
|
239
|
+
/** Coalescing window for attribute writes. */
|
|
240
|
+
private static readonly ATTRIBUTE_FLUSH_DEBOUNCE_MS = 800;
|
|
241
|
+
private attributeFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
242
|
+
/** Serializes attribute flushes; see flushAttributeOps. */
|
|
243
|
+
private attributeFlushInFlight = false;
|
|
244
|
+
private attributeFlushAgain = false;
|
|
245
|
+
/** Bumped when the pending store is cleared: an answer for the old store is ignored. */
|
|
246
|
+
private attributeGeneration = 0;
|
|
247
|
+
/** Consecutive failed attribute flushes, for the retry backoff. */
|
|
248
|
+
private attributeFailures = 0;
|
|
249
|
+
private identity!: IdentityManager;
|
|
250
|
+
private queue!: QueueManager;
|
|
251
|
+
private network!: NetworkClient;
|
|
252
|
+
private utm!: UTMManager;
|
|
253
|
+
private inapp!: InAppMessagingManager;
|
|
254
|
+
private _user!: User;
|
|
255
|
+
private _push!: PushManager;
|
|
256
|
+
private _ecommerce: EcommerceTracker | null = null;
|
|
257
|
+
private isInitialized: boolean = false;
|
|
258
|
+
/**
|
|
259
|
+
* Collect NOTHING: the visitor opted out, Do Not Track is on, or consent
|
|
260
|
+
* was denied. Derived by applyPrivacyState(); never set directly.
|
|
261
|
+
*/
|
|
262
|
+
private isOptedOut: boolean = false;
|
|
263
|
+
/** The SDK key was missing or malformed: an inert instance (see the constructor). */
|
|
264
|
+
private disabled: boolean = false;
|
|
265
|
+
/** Consent pending: collect locally, send nothing until it is granted. */
|
|
266
|
+
private consentPending: boolean = false;
|
|
267
|
+
private userOptedOut: boolean = false;
|
|
268
|
+
private dntEnabled: boolean = false;
|
|
269
|
+
private trackingConsent: TrackingConsent = 'granted';
|
|
270
|
+
/** Profile writes (subscriptions, aliases, the opt-out marker): persisted and ordered. */
|
|
271
|
+
private outbox!: WriteOutbox;
|
|
272
|
+
private lastTrackedUrl: string | null = null;
|
|
273
|
+
private deviceId!: string;
|
|
274
|
+
private boundHandlePageUnload: (() => void) | null = null;
|
|
275
|
+
private boundHandleOnline: (() => void) | null = null;
|
|
276
|
+
private boundHandleStorage: ((event: any) => void) | null = null;
|
|
277
|
+
/** See WITHDRAWAL_USER_KEY. */
|
|
278
|
+
private withdrawalUserId: string | null = null;
|
|
279
|
+
/** When this tab's opt-out / denial was decided (see CONSENT_DENIED_AT_KEY); 0 = unknown. */
|
|
280
|
+
private optedOutAt = 0;
|
|
281
|
+
private consentDeniedAt = 0;
|
|
282
|
+
/** The latest privacy-decision time this tab wrote or saw: stamps only increase. */
|
|
283
|
+
private lastDecisionStamp = 0;
|
|
284
|
+
/**
|
|
285
|
+
* Another tab logged out and this one followed: the session it starts may
|
|
286
|
+
* be stored after its new anonymous id (scan 7 N4), and is followed then.
|
|
287
|
+
*/
|
|
288
|
+
private awaitingSessionOf: string | null = null;
|
|
289
|
+
|
|
290
|
+
private pruneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param preInit - internal: privacy decisions queued before the SDK loaded
|
|
294
|
+
* (see installSnippetQueue). Not part of the public API.
|
|
295
|
+
*/
|
|
296
|
+
constructor(config: InitConfig, preInit?: PreInitDecision[]) {
|
|
297
|
+
// Singleton pattern: return existing instance if already initialized
|
|
298
|
+
if (JoryioSDK.instance) {
|
|
299
|
+
// eslint-disable-next-line no-console -- this.logger is not assigned yet on the singleton early-return path
|
|
300
|
+
console.warn('[Joryio] SDK already initialized. Returning existing instance.');
|
|
301
|
+
return JoryioSDK.instance;
|
|
302
|
+
}
|
|
303
|
+
// Validate required config. Never THROWN (contract item 12): a missing or
|
|
304
|
+
// malformed key used to throw out of the constructor, which in a bundle
|
|
305
|
+
// took the host page's own script down with it. The instance is built
|
|
306
|
+
// inert instead - every call on it is a logged no-op and nothing is sent,
|
|
307
|
+
// stored for sending, or registered - and the reason is printed once,
|
|
308
|
+
// whatever the log level.
|
|
309
|
+
const keyProblem =
|
|
310
|
+
!config || typeof config !== 'object' || typeof config.sdkKey !== 'string' || !config.sdkKey
|
|
311
|
+
? 'sdkKey is required'
|
|
312
|
+
: !config.sdkKey.startsWith('jry_sdk_')
|
|
313
|
+
? 'invalid SDK key format, expected jry_sdk_*'
|
|
314
|
+
: FOREIGN_PLATFORM_KEY_PREFIXES.some((prefix) => config.sdkKey.startsWith(prefix))
|
|
315
|
+
? 'this is a mobile app key - use the web SDK key (jry_sdk_web_...) from Settings -> Apps'
|
|
316
|
+
: null;
|
|
317
|
+
if (keyProblem) {
|
|
318
|
+
// eslint-disable-next-line no-console -- the logger is silent by default and this is the reason nothing works
|
|
319
|
+
console.error(`[Joryio] SDK not started: ${keyProblem}. Calls on this instance are ignored.`);
|
|
320
|
+
this.disabled = true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Resolve config with defaults
|
|
324
|
+
this.config = resolveConfig(
|
|
325
|
+
keyProblem
|
|
326
|
+
? { ...(config && typeof config === 'object' ? config : {}), sdkKey: 'jry_sdk_invalid', optOut: false }
|
|
327
|
+
: config,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Logger: silent unless `enableDebug` or an explicit `logLevel` was set.
|
|
331
|
+
this.logger = new Logger(this.config.loggingEnabled, this.config.logLevel);
|
|
332
|
+
|
|
333
|
+
this.logger.info(`Initializing Joryio SDK v${SDK_VERSION}`);
|
|
334
|
+
|
|
335
|
+
// Initialize core components
|
|
336
|
+
this.storage = new StorageManager(this.config.sdkKey, this.logger);
|
|
337
|
+
|
|
338
|
+
// --- Privacy state -------------------------------------------------
|
|
339
|
+
// Two independent switches (contract item 24): the app's consent banner
|
|
340
|
+
// and the visitor's own opt-out. Anything is collected only when consent
|
|
341
|
+
// is granted AND the visitor has not opted out; any "no" wins.
|
|
342
|
+
this.dntEnabled = this.config.respectDoNotTrack && this.isDoNotTrackEnabled();
|
|
343
|
+
if (this.dntEnabled) {
|
|
344
|
+
this.logger.warn('Do Not Track is enabled, SDK will not track events');
|
|
345
|
+
}
|
|
346
|
+
if (this.config.optOut) {
|
|
347
|
+
this.writeOptOut();
|
|
348
|
+
}
|
|
349
|
+
this.userOptedOut = this.config.optOut || this.storedOptOut();
|
|
350
|
+
if (this.userOptedOut) this.optedOutAt = this.storedStamp(OPT_OUT_AT_KEY);
|
|
351
|
+
this.trackingConsent = this.initialConsent(this.config.trackingConsent);
|
|
352
|
+
|
|
353
|
+
// Initialize UTM tracking (before session to check for campaign changes)
|
|
354
|
+
this.utm = new UTMManager(
|
|
355
|
+
this.storage,
|
|
356
|
+
this.logger,
|
|
357
|
+
this.config.resetSessionOnNewCampaign,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
// Check if we should reset session due to new campaign
|
|
361
|
+
const shouldResetSession = this.config.resetSessionOnNewCampaign &&
|
|
362
|
+
this.config.captureUTM &&
|
|
363
|
+
this.utm.hasNewCampaign();
|
|
364
|
+
|
|
365
|
+
this.session = new SessionManager(this.storage, this.logger, this.config.sessionTimeout);
|
|
366
|
+
|
|
367
|
+
// Reset session if new campaign detected
|
|
368
|
+
if (shouldResetSession) {
|
|
369
|
+
this.logger.info('New campaign detected, resetting session');
|
|
370
|
+
this.session.resetSession();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this.identity = new IdentityManager(
|
|
374
|
+
this.storage,
|
|
375
|
+
this.logger,
|
|
376
|
+
this.config.userId,
|
|
377
|
+
this.config.anonymousId,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
this.deviceId = this.getOrCreateDeviceId();
|
|
381
|
+
this.queue = new QueueManager(
|
|
382
|
+
this.storage,
|
|
383
|
+
this.logger,
|
|
384
|
+
this.config.batchSize,
|
|
385
|
+
this.config.batchFlushInterval,
|
|
386
|
+
this.config.persistQueue,
|
|
387
|
+
this.config.maxQueueSize,
|
|
388
|
+
this.config.retryBackoffMs,
|
|
389
|
+
);
|
|
390
|
+
this.network = new NetworkClient(
|
|
391
|
+
this.config.sdkKey,
|
|
392
|
+
this.logger,
|
|
393
|
+
{
|
|
394
|
+
maxRetries: this.config.maxRetries,
|
|
395
|
+
backoffMs: this.config.retryBackoffMs,
|
|
396
|
+
timeoutMs: this.config.requestTimeout,
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// A logged-out user's token stays bound to their queued items across a
|
|
401
|
+
// reload (D-a), so their withdrawals and last events still authenticate.
|
|
402
|
+
// Every tab shares the stored map: read-modify-write, never a blind
|
|
403
|
+
// overwrite of another tab's entries (rule 62).
|
|
404
|
+
this.network.setRetiredTokenStore({
|
|
405
|
+
load: () => this.storage.get<Record<string, unknown>>(RETIRED_SDK_AUTH_TOKENS_KEY),
|
|
406
|
+
update: (mutate) => {
|
|
407
|
+
const stored = this.storage.get<Record<string, unknown>>(RETIRED_SDK_AUTH_TOKENS_KEY);
|
|
408
|
+
const next = mutate(stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {});
|
|
409
|
+
if (Object.keys(next).length) this.storage.set(RETIRED_SDK_AUTH_TOKENS_KEY, next);
|
|
410
|
+
else this.storage.remove(RETIRED_SDK_AUTH_TOKENS_KEY);
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
// The user kept for withdrawals while tracking was off (D-b). An explicit
|
|
414
|
+
// `userId` in the init config names who this page is about and wins over
|
|
415
|
+
// it (rule 63): a page rendered for A must not send A's unsubscribe as B.
|
|
416
|
+
const storedWithdrawalUser = this.storage.get<string>(WITHDRAWAL_USER_KEY);
|
|
417
|
+
const configUserId = typeof this.config.userId === 'string' && this.config.userId ? this.config.userId : null;
|
|
418
|
+
this.withdrawalUserId =
|
|
419
|
+
typeof storedWithdrawalUser === 'string' &&
|
|
420
|
+
storedWithdrawalUser &&
|
|
421
|
+
storedWithdrawalUser !== this.identity.getUserId() &&
|
|
422
|
+
!configUserId
|
|
423
|
+
? storedWithdrawalUser
|
|
424
|
+
: null;
|
|
425
|
+
if (!this.withdrawalUserId && storedWithdrawalUser) this.storage.remove(WITHDRAWAL_USER_KEY);
|
|
426
|
+
this.network.setCurrentUserId(this.withdrawalUserId || this.identity.getUserId());
|
|
427
|
+
|
|
428
|
+
// SDK authentication (opt-in). When enabled the network client attaches the
|
|
429
|
+
// customer-minted JWT as `X-Joryio-Auth` and refreshes it on a 401
|
|
430
|
+
// `sdk_authentication_error`. No-op when disabled. Set after the current
|
|
431
|
+
// user, so the token is known to be that user's.
|
|
432
|
+
if (this.config.enableSdkAuthentication) {
|
|
433
|
+
this.network.setSdkAuthenticationEnabled(true);
|
|
434
|
+
if (this.config.sdkAuthenticationToken) {
|
|
435
|
+
this.network.setSdkAuthenticationToken(this.config.sdkAuthenticationToken);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
this.outbox = this.createOutbox();
|
|
440
|
+
|
|
441
|
+
// Initialize in-app messaging. The bridge lets in-app message HTML call SDK
|
|
442
|
+
// methods via `window.joryioBridge` (the push primer uses requestPushPermission).
|
|
443
|
+
// Arrows capture `this` lazily — `_push` is constructed just below, but the
|
|
444
|
+
// bridge methods are only invoked at message-interaction time, by which point
|
|
445
|
+
// every dependency exists.
|
|
446
|
+
this.inapp = new InAppMessagingManager(
|
|
447
|
+
this.config.apiEndpoint,
|
|
448
|
+
this.config.sdkKey,
|
|
449
|
+
this.identity,
|
|
450
|
+
this.session,
|
|
451
|
+
this.logger,
|
|
452
|
+
this.storage,
|
|
453
|
+
{
|
|
454
|
+
closeButton: true,
|
|
455
|
+
backdropClose: true,
|
|
456
|
+
// The RESOLVED displayOptions, which used to be dropped here: config.ts
|
|
457
|
+
// resolves zIndex / backdropColor / closeButtonColor / defaultPosition /
|
|
458
|
+
// defaultAnimation with defaults, and this literal replaced the lot. A
|
|
459
|
+
// site that set inApp.displayOptions.zIndex to sit above its own header
|
|
460
|
+
// got no effect and nothing to explain it.
|
|
461
|
+
...this.config.inApp.displayOptions,
|
|
462
|
+
onDisplay: this.config.inApp.onMessageDisplay,
|
|
463
|
+
onClick: this.config.inApp.onMessageClick,
|
|
464
|
+
onDismiss: this.config.inApp.onMessageDismiss,
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
logCustomEvent: (name, properties) => this.track(name, properties),
|
|
468
|
+
setCustomUserAttribute: (key, value) => this.setAttribute(key, value),
|
|
469
|
+
changeUser: (userId) => this.identify(userId),
|
|
470
|
+
// Synchronous dispatch — keeps the click's user-gesture activation so the
|
|
471
|
+
// OS push prompt fires (the iframe is same-origin; the call chain stays sync).
|
|
472
|
+
// `.catch`, not bare `void`: `void` silences the linter, not the
|
|
473
|
+
// rejection. An unhandled rejection fires the HOST page's
|
|
474
|
+
// onunhandledrejection handler and lands in their error monitoring as if
|
|
475
|
+
// their app had failed. Our push prompt being declined is our problem.
|
|
476
|
+
requestPushPermission: () => {
|
|
477
|
+
this._push.subscribe().catch((error) => {
|
|
478
|
+
this.logger.warn('Push subscribe failed:', error);
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
preparePushPermission: () => {
|
|
482
|
+
this._push.prepare().catch((error) => {
|
|
483
|
+
this.logger.warn('Push prepare failed:', error);
|
|
484
|
+
});
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
// Share the network client so in-app's own fetches carry the
|
|
488
|
+
// X-Joryio-Auth header and can trigger a token refresh on a 401.
|
|
489
|
+
this.network,
|
|
490
|
+
{
|
|
491
|
+
canSend: () => this.canSend(),
|
|
492
|
+
requestTimeoutMs: this.config.requestTimeout,
|
|
493
|
+
syncIntervalMs: this.config.inApp.syncInterval,
|
|
494
|
+
evaluationMode: this.config.inApp.evaluationMode,
|
|
495
|
+
noteStatus: (status, data) => {
|
|
496
|
+
this.network.noteSuspended(status, data);
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// Without this the setter is never called and the flag is permanently
|
|
502
|
+
// false, so every HTML campaign is refused with no way to opt in.
|
|
503
|
+
this.inapp.setAllowHtmlJsInAppMessages(this.config.inApp.allowHtmlJsInAppMessages);
|
|
504
|
+
|
|
505
|
+
// Initialize user object for subscription management
|
|
506
|
+
this._user = new User(
|
|
507
|
+
this.logger,
|
|
508
|
+
this.network,
|
|
509
|
+
this.identity,
|
|
510
|
+
this.config.apiEndpoint,
|
|
511
|
+
(label, record) => this.runWrite(label, record),
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// Web push manager. No prompt ever happens here; on init it only refreshes
|
|
515
|
+
// a subscription the visitor already granted (see syncExistingSubscription).
|
|
516
|
+
this._push = new PushManager(
|
|
517
|
+
this.logger,
|
|
518
|
+
this.network,
|
|
519
|
+
this.identity,
|
|
520
|
+
this.config.apiEndpoint,
|
|
521
|
+
{
|
|
522
|
+
canSend: () => this.canSend(),
|
|
523
|
+
// A server-side unregister is a withdrawal (contract item 36), sent
|
|
524
|
+
// even while ingestion is paused - the server accepts it.
|
|
525
|
+
canWithdraw: () => this.isInitialized && !this.disabled,
|
|
526
|
+
withdrawalUserId: () => this.effectiveUserId(),
|
|
527
|
+
storage: this.storage,
|
|
528
|
+
getDeviceId: () => this.deviceId,
|
|
529
|
+
// The new permission is reported now, not on the next page load.
|
|
530
|
+
onSubscribeSettled: () => this.reportPushPermissionIfChanged(),
|
|
531
|
+
},
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
// A Session Start that was on the wire when an opt-out / denial cleared
|
|
535
|
+
// the queue: its answer decides whether this session still owes one (rule 90).
|
|
536
|
+
this.queue.setClearedInflightCallback((delivered) => {
|
|
537
|
+
const sessionId = this.inflightSessionStartAtClear;
|
|
538
|
+
if (!sessionId) return;
|
|
539
|
+
this.inflightSessionStartAtClear = null;
|
|
540
|
+
const stored = delivered.some((e) => e.type === 'Session Start' && e.sessionId === sessionId);
|
|
541
|
+
if (!stored && this.storage.get<string>(SESSION_START_SENT_KEY) === sessionId) {
|
|
542
|
+
this.storage.set(SESSION_START_SENT_KEY, '');
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Set up queue flush callback
|
|
547
|
+
this.queue.setFlushCallback(async (events, identify) => {
|
|
548
|
+
try {
|
|
549
|
+
return await this.flushEvents(events, identify);
|
|
550
|
+
} finally {
|
|
551
|
+
// A logged-out user's token lives as long as their leftovers (D-a).
|
|
552
|
+
this.scheduleRetiredTokenPrune();
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// Decisions made before init (the snippet's queue) take effect NOW,
|
|
557
|
+
// before anything below can send under the state they replace.
|
|
558
|
+
PRE_INIT_BASELINE.set(this, { optedOut: this.userOptedOut, consent: this.trackingConsent });
|
|
559
|
+
for (const decision of preInit || []) this.applyPreInitDecision(decision);
|
|
560
|
+
|
|
561
|
+
this.recomputePrivacy();
|
|
562
|
+
if (this.isOptedOut) {
|
|
563
|
+
this.logger.warn('Tracking is off (opt-out, Do Not Track or consent denied)');
|
|
564
|
+
}
|
|
565
|
+
this.queue.setPaused(this.consentPending);
|
|
566
|
+
if (this.isOptedOut) {
|
|
567
|
+
// Events persisted by an earlier page view must not go out after a
|
|
568
|
+
// refusal either.
|
|
569
|
+
this.queue.clear();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Attribute writes not acknowledged on an earlier page view.
|
|
573
|
+
this.restoreAttributeWrites();
|
|
574
|
+
|
|
575
|
+
// Page unload: hand what is queued to the browser (fetch keepalive).
|
|
576
|
+
if (!this.disabled && typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
577
|
+
this.boundHandlePageUnload = this.handlePageUnload.bind(this);
|
|
578
|
+
window.addEventListener('pagehide', this.boundHandlePageUnload);
|
|
579
|
+
window.addEventListener('beforeunload', this.boundHandlePageUnload);
|
|
580
|
+
// Back online: retry now instead of waiting out the backoff.
|
|
581
|
+
this.boundHandleOnline = () => {
|
|
582
|
+
this.queue.resetBackoff();
|
|
583
|
+
if (this.canSend()) {
|
|
584
|
+
this.queue.flush().catch((error) => this.logger.error('Flush failed:', error));
|
|
585
|
+
}
|
|
586
|
+
// Profile writes and withdrawals that failed while offline.
|
|
587
|
+
this.outbox.kick();
|
|
588
|
+
this._push.retryPendingUnregister();
|
|
589
|
+
// Attribute writes waiting out their own backoff, too.
|
|
590
|
+
if (!this.identity.pending.isEmpty() && this.canSend()) {
|
|
591
|
+
this.attributeFailures = 0;
|
|
592
|
+
if (this.attributeFlushTimer !== null) {
|
|
593
|
+
clearTimeout(this.attributeFlushTimer);
|
|
594
|
+
this.attributeFlushTimer = null;
|
|
595
|
+
}
|
|
596
|
+
this.scheduleAttributeFlush(0);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
window.addEventListener('online', this.boundHandleOnline);
|
|
600
|
+
// Another tab of the site opted out, changed consent, logged out or
|
|
601
|
+
// identified: this tab follows at once instead of at its next reload.
|
|
602
|
+
this.boundHandleStorage = (event: any) => this.handleStorageEvent(event);
|
|
603
|
+
window.addEventListener('storage', this.boundHandleStorage);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Load plugins
|
|
607
|
+
if (!this.disabled) {
|
|
608
|
+
this.loadPlugins().catch((error) => this.logger.error('Plugin load failed:', error));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this.isInitialized = true;
|
|
612
|
+
// An inert instance is not THE instance: a later, correct `new JoryioSDK()`
|
|
613
|
+
// must get a working SDK rather than this one.
|
|
614
|
+
if (!this.disabled) JoryioSDK.instance = this;
|
|
615
|
+
this.logger.info('SDK initialized successfully');
|
|
616
|
+
|
|
617
|
+
// Report permission state now the SDK is live. Only sends on a change.
|
|
618
|
+
this.reportPushPermissionIfChanged();
|
|
619
|
+
|
|
620
|
+
// Enqueue Session Start BEFORE starting the queue auto-flush timer.
|
|
621
|
+
// This ensures Session Start is the first event sent, even if there are
|
|
622
|
+
// persisted events from a previous page load (queue.start() flushes those immediately).
|
|
623
|
+
let sessionStartSent = false;
|
|
624
|
+
// A pre-init wipeData() cleared the session: the fresh visitor's starts
|
|
625
|
+
// here (announced below, with its Session Start).
|
|
626
|
+
this.session.ensureSession();
|
|
627
|
+
if (this.config.trackSessionStart && !this.isOptedOut) {
|
|
628
|
+
const sessionInfo = this.session.getSessionInfo();
|
|
629
|
+
// A new session, or one whose Session Start was never queued (tracking
|
|
630
|
+
// was off when it began) - but never a second one for a session that
|
|
631
|
+
// already has it (a cold start resuming an unexpired session).
|
|
632
|
+
// (No marker at all = stored by an earlier SDK version: treated as sent.)
|
|
633
|
+
const startedFor = this.storage.get<string>(SESSION_START_SENT_KEY);
|
|
634
|
+
const startPending = startedFor !== null && startedFor !== sessionInfo.sessionId;
|
|
635
|
+
if (sessionInfo.isNewSession || shouldResetSession || startPending) {
|
|
636
|
+
// While consent is pending this only QUEUES it (the queue is paused):
|
|
637
|
+
// it goes out first when consent is granted and is discarded if it is
|
|
638
|
+
// denied.
|
|
639
|
+
this.trackSessionStart();
|
|
640
|
+
sessionStartSent = true;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Only track initial Page View if Session Start was NOT sent
|
|
645
|
+
// (Session Start includes landing page, backend creates initial Page View from it)
|
|
646
|
+
if (this.config.trackPageViews && !this.isOptedOut && !sessionStartSent) {
|
|
647
|
+
this.trackPageView();
|
|
648
|
+
if (typeof document !== 'undefined' && document.readyState === 'loading') {
|
|
649
|
+
document.addEventListener(
|
|
650
|
+
'DOMContentLoaded',
|
|
651
|
+
() => {
|
|
652
|
+
this.trackPageView();
|
|
653
|
+
},
|
|
654
|
+
{ once: true },
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Register callback for when session expires and a new one is created (e.g., tab becomes visible after inactivity).
|
|
660
|
+
// Must be registered BEFORE queue.start() to avoid missing a session change that occurs during startup.
|
|
661
|
+
this.session.onNewSession(() => {
|
|
662
|
+
if (this.isOptedOut) return;
|
|
663
|
+
if (this.config.trackSessionStart) {
|
|
664
|
+
this.logger.debug('New session created (mid-visit), tracking session start');
|
|
665
|
+
this.trackSessionStart();
|
|
666
|
+
}
|
|
667
|
+
// A new session is a sync point in both evaluation modes.
|
|
668
|
+
this.resyncInApp();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Start sending AFTER Session Start is enqueued, so it's included in the
|
|
672
|
+
// first flush along with any persisted events from previous page loads.
|
|
673
|
+
if (this.canSend()) {
|
|
674
|
+
this.queue.start();
|
|
675
|
+
|
|
676
|
+
// Initialize in-app messaging if enabled
|
|
677
|
+
if (this.config.inApp.enabled) {
|
|
678
|
+
this.inapp.initialize().catch((error) => {
|
|
679
|
+
this.logger.error('Failed to initialize in-app messaging:', error);
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// A subscription the browser rotated, or one bound to an old VAPID key,
|
|
684
|
+
// is re-registered here - nothing else ever did. (After a pre-init
|
|
685
|
+
// wipeData() this only tells the server about the unsubscribe: the
|
|
686
|
+
// browser stays unsubscribed until push.subscribe().)
|
|
687
|
+
this._push.syncExistingSubscription().catch(() => undefined);
|
|
688
|
+
|
|
689
|
+
// Held on an earlier page view while consent was pending, granted now.
|
|
690
|
+
this.releaseHeldWrites();
|
|
691
|
+
}
|
|
692
|
+
this.syncWorkerReporting();
|
|
693
|
+
|
|
694
|
+
// An identify() made while tracking was off on an earlier page view, now
|
|
695
|
+
// that it is allowed (D-b).
|
|
696
|
+
if (!this.isOptedOut) this.releaseDeferredIdentify();
|
|
697
|
+
|
|
698
|
+
// The opt-out marker, if the switch changed since the server last heard
|
|
699
|
+
// (an `optOut: true` in the config or a pre-init optOut() included).
|
|
700
|
+
this.syncOptOutMarker();
|
|
701
|
+
this.scheduleRetiredTokenPrune();
|
|
702
|
+
// Withdrawals go whatever the tracking state; the rest when allowed.
|
|
703
|
+
this.outbox.kick();
|
|
704
|
+
this._push.retryPendingUnregister();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** The persisted outbox, wired to this SDK's privacy state and identity. */
|
|
708
|
+
private createOutbox(): WriteOutbox {
|
|
709
|
+
return new WriteOutbox({
|
|
710
|
+
storage: this.storage,
|
|
711
|
+
logger: this.logger,
|
|
712
|
+
gate: (entry) => {
|
|
713
|
+
if (!this.isInitialized || this.disabled) return 'hold';
|
|
714
|
+
// A withdrawal goes even while ingestion is paused (402): the server
|
|
715
|
+
// accepts it (owner decision 2026-10-06).
|
|
716
|
+
if (entry.withdrawal) return 'send';
|
|
717
|
+
if (this.network.isSuspended()) return 'hold';
|
|
718
|
+
if (this.isOptedOut) return 'drop';
|
|
719
|
+
if (this.consentPending) return 'hold';
|
|
720
|
+
return 'send';
|
|
721
|
+
},
|
|
722
|
+
resolveOwner: (owner) => this.resolveOwner(owner),
|
|
723
|
+
ownerIsCurrentUser: (owner) => (owner.userId || null) === this.effectiveUserId(),
|
|
724
|
+
currentIdentity: () => ({ anonymousId: this.identity.getAnonymousId(), userId: this.effectiveUserId() }),
|
|
725
|
+
send: (entry, owner) => this.sendWrite(entry.record, owner),
|
|
726
|
+
beforeSend: () => this.flushQueueForWrite(),
|
|
727
|
+
isSdkAuthError: (status, data) => this.network.isSdkAuthError(status, data),
|
|
728
|
+
onSettled: (entry, outcome) => {
|
|
729
|
+
this.onWriteSettled(entry, outcome);
|
|
730
|
+
this.scheduleRetiredTokenPrune();
|
|
731
|
+
},
|
|
732
|
+
keepalive: (entry, owner) => {
|
|
733
|
+
const request = this.writeRequest(entry.record, owner);
|
|
734
|
+
const json = JSON.stringify(request.body);
|
|
735
|
+
if (!this.network.postKeepalive(request.url, request.body)) return null;
|
|
736
|
+
return utf8Length(json) + 256;
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Who a write is about right now: the current ids. */
|
|
742
|
+
private currentOwner(): WriteOwner {
|
|
743
|
+
const userId = this.effectiveUserId();
|
|
744
|
+
return { anonymousId: this.identity.getAnonymousId(), ...(userId ? { userId } : {}) };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* The user withdrawals are made for: the one identify() named while
|
|
749
|
+
* tracking was off (D-b), else the identified user.
|
|
750
|
+
*/
|
|
751
|
+
private effectiveUserId(): string | null {
|
|
752
|
+
return this.withdrawalUserId || this.identity.getUserId();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* identify() while consent is denied or the visitor opted out (D-b): the
|
|
757
|
+
* user id is kept on this device ONLY - nothing is sent, tracked or written
|
|
758
|
+
* to a profile - so a later unsubscribe or opt-out reaches that person
|
|
759
|
+
* rather than an orphan anonymous profile. The normal identify runs when
|
|
760
|
+
* tracking is allowed again.
|
|
761
|
+
*/
|
|
762
|
+
private deferIdentify(userId: string): void {
|
|
763
|
+
if (userId === this.effectiveUserId()) return;
|
|
764
|
+
// The previous user's SDK-auth token stays with the previous user (rule 53).
|
|
765
|
+
this.network.switchUser(userId);
|
|
766
|
+
this.withdrawalUserId = userId === this.identity.getUserId() ? null : userId;
|
|
767
|
+
if (this.withdrawalUserId) this.storage.set(WITHDRAWAL_USER_KEY, this.withdrawalUserId);
|
|
768
|
+
else this.storage.remove(WITHDRAWAL_USER_KEY);
|
|
769
|
+
this.logger.debug('Tracking is off: identify kept on this device for withdrawals only, not sent');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Tracking is allowed again: run the identify that was kept (D-b). */
|
|
773
|
+
private releaseDeferredIdentify(): void {
|
|
774
|
+
const userId = this.withdrawalUserId;
|
|
775
|
+
if (!userId) return;
|
|
776
|
+
this.forgetDeferredIdentify();
|
|
777
|
+
this.identify(userId);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
private forgetDeferredIdentify(): void {
|
|
781
|
+
this.withdrawalUserId = null;
|
|
782
|
+
this.storage.remove(WITHDRAWAL_USER_KEY);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Coalesced: forget the retired tokens of users with nothing left queued
|
|
787
|
+
* (D-a). `delayMs`: a token kept only by its grace period is looked at again.
|
|
788
|
+
*/
|
|
789
|
+
private scheduleRetiredTokenPrune(delayMs: number = 0): void {
|
|
790
|
+
if (this.pruneTimer !== null || this.disabled) return;
|
|
791
|
+
this.pruneTimer = setTimeout(() => {
|
|
792
|
+
this.pruneTimer = null;
|
|
793
|
+
this.pruneRetiredTokens();
|
|
794
|
+
}, delayMs);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private pruneRetiredTokens(): void {
|
|
798
|
+
try {
|
|
799
|
+
if (!this.network.retiredSdkAuthUsers().length) return;
|
|
800
|
+
const users = new Set<string>();
|
|
801
|
+
// The signed-in and the held (D-b) user's kept token is never "nothing
|
|
802
|
+
// left": they are the ones who may still need it (same on iOS and Android).
|
|
803
|
+
const current = this.network.getCurrentUserId();
|
|
804
|
+
if (current) users.add(current);
|
|
805
|
+
if (this.withdrawalUserId) users.add(this.withdrawalUserId);
|
|
806
|
+
for (const e of this.queue.getQueuedEvents()) if (e.userId) users.add(e.userId);
|
|
807
|
+
for (const i of this.queue.getPendingIdentifies()) users.add(i.userId);
|
|
808
|
+
for (const r of this.outbox.records()) {
|
|
809
|
+
const userId = (r.payload as any).userId;
|
|
810
|
+
if (typeof userId === 'string' && userId) users.add(userId);
|
|
811
|
+
}
|
|
812
|
+
for (const e of this.identity.pending.serialize()) if (e.owner?.userId) users.add(e.owner.userId);
|
|
813
|
+
for (const u of this._push.pendingUnregisterUsers()) users.add(u);
|
|
814
|
+
// ...and what EVERY tab persisted (rule 62): this tab's memory is not
|
|
815
|
+
// the whole picture - another tab may hold the logged-out user's
|
|
816
|
+
// unsubscribe, and once that tab reloads the stored token is all it has.
|
|
817
|
+
for (const key of PERSISTED_ITEM_KEYS) collectUserIds(this.storage.get<unknown>(key), users);
|
|
818
|
+
const wait = this.network.forgetRetiredSdkAuthTokensExcept(users);
|
|
819
|
+
if (wait !== null) this.scheduleRetiredTokenPrune(wait + 50);
|
|
820
|
+
} catch (error) {
|
|
821
|
+
this.logger.warn('Could not prune stored SDK authentication tokens:', error);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* A `storage` event: another tab of this site changed what this one holds
|
|
827
|
+
* in memory (contract item 5 - "enforced on every write path" covers every
|
|
828
|
+
* tab). Opt-out and consent are re-read; a changed identity (a reset() or
|
|
829
|
+
* identify() over there) is adopted.
|
|
830
|
+
*/
|
|
831
|
+
private handleStorageEvent(event: any): void {
|
|
832
|
+
try {
|
|
833
|
+
if (!this.isInitialized || this.disabled || !event) return;
|
|
834
|
+
const key: string | null = event.key ?? null;
|
|
835
|
+
const watched = (name: string) => key === null || key === this.storage.storageKeyFor(name);
|
|
836
|
+
|
|
837
|
+
if (key === null) {
|
|
838
|
+
// localStorage.clear() by another tab or script (common at logout).
|
|
839
|
+
// Nothing stored any more is not a decision: an opt-out or a denial
|
|
840
|
+
// held here is never lifted without an explicit grant (rule 62) -
|
|
841
|
+
// and it is stored again, so the next page view keeps it too. With
|
|
842
|
+
// the time it was DECIDED: an explicit grant or opt-in the clearing
|
|
843
|
+
// tab makes after the clear is newer, and wins (rule 77).
|
|
844
|
+
this.repersistPrivacyDecisions();
|
|
845
|
+
// This tab's waiting writes (withdrawals among them) are stored
|
|
846
|
+
// again too, so they survive this tab closing.
|
|
847
|
+
this.outbox.persistNow();
|
|
848
|
+
} else if (
|
|
849
|
+
watched(OPT_OUT_KEY) ||
|
|
850
|
+
watched(OPT_OUT_AT_KEY) ||
|
|
851
|
+
watched(OPT_IN_SIGNAL_KEY) ||
|
|
852
|
+
watched(CONSENT_KEY) ||
|
|
853
|
+
watched(CONSENT_DENIED_AT_KEY) ||
|
|
854
|
+
watched(CONSENT_GRANT_SIGNAL_KEY)
|
|
855
|
+
) {
|
|
856
|
+
const optedOut = this.storedOptOut();
|
|
857
|
+
const denied = this.storedDenial();
|
|
858
|
+
if (optedOut) this.optedOutAt = this.storedStamp(OPT_OUT_AT_KEY);
|
|
859
|
+
if (denied) this.consentDeniedAt = this.storedStamp(CONSENT_DENIED_AT_KEY);
|
|
860
|
+
this.noteDecisionStamps();
|
|
861
|
+
let changed = false;
|
|
862
|
+
if (optedOut !== this.userOptedOut) {
|
|
863
|
+
this.userOptedOut = optedOut;
|
|
864
|
+
changed = true;
|
|
865
|
+
}
|
|
866
|
+
if (denied && this.trackingConsent !== 'denied') {
|
|
867
|
+
this.trackingConsent = 'denied';
|
|
868
|
+
changed = true;
|
|
869
|
+
} else if (!denied && this.trackingConsent === 'denied') {
|
|
870
|
+
// The other tab lifted the denial (only 'granted' does).
|
|
871
|
+
this.trackingConsent = 'granted';
|
|
872
|
+
changed = true;
|
|
873
|
+
} else if (
|
|
874
|
+
!denied &&
|
|
875
|
+
this.trackingConsent === 'pending' &&
|
|
876
|
+
watched(CONSENT_GRANT_SIGNAL_KEY) &&
|
|
877
|
+
this.storage.get<number>(CONSENT_GRANT_SIGNAL_KEY) !== null
|
|
878
|
+
) {
|
|
879
|
+
// The visitor answered the banner in another tab: 'pending' is not
|
|
880
|
+
// stored, so this tab hears the grant through its signal.
|
|
881
|
+
this.trackingConsent = 'granted';
|
|
882
|
+
changed = true;
|
|
883
|
+
}
|
|
884
|
+
// The marker was queued by the tab that made the change.
|
|
885
|
+
if (changed) this.applyPrivacyState();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (key !== null && watched(WITHDRAWAL_USER_KEY)) {
|
|
889
|
+
// Another tab kept (or forgot) a user for withdrawals (D-b, rule 77):
|
|
890
|
+
// the next unsubscribe made HERE goes to that person too, with that
|
|
891
|
+
// person's token - not to the user this tab still had in memory.
|
|
892
|
+
const stored = this.storage.get<string>(WITHDRAWAL_USER_KEY);
|
|
893
|
+
const held = typeof stored === 'string' && stored && stored !== this.identity.getUserId() ? stored : null;
|
|
894
|
+
if (held !== this.withdrawalUserId) {
|
|
895
|
+
this.network.switchUser(held || this.identity.getUserId());
|
|
896
|
+
this.withdrawalUserId = held;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (key !== null && watched(SESSION_ID_KEY) && this.awaitingSessionOf) {
|
|
901
|
+
// The session the other tab started at its logout, stored after its
|
|
902
|
+
// new anonymous id (scan 7 N4).
|
|
903
|
+
if (this.awaitingSessionOf === this.identity.getAnonymousId() && this.storage.get<string>(SESSION_ID_KEY)) {
|
|
904
|
+
this.session.followStoredSession();
|
|
905
|
+
}
|
|
906
|
+
this.awaitingSessionOf = null;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (watched(ANONYMOUS_ID_STORAGE_KEY) || watched(USER_ID_STORAGE_KEY)) {
|
|
910
|
+
const anonymousId = this.storage.get<string>(ANONYMOUS_ID_STORAGE_KEY);
|
|
911
|
+
const userId = this.storage.get<string>(USER_ID_STORAGE_KEY) || null;
|
|
912
|
+
if (!anonymousId) return;
|
|
913
|
+
if (anonymousId === this.identity.getAnonymousId() && userId === this.identity.getUserId()) return;
|
|
914
|
+
this.logger.info('Another tab changed the visitor identity; following it');
|
|
915
|
+
const loggedOut = anonymousId !== this.identity.getAnonymousId();
|
|
916
|
+
// The same bookkeeping reset() / identify() does here (rule 62):
|
|
917
|
+
// in-app first (dismissed as the outgoing user, with their token)...
|
|
918
|
+
if (this.config.inApp.enabled) this.inapp.clearAll();
|
|
919
|
+
else this.inapp.invalidate();
|
|
920
|
+
// ...writes made while this anonymous id was anonymous stay with the
|
|
921
|
+
// user it became, not the next visitor...
|
|
922
|
+
this.pinAnonymousWrites();
|
|
923
|
+
this.network.switchUser(userId);
|
|
924
|
+
this.identity.adopt(anonymousId, userId);
|
|
925
|
+
// Whoever was kept for withdrawals here belongs to the old identity.
|
|
926
|
+
this.withdrawalUserId = null;
|
|
927
|
+
if (loggedOut) {
|
|
928
|
+
// ...and the next visitor's events belong to the session the other
|
|
929
|
+
// tab started for them, not the outgoing visitor's. reset() over
|
|
930
|
+
// there stores the new session AFTER the new anonymous id, so it
|
|
931
|
+
// is followed again when it arrives.
|
|
932
|
+
this.utm.reset();
|
|
933
|
+
this.session.followStoredSession();
|
|
934
|
+
this.awaitingSessionOf = anonymousId;
|
|
935
|
+
}
|
|
936
|
+
this.resyncInApp(true);
|
|
937
|
+
}
|
|
938
|
+
} catch (error) {
|
|
939
|
+
this.logger.error('Could not follow a change made in another tab:', error);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Store this tab's opt-out and denial again (after another tab cleared
|
|
945
|
+
* storage), each with the time it was decided - not now.
|
|
946
|
+
*/
|
|
947
|
+
private repersistPrivacyDecisions(): void {
|
|
948
|
+
if (this.userOptedOut && this.storage.get<boolean>(OPT_OUT_KEY) !== true) this.writeOptOut(this.optedOutAt);
|
|
949
|
+
if (this.trackingConsent === 'denied' && this.storage.get<string>(CONSENT_KEY) !== 'denied') {
|
|
950
|
+
this.writeDenial(this.consentDeniedAt);
|
|
951
|
+
}
|
|
952
|
+
if (this.withdrawalUserId && this.storage.get<string>(WITHDRAWAL_USER_KEY) !== this.withdrawalUserId) {
|
|
953
|
+
this.storage.set(WITHDRAWAL_USER_KEY, this.withdrawalUserId);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** A stored decision time (0 when absent or unreadable). */
|
|
958
|
+
private storedStamp(key: string): number {
|
|
959
|
+
const value = this.storage.get<number>(key);
|
|
960
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Remember the decision times stored by any tab, so the next one is later. */
|
|
964
|
+
private noteDecisionStamps(): void {
|
|
965
|
+
for (const key of [OPT_OUT_AT_KEY, OPT_IN_SIGNAL_KEY, CONSENT_DENIED_AT_KEY, CONSENT_GRANT_SIGNAL_KEY]) {
|
|
966
|
+
this.lastDecisionStamp = Math.max(this.lastDecisionStamp, this.storedStamp(key));
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** A time for an explicit decision made now: later than any decision seen. */
|
|
971
|
+
private nextDecisionStamp(): number {
|
|
972
|
+
this.noteDecisionStamps();
|
|
973
|
+
this.lastDecisionStamp = Math.max(
|
|
974
|
+
Date.now(),
|
|
975
|
+
this.lastDecisionStamp + 1,
|
|
976
|
+
this.optedOutAt + 1,
|
|
977
|
+
this.consentDeniedAt + 1,
|
|
978
|
+
);
|
|
979
|
+
return this.lastDecisionStamp;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** The stored opt-out, unless a newer explicit opt-in is stored (rule 77). */
|
|
983
|
+
private storedOptOut(): boolean {
|
|
984
|
+
if (this.storage.get<boolean>(OPT_OUT_KEY) !== true) return false;
|
|
985
|
+
return !(this.storedStamp(OPT_IN_SIGNAL_KEY) > this.storedStamp(OPT_OUT_AT_KEY));
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** The stored denial, unless a newer explicit grant is stored (rule 77). */
|
|
989
|
+
private storedDenial(): boolean {
|
|
990
|
+
if (this.storage.get<string>(CONSENT_KEY) !== 'denied') return false;
|
|
991
|
+
return !(this.storedStamp(CONSENT_GRANT_SIGNAL_KEY) > this.storedStamp(CONSENT_DENIED_AT_KEY));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** Store an opt-out decided at `at` (now when omitted). */
|
|
995
|
+
private writeOptOut(at?: number): void {
|
|
996
|
+
const stamp = at === undefined ? this.nextDecisionStamp() : at;
|
|
997
|
+
this.optedOutAt = stamp;
|
|
998
|
+
this.storage.set(OPT_OUT_KEY, true);
|
|
999
|
+
this.storage.set(OPT_OUT_AT_KEY, stamp);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** An explicit opt-in: the opt-out goes, and other tabs hear it (even after a clear). */
|
|
1003
|
+
private writeOptIn(): void {
|
|
1004
|
+
// Stamped BEFORE the opt-out's own time is forgotten: later than it.
|
|
1005
|
+
const stamp = this.nextDecisionStamp();
|
|
1006
|
+
this.optedOutAt = 0;
|
|
1007
|
+
this.storage.remove(OPT_OUT_KEY);
|
|
1008
|
+
this.storage.remove(OPT_OUT_AT_KEY);
|
|
1009
|
+
this.storage.set(OPT_IN_SIGNAL_KEY, stamp);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** Store a denial decided at `at` (now when omitted). */
|
|
1013
|
+
private writeDenial(at?: number): void {
|
|
1014
|
+
const stamp = at === undefined ? this.nextDecisionStamp() : at;
|
|
1015
|
+
this.consentDeniedAt = stamp;
|
|
1016
|
+
this.storage.set(CONSENT_KEY, 'denied');
|
|
1017
|
+
this.storage.set(CONSENT_DENIED_AT_KEY, stamp);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* An explicit grant: the denial goes, and other tabs hear it (the signal:
|
|
1022
|
+
* a 'pending' tab stores nothing to hear a change of).
|
|
1023
|
+
*/
|
|
1024
|
+
private writeGrant(): void {
|
|
1025
|
+
// Stamped BEFORE the denial's own time is forgotten: later than it.
|
|
1026
|
+
const stamp = this.nextDecisionStamp();
|
|
1027
|
+
this.consentDeniedAt = 0;
|
|
1028
|
+
this.storage.remove(CONSENT_KEY);
|
|
1029
|
+
this.storage.remove(CONSENT_DENIED_AT_KEY);
|
|
1030
|
+
this.storage.set(CONSENT_GRANT_SIGNAL_KEY, stamp);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* The ids a write is SENT with (contract item 38). Its own user id when it
|
|
1035
|
+
* had one; otherwise the current user id - once the server has merged the
|
|
1036
|
+
* captured anonymous id into that user (no identify of it is still
|
|
1037
|
+
* waiting). Under SDK authentication an anonymous write for an anonymous id
|
|
1038
|
+
* that was already identified is refused (403 SDK_ANONYMOUS_ID_IDENTIFIED),
|
|
1039
|
+
* so a newsletter opt-in made just before sign-up was lost.
|
|
1040
|
+
*/
|
|
1041
|
+
private resolveOwner(owner: WriteOwner): WriteOwner {
|
|
1042
|
+
if (owner.userId) return owner;
|
|
1043
|
+
// The user kept for withdrawals (D-b) counts (rule 63): an unsubscribe
|
|
1044
|
+
// queued as anonymous just before identify('B') while denied reaches B.
|
|
1045
|
+
const userId = this.effectiveUserId();
|
|
1046
|
+
if (!userId || owner.anonymousId !== this.identity.getAnonymousId()) return owner;
|
|
1047
|
+
const identifyWaiting = this.queue.getPendingIdentifies().some((i) => i.anonymousId === owner.anonymousId);
|
|
1048
|
+
return identifyWaiting ? owner : { anonymousId: owner.anonymousId, userId };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Before the identity changes (reset, wipe, identify of another user):
|
|
1053
|
+
* writes made while the current anonymous id was anonymous belong to the
|
|
1054
|
+
* user it became, and must keep going to that user - not to the next one,
|
|
1055
|
+
* and not as an anonymous write of an id that was identified.
|
|
1056
|
+
*/
|
|
1057
|
+
private pinAnonymousWrites(): void {
|
|
1058
|
+
const userId = this.identity.getUserId();
|
|
1059
|
+
if (!userId) return;
|
|
1060
|
+
const anonymousId = this.identity.getAnonymousId();
|
|
1061
|
+
this.identity.pending.pinOwner(anonymousId, userId);
|
|
1062
|
+
this.persistAttributeWrites();
|
|
1063
|
+
this.outbox.pinOwner(anonymousId, userId);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** The request one write is sent as. */
|
|
1067
|
+
private writeRequest(record: WriteRecord, owner: WriteOwner): { url: string; body: any } {
|
|
1068
|
+
const endpoint = this.config.apiEndpoint;
|
|
1069
|
+
const ids = { ...(owner.userId ? { userId: owner.userId } : {}), anonymousId: owner.anonymousId };
|
|
1070
|
+
switch (record.kind) {
|
|
1071
|
+
case 'alias':
|
|
1072
|
+
return { url: `${endpoint}/v1/alias/add`, body: { ...record.payload, ...ids } };
|
|
1073
|
+
case 'subscription':
|
|
1074
|
+
return { url: `${endpoint}/v1/subscriptions/channel`, body: { ...record.payload, ...ids } };
|
|
1075
|
+
case 'group':
|
|
1076
|
+
return { url: `${endpoint}/v1/subscriptions/group`, body: { ...record.payload, ...ids } };
|
|
1077
|
+
case 'marker':
|
|
1078
|
+
default:
|
|
1079
|
+
return {
|
|
1080
|
+
url: `${endpoint}/v1/attributes`,
|
|
1081
|
+
body: {
|
|
1082
|
+
...(owner.userId ? { userId: owner.userId } : { anonymousId: owner.anonymousId }),
|
|
1083
|
+
...this.markerOps(record as Extract<WriteRecord, { kind: 'marker' }>),
|
|
1084
|
+
},
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/** The opt-out marker's attribute write: the switch's value, `true` or `false`. */
|
|
1090
|
+
private markerOps(record: Extract<WriteRecord, { kind: 'marker' }>): AttributeOps {
|
|
1091
|
+
const desired = record.payload.value;
|
|
1092
|
+
return { attributes: { [TRACKING_OPTED_OUT_ATTRIBUTE]: desired } };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
private sendWrite(record: WriteRecord, owner: WriteOwner): Promise<NetworkResponse> {
|
|
1096
|
+
const endpoint = this.config.apiEndpoint;
|
|
1097
|
+
const ids = { ...(owner.userId ? { userId: owner.userId } : {}), anonymousId: owner.anonymousId };
|
|
1098
|
+
switch (record.kind) {
|
|
1099
|
+
case 'alias':
|
|
1100
|
+
return this.network.sendAddAlias(endpoint, { ...record.payload, ...ids });
|
|
1101
|
+
case 'subscription':
|
|
1102
|
+
return this.network.sendSubscriptionUpdate(endpoint, { ...record.payload, ...ids });
|
|
1103
|
+
case 'group':
|
|
1104
|
+
return this.network.sendSubscriptionGroupUpdate(endpoint, { ...record.payload, ...ids });
|
|
1105
|
+
case 'marker':
|
|
1106
|
+
default:
|
|
1107
|
+
return this.network.sendSetAttributes(
|
|
1108
|
+
endpoint,
|
|
1109
|
+
this.markerOps(record as Extract<WriteRecord, { kind: 'marker' }>),
|
|
1110
|
+
owner.userId,
|
|
1111
|
+
owner.anonymousId,
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Bookkeeping when a write leaves the outbox. */
|
|
1117
|
+
private onWriteSettled(entry: OutboxEntry, outcome: 'done' | 'drop'): void {
|
|
1118
|
+
if (entry.record.kind !== 'marker') return;
|
|
1119
|
+
const value = entry.record.payload.value;
|
|
1120
|
+
if (outcome === 'done') {
|
|
1121
|
+
if (value) this.storage.set(REPORTED_OPT_OUT_KEY, true);
|
|
1122
|
+
else this.storage.remove(REPORTED_OPT_OUT_KEY);
|
|
1123
|
+
this.storage.remove(OPT_OUT_MARKER_GIVEN_UP_KEY);
|
|
1124
|
+
} else {
|
|
1125
|
+
this.storage.set(OPT_OUT_MARKER_GIVEN_UP_KEY, value);
|
|
1126
|
+
}
|
|
1127
|
+
// The switch may have flipped again while this was on the wire.
|
|
1128
|
+
this.syncOptOutMarker();
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Tell the push service worker whether it may report deliveries and clicks.
|
|
1133
|
+
* It may not whenever the page may not send: an opt-out, but equally
|
|
1134
|
+
* consent denied or pending and Do Not Track. Only the explicit opt-out
|
|
1135
|
+
* used to be written, so a visitor who denied consent - or whose browser
|
|
1136
|
+
* sends Do Not Track - still had every push delivery and click reported.
|
|
1137
|
+
*/
|
|
1138
|
+
private syncWorkerReporting(): void {
|
|
1139
|
+
// An inert instance must not touch the worker's flag: a valid instance on
|
|
1140
|
+
// the same origin owns it.
|
|
1141
|
+
if (this.disabled) return;
|
|
1142
|
+
this._push.setTrackingOptedOut(this.isOptedOut || this.consentPending).catch(() => undefined);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// ---------------------------------------------------------------------------
|
|
1146
|
+
// Privacy state
|
|
1147
|
+
// ---------------------------------------------------------------------------
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* The consent in force at init (contract item 24):
|
|
1151
|
+
* - explicit `granted` lifts a stored denial;
|
|
1152
|
+
* - explicit `denied` is stored;
|
|
1153
|
+
* - explicit `pending` holds for this page view, never over a stored denial;
|
|
1154
|
+
* - unset: a stored denial, else granted (a site without a banner).
|
|
1155
|
+
*/
|
|
1156
|
+
private initialConsent(configured: TrackingConsent | undefined): TrackingConsent {
|
|
1157
|
+
const storedDenied = this.storedDenial();
|
|
1158
|
+
if (configured === 'granted') {
|
|
1159
|
+
this.storage.remove(CONSENT_KEY);
|
|
1160
|
+
this.storage.remove(CONSENT_DENIED_AT_KEY);
|
|
1161
|
+
return 'granted';
|
|
1162
|
+
}
|
|
1163
|
+
if (configured === 'denied') {
|
|
1164
|
+
this.writeDenial();
|
|
1165
|
+
return 'denied';
|
|
1166
|
+
}
|
|
1167
|
+
if (storedDenied) {
|
|
1168
|
+
this.consentDeniedAt = this.storedStamp(CONSENT_DENIED_AT_KEY);
|
|
1169
|
+
return 'denied';
|
|
1170
|
+
}
|
|
1171
|
+
return configured === 'pending' ? 'pending' : 'granted';
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** Record a consent answer: the same rules for init, pre-init calls and updateConsent(). */
|
|
1175
|
+
private storeConsent(consent: TrackingConsent): TrackingConsent {
|
|
1176
|
+
if (consent === 'granted') {
|
|
1177
|
+
this.writeGrant();
|
|
1178
|
+
return 'granted';
|
|
1179
|
+
}
|
|
1180
|
+
if (consent === 'denied') {
|
|
1181
|
+
this.writeDenial();
|
|
1182
|
+
return 'denied';
|
|
1183
|
+
}
|
|
1184
|
+
// `pending` is for this page view only and never lifts a stored denial.
|
|
1185
|
+
if (this.storedDenial()) {
|
|
1186
|
+
this.logger.info("updateConsent('pending') ignored: consent was denied, and only 'granted' lifts that");
|
|
1187
|
+
return 'denied';
|
|
1188
|
+
}
|
|
1189
|
+
return 'pending';
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** One decision from before init, applied before any side effect. */
|
|
1193
|
+
private applyPreInitDecision(decision: PreInitDecision): void {
|
|
1194
|
+
try {
|
|
1195
|
+
switch (decision.method) {
|
|
1196
|
+
case 'optOut':
|
|
1197
|
+
this.userOptedOut = true;
|
|
1198
|
+
this.writeOptOut();
|
|
1199
|
+
break;
|
|
1200
|
+
case 'optIn':
|
|
1201
|
+
this.userOptedOut = false;
|
|
1202
|
+
this.writeOptIn();
|
|
1203
|
+
break;
|
|
1204
|
+
case 'updateConsent':
|
|
1205
|
+
if (decision.consent === 'granted' || decision.consent === 'denied' || decision.consent === 'pending') {
|
|
1206
|
+
this.trackingConsent = this.storeConsent(decision.consent);
|
|
1207
|
+
}
|
|
1208
|
+
break;
|
|
1209
|
+
case 'wipeData':
|
|
1210
|
+
// Push first, while the outgoing ids are still current (it
|
|
1211
|
+
// captures them); it is told to the server once sending starts.
|
|
1212
|
+
this._push.forget().catch(() => undefined);
|
|
1213
|
+
this.wipeLocalState();
|
|
1214
|
+
// The wipe came first; the init config's user id - and its SDK
|
|
1215
|
+
// authentication token, which the wipe retired with the erased
|
|
1216
|
+
// identity - apply after it. Without the token that user's
|
|
1217
|
+
// requests went out unauthenticated until the host's auth
|
|
1218
|
+
// handler minted a new one.
|
|
1219
|
+
if (this.config.userId) {
|
|
1220
|
+
this.identity.identify(this.config.userId);
|
|
1221
|
+
this.network.setCurrentUserId(this.config.userId);
|
|
1222
|
+
}
|
|
1223
|
+
if (this.config.enableSdkAuthentication && this.config.sdkAuthenticationToken) {
|
|
1224
|
+
this.network.setSdkAuthenticationToken(this.config.sdkAuthenticationToken);
|
|
1225
|
+
}
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
this.logger.error(`Applying ${decision.method} from before init failed:`, error);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* The current consent: `granted`, `denied` or `pending`. Independent of the
|
|
1235
|
+
* visitor's own opt-out (see isUserOptedOut()).
|
|
1236
|
+
*/
|
|
1237
|
+
getTrackingConsent(): TrackingConsent {
|
|
1238
|
+
return this.trackingConsent;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Whether the visitor opted out (optOut(), or `optOut: true` at init).
|
|
1243
|
+
* Reflects ONLY that switch - not consent, not Do Not Track.
|
|
1244
|
+
*/
|
|
1245
|
+
isUserOptedOut(): boolean {
|
|
1246
|
+
return this.userOptedOut;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Keep the profile's `tracking_opted_out` in step with the opt-out switch
|
|
1251
|
+
* (contract items 33 and 36): queued when it CHANGES - including a decision
|
|
1252
|
+
* made in the init config or before init - and not at all while unchanged.
|
|
1253
|
+
*
|
|
1254
|
+
* `true` is a WITHDRAWAL: queued in the persisted outbox and delivered even
|
|
1255
|
+
* while opted out (that is the point), with consent denied or pending, and
|
|
1256
|
+
* after wipeData() - with the ids of the person who opted out. `false`
|
|
1257
|
+
* (opting back in) is an ordinary write and waits for tracking to be
|
|
1258
|
+
* allowed. Retried by the one rule (contract item 40); a value refused for
|
|
1259
|
+
* good is not re-sent on every page view (a fresh optOut() or optIn() call
|
|
1260
|
+
* clears that and tries again).
|
|
1261
|
+
*/
|
|
1262
|
+
private syncOptOutMarker(): void {
|
|
1263
|
+
if (this.disabled || !this.isInitialized) return;
|
|
1264
|
+
const desired = this.userOptedOut;
|
|
1265
|
+
const reported = this.storage.get<boolean>(REPORTED_OPT_OUT_KEY) === true;
|
|
1266
|
+
const anonymousId = this.identity.getAnonymousId();
|
|
1267
|
+
const queuedLast = this.outbox.findLast(
|
|
1268
|
+
(e) => e.record.kind === 'marker' && e.record.payload.anonymousId === anonymousId,
|
|
1269
|
+
);
|
|
1270
|
+
if (queuedLast) {
|
|
1271
|
+
if (queuedLast.record.kind === 'marker' && queuedLast.record.payload.value === desired) return;
|
|
1272
|
+
if (desired === reported && !this.outbox.isInFlight(queuedLast)) {
|
|
1273
|
+
// Flipped back before it was sent: nothing to tell the server.
|
|
1274
|
+
this.outbox.cancel((e) => e.record.kind === 'marker' && e.record.payload.anonymousId === anonymousId);
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
} else {
|
|
1278
|
+
if (desired === reported) return;
|
|
1279
|
+
if (this.storage.get<boolean>(OPT_OUT_MARKER_GIVEN_UP_KEY) === desired) return;
|
|
1280
|
+
}
|
|
1281
|
+
this.outbox.enqueue({ kind: 'marker', payload: { value: desired, ...this.currentOwner() } });
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
private recomputePrivacy(): void {
|
|
1285
|
+
this.isOptedOut = this.disabled || this.userOptedOut || this.dntEnabled || this.trackingConsent === 'denied';
|
|
1286
|
+
this.consentPending = !this.isOptedOut && this.trackingConsent === 'pending';
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** May anything leave the device right now? */
|
|
1290
|
+
private canSend(): boolean {
|
|
1291
|
+
return (
|
|
1292
|
+
this.isInitialized &&
|
|
1293
|
+
!this.isOptedOut &&
|
|
1294
|
+
!this.consentPending &&
|
|
1295
|
+
!this.network.isSuspended()
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Bring every component in line with the current opt-out / consent state.
|
|
1301
|
+
* The one place that starts and stops collection, so opt-out, consent and
|
|
1302
|
+
* opt-in cannot each stop a different subset of it.
|
|
1303
|
+
*/
|
|
1304
|
+
private applyPrivacyState(): void {
|
|
1305
|
+
const wasOptedOut = this.isOptedOut;
|
|
1306
|
+
const wasPending = this.consentPending;
|
|
1307
|
+
this.recomputePrivacy();
|
|
1308
|
+
this.syncWorkerReporting();
|
|
1309
|
+
if (wasOptedOut !== this.isOptedOut || wasPending !== this.consentPending) {
|
|
1310
|
+
// In-app answers requested under the previous state are stale.
|
|
1311
|
+
this.inapp.invalidate();
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (this.isOptedOut) {
|
|
1315
|
+
// Collect nothing - and keep nothing that was waiting to be sent. A
|
|
1316
|
+
// Session Start that never left goes too, so a later grant sends one -
|
|
1317
|
+
// also a grant passed at init on a later page view: the marker says
|
|
1318
|
+
// "none queued for any session yet" (''), never absent, which init
|
|
1319
|
+
// reads as "sent" (rule 85, the sibling of the wipe fix, rule 75).
|
|
1320
|
+
//
|
|
1321
|
+
// Only one still WAITING in the queue (rule 90). One already on the
|
|
1322
|
+
// wire is settled by the server's answer: stored = sent (the marker
|
|
1323
|
+
// keeps this session), anything else = owed ('').
|
|
1324
|
+
const sessionId = this.session.getSessionId();
|
|
1325
|
+
const isStart = (e: QueuedEvent) => e.type === 'Session Start' && e.sessionId === sessionId;
|
|
1326
|
+
if (this.queue.getWaitingEvents().some(isStart)) {
|
|
1327
|
+
this.storage.set(SESSION_START_SENT_KEY, '');
|
|
1328
|
+
} else if (sessionId && this.queue.getInflightEvents().some(isStart)) {
|
|
1329
|
+
this.inflightSessionStartAtClear = sessionId;
|
|
1330
|
+
}
|
|
1331
|
+
this.queue.stop();
|
|
1332
|
+
this.queue.clear();
|
|
1333
|
+
this.queue.setPaused(false);
|
|
1334
|
+
this.clearPendingAttributeWrites();
|
|
1335
|
+
// Every profile write stops - except withdrawals (an unsubscribe, the
|
|
1336
|
+
// opt-out marker), which are always delivered (contract item 36).
|
|
1337
|
+
this.outbox.discardNonWithdrawals();
|
|
1338
|
+
this.outbox.kick();
|
|
1339
|
+
if (this.config.inApp.enabled) this.inapp.stop();
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Collection is allowed again (granted or pending). An identify() made
|
|
1344
|
+
// while it was off now runs normally (D-b) - FIRST, so what follows is
|
|
1345
|
+
// about that user, not the one signed in before (rule 63)...
|
|
1346
|
+
if (wasOptedOut) this.releaseDeferredIdentify();
|
|
1347
|
+
// ...then a session that began while it was off gets its Session Start
|
|
1348
|
+
// (scan 3, web #10).
|
|
1349
|
+
if (wasOptedOut) this.trackSessionStartIfMissing();
|
|
1350
|
+
|
|
1351
|
+
if (this.consentPending) {
|
|
1352
|
+
// Collect, hold, send nothing.
|
|
1353
|
+
this.queue.stop();
|
|
1354
|
+
this.queue.setPaused(true);
|
|
1355
|
+
if (this.config.inApp.enabled) this.inapp.stop();
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// Granted: release what was held, in order - the queue (Session Start
|
|
1360
|
+
// first, then events, with any identify), then attributes and other writes.
|
|
1361
|
+
this.queue.setPaused(false);
|
|
1362
|
+
if (!this.queue.isStarted()) this.queue.start();
|
|
1363
|
+
this.queue.flush().catch((error) => this.logger.error('Flush failed:', error));
|
|
1364
|
+
|
|
1365
|
+
this.releaseHeldWrites();
|
|
1366
|
+
|
|
1367
|
+
if (this.config.inApp.enabled) {
|
|
1368
|
+
this.inapp.initialize().catch((error) => {
|
|
1369
|
+
this.logger.error('Failed to initialize in-app messaging:', error);
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
this._push.syncExistingSubscription().catch(() => undefined);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/** Send the attribute and profile writes held while consent was pending. */
|
|
1376
|
+
private releaseHeldWrites(): void {
|
|
1377
|
+
if (!this.identity.pending.isEmpty()) {
|
|
1378
|
+
this.scheduleAttributeFlush(0);
|
|
1379
|
+
}
|
|
1380
|
+
this.outbox.kick();
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* Queue a profile write (alias, subscription, group) in the outbox. A
|
|
1385
|
+
* withdrawal (unsubscribe) is always accepted; anything else is refused
|
|
1386
|
+
* while opted out or consent is denied, and held while it is pending.
|
|
1387
|
+
*/
|
|
1388
|
+
private runWrite(label: string, record: WriteRecord): void {
|
|
1389
|
+
if (!this.isInitialized || this.disabled) {
|
|
1390
|
+
this.logger.error(`${label}: SDK not initialized`);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
if ((this.isOptedOut || this.dataGate.size > 0) && !isWithdrawal(record)) {
|
|
1394
|
+
this.logger.debug(`${label} not sent: tracking is off`);
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
// A withdrawal made after identify() while tracking was off goes to the
|
|
1398
|
+
// user that identify() named (D-b), not to the anonymous profile.
|
|
1399
|
+
if (this.withdrawalUserId && isWithdrawal(record) && record.payload.anonymousId === this.identity.getAnonymousId()) {
|
|
1400
|
+
record = { ...record, payload: { ...record.payload, userId: this.withdrawalUserId } } as WriteRecord;
|
|
1401
|
+
}
|
|
1402
|
+
this.outbox.enqueue(record);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* Bring back the attribute writes an earlier page view did not get
|
|
1407
|
+
* acknowledged. Discarded if the visitor opted out or consent was denied.
|
|
1408
|
+
*/
|
|
1409
|
+
private restoreAttributeWrites(): void {
|
|
1410
|
+
const attrs = this.storage.get<{ entries?: unknown; ops?: AttributeOps | null; increment?: Record<string, number> }>(ATTRIBUTE_WRITES_KEY);
|
|
1411
|
+
if (this.isOptedOut) {
|
|
1412
|
+
this.storage.remove(ATTRIBUTE_WRITES_KEY);
|
|
1413
|
+
this.outbox.discardNonWithdrawals();
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
if (attrs && typeof attrs === 'object') {
|
|
1417
|
+
if (Array.isArray(attrs.entries)) {
|
|
1418
|
+
this.identity.pending.restore(attrs.entries);
|
|
1419
|
+
} else {
|
|
1420
|
+
// Stored by an earlier version: { ops, increment }.
|
|
1421
|
+
if (attrs.ops && typeof attrs.ops === 'object') this.identity.pending.mark(attrs.ops);
|
|
1422
|
+
if (attrs.increment && typeof attrs.increment === 'object') this.identity.pending.mark({ increment: attrs.increment });
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* The attribute writes not acknowledged yet survive a navigation (contract
|
|
1429
|
+
* item 37). Kept only until the server acknowledges them.
|
|
1430
|
+
*/
|
|
1431
|
+
private persistAttributeWrites(): void {
|
|
1432
|
+
if (this.identity.pending.isEmpty()) {
|
|
1433
|
+
this.storage.remove(ATTRIBUTE_WRITES_KEY);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
this.storage.set(ATTRIBUTE_WRITES_KEY, { entries: this.identity.pending.serialize() });
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Before a profile write: get the queued events and any identify to the
|
|
1441
|
+
* server first, so the contact the write addresses exists and is merged.
|
|
1442
|
+
*/
|
|
1443
|
+
private async flushQueueForWrite(): Promise<void> {
|
|
1444
|
+
if (!this.canSend()) return;
|
|
1445
|
+
if (this.queue.getPendingIdentify() || this.queue.getQueueSize() > 0) {
|
|
1446
|
+
await this.queue.flush().catch(() => undefined);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Track session start event (internal)
|
|
1452
|
+
* Includes landing page data so backend can create initial Page View
|
|
1453
|
+
*/
|
|
1454
|
+
private trackSessionStart(): void {
|
|
1455
|
+
this.logger.debug('Tracking session start');
|
|
1456
|
+
|
|
1457
|
+
// Track Session Start event
|
|
1458
|
+
// Note: sessionCount is managed server-side by counting Session Start events
|
|
1459
|
+
// This ensures accurate cross-device session counting
|
|
1460
|
+
const properties: Record<string, any> = {};
|
|
1461
|
+
|
|
1462
|
+
if (this.config.trackDeviceProperties) {
|
|
1463
|
+
Object.assign(properties, getDeviceProperties());
|
|
1464
|
+
properties.$device_id = this.deviceId;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// Include page data for backend to create initial Page View
|
|
1468
|
+
if (typeof window !== 'undefined' && window.location) {
|
|
1469
|
+
properties.landing_page = window.location.href;
|
|
1470
|
+
properties.landing_path = window.location.pathname;
|
|
1471
|
+
properties.landing_search = window.location.search;
|
|
1472
|
+
properties.landing_title = typeof document !== 'undefined' ? document.title : '';
|
|
1473
|
+
properties.landing_referrer = typeof document !== 'undefined' ? (document.referrer || null) : null;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
this.track('Session Start', properties);
|
|
1477
|
+
this.storage.set(SESSION_START_SENT_KEY, this.session.getSessionId());
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** Queue this session's Session Start if it never was (tracking was off when it began). */
|
|
1481
|
+
private trackSessionStartIfMissing(): void {
|
|
1482
|
+
if (!this.config.trackSessionStart || this.isOptedOut) return;
|
|
1483
|
+
// No session (after wipeData()): starting one announces it, with its
|
|
1484
|
+
// Session Start, through onNewSession.
|
|
1485
|
+
this.session.ensureSession();
|
|
1486
|
+
if (this.storage.get<string>(SESSION_START_SENT_KEY) === this.session.getSessionId()) return;
|
|
1487
|
+
this.trackSessionStart();
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
private getOrCreateDeviceId(): string {
|
|
1491
|
+
const key = 'device_id';
|
|
1492
|
+
const existing = this.storage.get<string>(key);
|
|
1493
|
+
if (existing) {
|
|
1494
|
+
return existing;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const deviceId = generateUUID();
|
|
1498
|
+
this.storage.set(key, deviceId);
|
|
1499
|
+
return deviceId;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
private trackPageView(): void {
|
|
1503
|
+
if (typeof window === 'undefined' || !window.location) {
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const url = window.location.href;
|
|
1508
|
+
if (this.lastTrackedUrl === url) {
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const properties: Record<string, any> = {
|
|
1513
|
+
url,
|
|
1514
|
+
path: window.location.pathname,
|
|
1515
|
+
search: window.location.search,
|
|
1516
|
+
title: typeof document !== 'undefined' ? document.title : '',
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
if (this.config.trackPageProperties && typeof document !== 'undefined') {
|
|
1520
|
+
properties.referrer = document.referrer || null;
|
|
1521
|
+
properties.landing_page = this.utm.getSessionLandingPage() || null;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
this.lastTrackedUrl = url;
|
|
1525
|
+
this.track('Page Viewed', properties);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Track an event
|
|
1530
|
+
*/
|
|
1531
|
+
track(eventName: string, properties?: Record<string, any>): void {
|
|
1532
|
+
if (!this.isInitialized) {
|
|
1533
|
+
this.logger.error('SDK not initialized');
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (this.isDroppingDataCalls('track')) return;
|
|
1537
|
+
|
|
1538
|
+
if (this.isOptedOut) {
|
|
1539
|
+
this.logger.debug('Tracking disabled, event not tracked:', eventName);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Refused here, not by the server: an empty or over-long name is a 400,
|
|
1544
|
+
// and a 400 used to re-queue the whole batch at the front forever.
|
|
1545
|
+
// Exactly the server's rule (isValidSdkEventName): 1-255 characters. A
|
|
1546
|
+
// whitespace-only name is accepted there, so it is not refused here.
|
|
1547
|
+
if (typeof eventName !== 'string' || eventName.length < 1 || eventName.length > MAX_NAME_LENGTH) {
|
|
1548
|
+
this.logger.error(`track() needs an event name of 1-${MAX_NAME_LENGTH} characters; event dropped`);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
try {
|
|
1553
|
+
// An idle session ends HERE too, not only when the tab becomes visible
|
|
1554
|
+
// again: a tab left open and visible for an hour kept one session
|
|
1555
|
+
// forever (scan 3, web #5). The new session's Session Start (from the
|
|
1556
|
+
// session's onNewSession hook) is queued BEFORE this event.
|
|
1557
|
+
if (eventName !== 'Session Start') this.session.refreshIfExpired();
|
|
1558
|
+
// No session at all (after wipeData()): one starts here, with its
|
|
1559
|
+
// Session Start queued first (rule 54 - reads never start one).
|
|
1560
|
+
this.session.ensureSession();
|
|
1561
|
+
|
|
1562
|
+
// Update session activity on every tracked event
|
|
1563
|
+
this.session.updateActivity();
|
|
1564
|
+
|
|
1565
|
+
// Shallow-copy the caller's properties before injecting internal keys so we
|
|
1566
|
+
// never mutate the object the caller passed (they may reuse it).
|
|
1567
|
+
const eventProperties: Record<string, any> =
|
|
1568
|
+
properties && typeof properties === 'object' && !Array.isArray(properties) ? { ...properties } : {};
|
|
1569
|
+
|
|
1570
|
+
// Merge event properties with UTM parameters if enabled
|
|
1571
|
+
if (this.config.captureUTM) {
|
|
1572
|
+
Object.assign(eventProperties, this.utm.getUTMProperties());
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Always include device_id in event properties for tracking
|
|
1576
|
+
if (this.deviceId && !eventProperties.$device_id) {
|
|
1577
|
+
eventProperties.$device_id = this.deviceId;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const event: QueuedEvent = {
|
|
1581
|
+
eventId: generateUUID(), // Client-generated ID for idempotency
|
|
1582
|
+
type: eventName,
|
|
1583
|
+
properties: eventProperties,
|
|
1584
|
+
// ISO 8601 UTC with milliseconds, the same on every SDK (rule 55).
|
|
1585
|
+
timestamp: new Date().toISOString(),
|
|
1586
|
+
userId: this.identity.getUserId(),
|
|
1587
|
+
anonymousId: this.identity.getAnonymousId(),
|
|
1588
|
+
sessionId: this.session.getSessionId(),
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
// Run beforeTrack hooks (web-only plugin API)
|
|
1592
|
+
let processedEvent: any = event;
|
|
1593
|
+
for (const plugin of this.config.plugins) {
|
|
1594
|
+
if (plugin.beforeTrack) {
|
|
1595
|
+
try {
|
|
1596
|
+
processedEvent = plugin.beforeTrack(processedEvent);
|
|
1597
|
+
} catch (error) {
|
|
1598
|
+
this.logger.error(`Plugin ${plugin.name} beforeTrack threw; event kept unchanged:`, error);
|
|
1599
|
+
}
|
|
1600
|
+
if (processedEvent === null) {
|
|
1601
|
+
this.logger.debug(`Event blocked by plugin: ${plugin.name}`);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// A property whose value JSON cannot carry (NaN, Infinity, a BigInt,
|
|
1608
|
+
// a function, a circular object) is dropped with a warning; the event
|
|
1609
|
+
// goes without it (rule 49).
|
|
1610
|
+
if (processedEvent && processedEvent.properties && typeof processedEvent.properties === 'object') {
|
|
1611
|
+
processedEvent = {
|
|
1612
|
+
...processedEvent,
|
|
1613
|
+
properties: withoutUnsendableValues(processedEvent.properties, (key, why) =>
|
|
1614
|
+
this.logger.warn(`Event "${eventName}": property "${key}" dropped - ${why}`),
|
|
1615
|
+
),
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// Refused here, not by the server: properties over the API's limits
|
|
1620
|
+
// are refused for good, so sending them only costs a request
|
|
1621
|
+
// (contract item 29).
|
|
1622
|
+
const problem =
|
|
1623
|
+
processedEvent && processedEvent.properties && typeof processedEvent.properties === 'object'
|
|
1624
|
+
? eventPropertiesProblem(processedEvent.properties)
|
|
1625
|
+
: null;
|
|
1626
|
+
if (problem) {
|
|
1627
|
+
this.logger.error(`Event "${eventName}" dropped: ${problem}`);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Add to queue
|
|
1632
|
+
this.queue.enqueue(processedEvent);
|
|
1633
|
+
|
|
1634
|
+
// Show anything waiting on this event - locally, with no round trip.
|
|
1635
|
+
// AFTER enqueue, deliberately: the event is data and must be recorded
|
|
1636
|
+
// whether or not it triggers a message, and a throwing display path must
|
|
1637
|
+
// not lose the event. Triggering is a display decision, not a substitute
|
|
1638
|
+
// for analytics.
|
|
1639
|
+
if (this.canSend()) {
|
|
1640
|
+
try {
|
|
1641
|
+
this.inapp?.onEventTracked(eventName, eventProperties);
|
|
1642
|
+
} catch (error) {
|
|
1643
|
+
this.logger.error('In-app trigger evaluation failed:', error);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// Run afterTrack hooks
|
|
1648
|
+
for (const plugin of this.config.plugins) {
|
|
1649
|
+
if (plugin.afterTrack) {
|
|
1650
|
+
try {
|
|
1651
|
+
plugin.afterTrack(processedEvent);
|
|
1652
|
+
} catch (error) {
|
|
1653
|
+
this.logger.error(`Plugin ${plugin.name} afterTrack threw:`, error);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// Send immediately if configured
|
|
1659
|
+
if (this.config.sendImmediately && this.canSend()) {
|
|
1660
|
+
this.flush().catch((error) => this.logger.error('Immediate flush failed:', error));
|
|
1661
|
+
}
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
// Never throw into the host page.
|
|
1664
|
+
this.logger.error('track() failed:', error);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Identify a user.
|
|
1670
|
+
*
|
|
1671
|
+
* Sends queued events AND identify data in a single atomic HTTP request.
|
|
1672
|
+
* The backend processes events first, then merges the anonymous user —
|
|
1673
|
+
* guaranteed ordering eliminates the Session Start race condition.
|
|
1674
|
+
*/
|
|
1675
|
+
identify(userId: string): void {
|
|
1676
|
+
if (!this.isInitialized) {
|
|
1677
|
+
this.logger.error('SDK not initialized');
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
if (typeof userId !== 'string' || !userId.trim() || userId.length > MAX_NAME_LENGTH) {
|
|
1682
|
+
this.logger.error(`identify() needs a userId of 1-${MAX_NAME_LENGTH} characters`);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
if (this.isOptedOut) {
|
|
1687
|
+
if (!this.disabled) this.deferIdentify(userId);
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
// An explicit identify replaces any user kept for withdrawals (rule 63).
|
|
1691
|
+
if (this.withdrawalUserId) this.forgetDeferredIdentify();
|
|
1692
|
+
|
|
1693
|
+
const previousUserId = this.identity.getUserId();
|
|
1694
|
+
const anonymousId = this.identity.getAnonymousId();
|
|
1695
|
+
|
|
1696
|
+
// Store identify intent — the next flush will include it in the batch request
|
|
1697
|
+
this.queue.setPendingIdentify({
|
|
1698
|
+
userId,
|
|
1699
|
+
anonymousId,
|
|
1700
|
+
attributes: {},
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
// Update local identity immediately so subsequent track() calls use the new userId
|
|
1704
|
+
const switching = !!previousUserId && previousUserId !== userId;
|
|
1705
|
+
if (switching) {
|
|
1706
|
+
this.pinAnonymousWrites();
|
|
1707
|
+
// A's message on screen is A's: dismissed (tracked as A, with A's
|
|
1708
|
+
// token) before anything else changes, like reset() does.
|
|
1709
|
+
if (this.config.inApp.enabled) this.inapp.clearAll();
|
|
1710
|
+
}
|
|
1711
|
+
this.identity.identify(userId);
|
|
1712
|
+
// identify('B') while A was signed in, without reset(), is a reset for
|
|
1713
|
+
// the token too (rule 53): A's token is retired to A's queued items, so
|
|
1714
|
+
// they go out with A's token instead of B's - unless the token already
|
|
1715
|
+
// set is B's own. Also keeps the user id for auth-failure reporting.
|
|
1716
|
+
this.network.switchUser(userId);
|
|
1717
|
+
// In-app answers requested for the previous identity are stale.
|
|
1718
|
+
if (previousUserId !== userId) this.inapp.invalidate();
|
|
1719
|
+
|
|
1720
|
+
if (!this.canSend()) return; // consent pending: sent when granted
|
|
1721
|
+
|
|
1722
|
+
// Force flush — sends { events: [...], identify: {...} } in one request,
|
|
1723
|
+
// then ask for this person's in-app campaigns (an SPA never reloads, so
|
|
1724
|
+
// nothing else would).
|
|
1725
|
+
void this.queue
|
|
1726
|
+
.flush(false, true)
|
|
1727
|
+
.catch((error) => {
|
|
1728
|
+
this.logger.error('Failed to flush with identify:', error);
|
|
1729
|
+
})
|
|
1730
|
+
.then(() => {
|
|
1731
|
+
// After a switch the in-app state was cleared: start it again.
|
|
1732
|
+
if (previousUserId !== userId) this.resyncInApp(switching);
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* Add a labelled alias (e.g. `addAlias('crm_id', 'C-123')`) to this visitor.
|
|
1738
|
+
*
|
|
1739
|
+
* Works for anonymous visitors as well as identified ones: the request
|
|
1740
|
+
* carries `anonymousId` always and `userId` once identified.
|
|
1741
|
+
*/
|
|
1742
|
+
addAlias(aliasLabel: string, aliasName: string): void {
|
|
1743
|
+
if (!this.isInitialized) {
|
|
1744
|
+
this.logger.error('SDK not initialized');
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
if (this.isDroppingDataCalls('addAlias')) return;
|
|
1748
|
+
|
|
1749
|
+
if (typeof aliasLabel !== 'string' || !aliasLabel.trim() || typeof aliasName !== 'string' || !aliasName.trim()) {
|
|
1750
|
+
this.logger.error('addAlias(aliasLabel, aliasName) needs two non-empty strings');
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// Captured now, so a reset() before the request goes out cannot move the
|
|
1755
|
+
// alias onto the next visitor.
|
|
1756
|
+
const userId = this.identity.getUserId();
|
|
1757
|
+
const payload = {
|
|
1758
|
+
anonymousId: this.identity.getAnonymousId(),
|
|
1759
|
+
...(userId ? { userId } : {}),
|
|
1760
|
+
aliasLabel,
|
|
1761
|
+
aliasName,
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
this.runWrite('addAlias', { kind: 'alias', payload });
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* Set user attributes
|
|
1769
|
+
*/
|
|
1770
|
+
setAttributes(attributes: Record<string, any>): void {
|
|
1771
|
+
if (!this.isInitialized) {
|
|
1772
|
+
this.logger.error('SDK not initialized');
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (this.isDroppingDataCalls('setAttributes')) return;
|
|
1776
|
+
|
|
1777
|
+
// Validate that attributes is a plain object
|
|
1778
|
+
if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
|
|
1779
|
+
this.logger.error('setAttributes requires a plain object, e.g. setAttributes({ email: "user@example.com" })');
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// A null (or undefined) value UNSETS the attribute - the same on every
|
|
1784
|
+
// platform (contract addendum 19). Sent as a `$set` of null it stored a
|
|
1785
|
+
// literal null on web while the native SDKs removed the key, so one call
|
|
1786
|
+
// produced two different profiles.
|
|
1787
|
+
const set: Record<string, any> = {};
|
|
1788
|
+
const unset: string[] = [];
|
|
1789
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
1790
|
+
if (!key) continue;
|
|
1791
|
+
if (value === null || value === undefined) {
|
|
1792
|
+
unset.push(key);
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
// A value JSON cannot carry would block every later attribute write
|
|
1796
|
+
// (each request carries the oldest write of every key): that key is
|
|
1797
|
+
// dropped with a warning, the others go (rule 49).
|
|
1798
|
+
const problem = valueProblem(value);
|
|
1799
|
+
if (problem) {
|
|
1800
|
+
this.logger.warn(`Attribute "${key}" not set: its value is ${problem}`);
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
set[key] = value;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
this.identity.setAttributes(set);
|
|
1807
|
+
for (const key of unset) this.identity.unsetAttribute(key);
|
|
1808
|
+
|
|
1809
|
+
// Send to backend for both identified and anonymous users.
|
|
1810
|
+
// setAttributes → absolute `$set` section; null values → `$unset`.
|
|
1811
|
+
if (!this.isOptedOut && (Object.keys(set).length || unset.length)) {
|
|
1812
|
+
this.sendAttributesAfterPendingIdentify({
|
|
1813
|
+
...(Object.keys(set).length ? { attributes: set } : {}),
|
|
1814
|
+
...(unset.length ? { unset } : {}),
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* Queue an attribute-operations payload. It is sent after a short debounce,
|
|
1821
|
+
* and only once any pending identify has reached the server - otherwise the
|
|
1822
|
+
* write hits /attributes before the anon→identified merge and lands on the
|
|
1823
|
+
* wrong profile. Applies to every attribute op (set / increment / append /
|
|
1824
|
+
* remove / unset).
|
|
1825
|
+
*/
|
|
1826
|
+
private sendAttributesAfterPendingIdentify(ops: AttributeOps): void {
|
|
1827
|
+
// Coalesce a burst into one request. Six setAttributes calls on page load
|
|
1828
|
+
// should be one round trip, not six.
|
|
1829
|
+
//
|
|
1830
|
+
// Every write lands in the ordered log (PendingAttributes) in call order:
|
|
1831
|
+
// two addToArray calls on one key both reach the server, and a later
|
|
1832
|
+
// write to a key never shares a request with - or overtakes - an earlier
|
|
1833
|
+
// one (contract item 31). Increments are summed while they wait.
|
|
1834
|
+
this.identity.pending.mark(ops, this.currentOwner());
|
|
1835
|
+
this.persistAttributeWrites();
|
|
1836
|
+
this.scheduleAttributeFlush(JoryioSDK.ATTRIBUTE_FLUSH_DEBOUNCE_MS);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
private scheduleAttributeFlush(delayMs: number): void {
|
|
1840
|
+
if (this.attributeFlushTimer !== null) return;
|
|
1841
|
+
this.attributeFlushTimer = setTimeout(() => {
|
|
1842
|
+
this.attributeFlushTimer = null;
|
|
1843
|
+
// Consent pending (or sending suspended): keep everything for when it
|
|
1844
|
+
// may be sent.
|
|
1845
|
+
if (!this.canSend()) return;
|
|
1846
|
+
this.flushAttributeOps();
|
|
1847
|
+
}, delayMs);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/** Drop every attribute write not yet sent (opt-out, consent denied, reset). */
|
|
1851
|
+
private clearPendingAttributeWrites(): void {
|
|
1852
|
+
if (this.attributeFlushTimer !== null) {
|
|
1853
|
+
clearTimeout(this.attributeFlushTimer);
|
|
1854
|
+
this.attributeFlushTimer = null;
|
|
1855
|
+
}
|
|
1856
|
+
this.identity.pending.clear();
|
|
1857
|
+
// An answer still on its way belongs to the store just emptied.
|
|
1858
|
+
this.attributeGeneration++;
|
|
1859
|
+
this.attributeFlushInFlight = false;
|
|
1860
|
+
this.attributeFlushAgain = false;
|
|
1861
|
+
this.attributeFailures = 0;
|
|
1862
|
+
this.storage.remove(ATTRIBUTE_WRITES_KEY);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* Send the next request's worth of attribute writes: the oldest pending
|
|
1867
|
+
* write of every key. Serialized - one request at a time - and repeated
|
|
1868
|
+
* until the log is empty. scheduleAttributeFlush is the normal entry point.
|
|
1869
|
+
*/
|
|
1870
|
+
private flushAttributeOps(): void {
|
|
1871
|
+
const pendingIdentify = this.queue.getPendingIdentify();
|
|
1872
|
+
const ready = pendingIdentify ? this.queue.flush(false, true).catch(() => undefined) : Promise.resolve();
|
|
1873
|
+
void ready.then(() => {
|
|
1874
|
+
// Opted out meanwhile: applyPrivacyState cleared the store. Pending
|
|
1875
|
+
// consent / suspended: everything stays for later.
|
|
1876
|
+
if (!this.canSend()) return;
|
|
1877
|
+
if (this.queue.getPendingIdentify()) {
|
|
1878
|
+
// The identify has not reached the server yet (offline, backing off).
|
|
1879
|
+
// Sending now would write onto the anonymous profile; wait instead.
|
|
1880
|
+
this.scheduleAttributeFlush(this.attributeRetryDelay());
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// Serialize + coalesce. A call arriving mid-flight raises a flag, and
|
|
1885
|
+
// one follow-up flush covers everything it added.
|
|
1886
|
+
if (this.attributeFlushInFlight) {
|
|
1887
|
+
this.attributeFlushAgain = true;
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
const request = this.identity.pending.nextRequest((o) => this.resolveOwner(o ?? this.currentOwner()));
|
|
1891
|
+
if (!request) return;
|
|
1892
|
+
this.attributeFlushInFlight = true;
|
|
1893
|
+
const generation = this.attributeGeneration;
|
|
1894
|
+
const { ops, entries } = request;
|
|
1895
|
+
// The ids of the person who made these writes (contract items 31, 38).
|
|
1896
|
+
const owner = this.resolveOwner(request.owner ?? this.currentOwner());
|
|
1897
|
+
|
|
1898
|
+
this.network.sendSetAttributes(
|
|
1899
|
+
this.config.apiEndpoint,
|
|
1900
|
+
ops,
|
|
1901
|
+
owner.userId,
|
|
1902
|
+
owner.anonymousId,
|
|
1903
|
+
).then((res: NetworkResponse) => {
|
|
1904
|
+
if (generation !== this.attributeGeneration) return; // cleared meanwhile (opt-out, wipe)
|
|
1905
|
+
// Acknowledge ONLY on success. `request()` never throws - it resolves
|
|
1906
|
+
// `{ ok: false }` - so the old `.then(ack)` acknowledged every FAILED
|
|
1907
|
+
// write too, and the "durable" attribute queue silently dropped them.
|
|
1908
|
+
const verdict = classifyWriteResponse(res, {
|
|
1909
|
+
isSdkAuthError: this.network.isSdkAuthError(res.status, res.data),
|
|
1910
|
+
ownerIsCurrentUser: (owner.userId || null) === this.identity.getUserId(),
|
|
1911
|
+
});
|
|
1912
|
+
if (verdict.action === 'done') {
|
|
1913
|
+
this.attributeFailures = 0;
|
|
1914
|
+
this.identity.pending.acknowledge(entries);
|
|
1915
|
+
this.persistAttributeWrites();
|
|
1916
|
+
const permission = ops.attributes?.[PUSH_PERMISSION_ATTRIBUTE];
|
|
1917
|
+
if (typeof permission === 'string') this.storage.set(REPORTED_PUSH_PERMISSION_KEY, permission);
|
|
1918
|
+
if (this.identity.pending.hasUnsent()) this.attributeFlushAgain = true;
|
|
1919
|
+
this.scheduleRetiredTokenPrune();
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
if (verdict.action === 'drop') {
|
|
1923
|
+
// Refused for good (400/403/413/422), or an SDK-auth 401 of a user
|
|
1924
|
+
// who is no longer signed in: retrying would fail forever and hold
|
|
1925
|
+
// every later write to these keys behind it. Only THIS request's
|
|
1926
|
+
// writes are dropped (contract items 28, 39, 40).
|
|
1927
|
+
this.logger.error(`Attributes dropped: ${verdict.reason}`, res.error);
|
|
1928
|
+
this.identity.pending.drop(entries);
|
|
1929
|
+
this.persistAttributeWrites();
|
|
1930
|
+
if (this.identity.pending.hasUnsent()) this.attributeFlushAgain = true;
|
|
1931
|
+
this.scheduleRetiredTokenPrune();
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
// An increment is lost only when its request MAY have been applied
|
|
1935
|
+
// (no answer, a 5xx): a repeated $inc then double-counts. A clear
|
|
1936
|
+
// refusal (401/402/429, success: false) applied nothing.
|
|
1937
|
+
const ambiguous = !res.softFailure && (res.status === 0 || res.status >= 500);
|
|
1938
|
+
const lost = this.identity.pending.release(entries, ambiguous);
|
|
1939
|
+
if (lost > 0) {
|
|
1940
|
+
this.logger.error(
|
|
1941
|
+
`Failed to send attributes (HTTP ${res.status || 'no response'}); ${lost} increment(s) are LOST `
|
|
1942
|
+
+ '(an $inc cannot be safely retried).',
|
|
1943
|
+
);
|
|
1944
|
+
}
|
|
1945
|
+
if (verdict.action === 'hold') {
|
|
1946
|
+
this.persistAttributeWrites();
|
|
1947
|
+
this.logger.error(`Attributes not sent: ${verdict.reason}; they are kept for the next page view.`);
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
// Retry. Offline, 429 and 5xx never count toward the cap; a
|
|
1951
|
+
// refusal that can heal (success: false, a 401 for the current
|
|
1952
|
+
// user) counts, at most WRITE_MAX_ATTEMPTS times (contract item 40).
|
|
1953
|
+
if (verdict.counts) {
|
|
1954
|
+
const exhausted = entries.filter((e) => {
|
|
1955
|
+
e.attempts = (e.attempts || 0) + 1;
|
|
1956
|
+
return e.attempts >= WRITE_MAX_ATTEMPTS;
|
|
1957
|
+
});
|
|
1958
|
+
if (exhausted.length) {
|
|
1959
|
+
this.logger.error(`Attributes failed (HTTP ${res.status}) too many times; dropping them`);
|
|
1960
|
+
this.identity.pending.drop(exhausted);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
this.persistAttributeWrites();
|
|
1964
|
+
this.logger.warn(`Failed to send attributes (HTTP ${res.status || 'no response'}); will retry`);
|
|
1965
|
+
this.attributeFailures++;
|
|
1966
|
+
this.scheduleAttributeFlush(Math.max(this.attributeRetryDelay(), Math.min(verdict.retryAfterMs ?? 0, 5 * 60 * 1000)));
|
|
1967
|
+
}).then(() => {
|
|
1968
|
+
if (generation !== this.attributeGeneration) return;
|
|
1969
|
+
this.attributeFlushInFlight = false;
|
|
1970
|
+
if (this.attributeFlushAgain) {
|
|
1971
|
+
this.attributeFlushAgain = false;
|
|
1972
|
+
this.flushAttributeOps();
|
|
1973
|
+
}
|
|
1974
|
+
// The tail of the chain needs its own handler: anything thrown by the
|
|
1975
|
+
// bookkeeping above - or by the recursive flushAttributeOps - would
|
|
1976
|
+
// otherwise be an unhandled rejection in the host page.
|
|
1977
|
+
}).catch((error) => {
|
|
1978
|
+
if (generation === this.attributeGeneration) this.attributeFlushInFlight = false;
|
|
1979
|
+
this.logger.error('Attribute flush bookkeeping failed:', error);
|
|
1980
|
+
});
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
private attributeRetryDelay(): number {
|
|
1985
|
+
const n = Math.max(1, this.attributeFailures);
|
|
1986
|
+
return Math.min(5000 * Math.pow(2, n - 1), 5 * 60 * 1000);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/**
|
|
1990
|
+
* Report notification-permission state as a user attribute, but ONLY when it
|
|
1991
|
+
* changed since we last reported it.
|
|
1992
|
+
*
|
|
1993
|
+
* Same reserved key and the SAME VALUES as iOS and Android
|
|
1994
|
+
* (`granted | denied | not_determined | unavailable`), so one segment
|
|
1995
|
+
* definition covers every platform. The public API names the states
|
|
1996
|
+
* `notDetermined` / `unavailable` (PushPermissionState); the attribute keeps
|
|
1997
|
+
* the snake_case wire value.
|
|
1998
|
+
*
|
|
1999
|
+
* Change-detection matters: attributes are not deduplicated server-side, so
|
|
2000
|
+
* reporting unconditionally would spend a request on every page load.
|
|
2001
|
+
*/
|
|
2002
|
+
private reportPushPermissionIfChanged(): void {
|
|
2003
|
+
if (!this.isInitialized || this.isOptedOut) return;
|
|
2004
|
+
try {
|
|
2005
|
+
const state = this._push.getPermissionState();
|
|
2006
|
+
const current = state === 'notDetermined' ? 'not_determined' : state;
|
|
2007
|
+
// The marker records what the SERVER acknowledged, and is written when
|
|
2008
|
+
// the attribute write is acknowledged (see flushAttributeOps). It used
|
|
2009
|
+
// to be written here, before anything was sent: a write held by pending
|
|
2010
|
+
// consent, refused, or lost with the page was never reported again.
|
|
2011
|
+
if (this.storage.get<string>(REPORTED_PUSH_PERMISSION_KEY) === current) return;
|
|
2012
|
+
this.logger.debug(`Push permission changed to ${current}; reporting`);
|
|
2013
|
+
this.setAttribute(PUSH_PERMISSION_ATTRIBUTE, current);
|
|
2014
|
+
} catch (error) {
|
|
2015
|
+
this.logger.warn('Could not report push permission:', error);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* Set a single attribute
|
|
2021
|
+
*/
|
|
2022
|
+
setAttribute(key: string, value: any): void {
|
|
2023
|
+
if (typeof key !== 'string' || !key) {
|
|
2024
|
+
this.logger.error('setAttribute(key, value) needs a non-empty key');
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
this.setAttributes({ [key]: value });
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* Increment a numeric attribute
|
|
2032
|
+
*/
|
|
2033
|
+
incrementAttribute(key: string, value: number = 1): void {
|
|
2034
|
+
if (!this.isInitialized) {
|
|
2035
|
+
this.logger.error('SDK not initialized');
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
if (this.isDroppingDataCalls('incrementAttribute')) return;
|
|
2039
|
+
if (typeof key !== 'string' || !key || typeof value !== 'number' || !Number.isFinite(value)) {
|
|
2040
|
+
this.logger.error('incrementAttribute(key, value) needs a key and a finite number');
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// In-memory optimistic echo only (for in-session getAttribute read-back).
|
|
2045
|
+
this.identity.incrementAttribute(key, value);
|
|
2046
|
+
|
|
2047
|
+
// Send the DELTA as an atomic `$inc` op — never a locally-computed
|
|
2048
|
+
// absolute — so concurrent tabs/devices don't clobber the counter.
|
|
2049
|
+
if (!this.isOptedOut) {
|
|
2050
|
+
this.sendAttributesAfterPendingIdentify({ increment: { [key]: value } });
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Unset an attribute
|
|
2056
|
+
*/
|
|
2057
|
+
unsetAttribute(key: string): void {
|
|
2058
|
+
if (!this.isInitialized) {
|
|
2059
|
+
this.logger.error('SDK not initialized');
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (this.isDroppingDataCalls('unsetAttribute')) return;
|
|
2063
|
+
if (typeof key !== 'string' || !key) {
|
|
2064
|
+
this.logger.error('unsetAttribute() needs a non-empty attribute key');
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
this.identity.unsetAttribute(key);
|
|
2069
|
+
|
|
2070
|
+
// Send an atomic `$unset` op (waits for any pending identify first).
|
|
2071
|
+
if (!this.isOptedOut) {
|
|
2072
|
+
this.sendAttributesAfterPendingIdentify({ unset: [key] });
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/**
|
|
2077
|
+
* Add a value to an array attribute
|
|
2078
|
+
*/
|
|
2079
|
+
addToArray(key: string, value: any): void {
|
|
2080
|
+
if (!this.isInitialized) {
|
|
2081
|
+
this.logger.error('SDK not initialized');
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
if (this.isDroppingDataCalls('addToArray')) return;
|
|
2085
|
+
if (typeof key !== 'string' || !key) {
|
|
2086
|
+
this.logger.error('addToArray() needs a non-empty attribute key');
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
const problem = valueProblem(value);
|
|
2091
|
+
if (problem) {
|
|
2092
|
+
this.logger.warn(`addToArray("${key}") ignored: the value is ${problem}`);
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// In-memory optimistic echo only (for in-session getAttribute read-back).
|
|
2097
|
+
this.identity.addToArray(key, value);
|
|
2098
|
+
|
|
2099
|
+
// Send an atomic `$addToSet` op with just the added value — not the whole
|
|
2100
|
+
// recomputed array — so concurrent writers don't clobber the set.
|
|
2101
|
+
if (!this.isOptedOut) {
|
|
2102
|
+
this.sendAttributesAfterPendingIdentify({ append: { [key]: value } });
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* Remove a value from an array attribute
|
|
2108
|
+
*/
|
|
2109
|
+
removeFromArray(key: string, value: any): void {
|
|
2110
|
+
if (!this.isInitialized) {
|
|
2111
|
+
this.logger.error('SDK not initialized');
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
if (this.isDroppingDataCalls('removeFromArray')) return;
|
|
2115
|
+
if (typeof key !== 'string' || !key) {
|
|
2116
|
+
this.logger.error('removeFromArray() needs a non-empty attribute key');
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
const problem = valueProblem(value);
|
|
2121
|
+
if (problem) {
|
|
2122
|
+
this.logger.warn(`removeFromArray("${key}") ignored: the value is ${problem}`);
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// In-memory optimistic echo only (for in-session getAttribute read-back).
|
|
2127
|
+
this.identity.removeFromArray(key, value);
|
|
2128
|
+
|
|
2129
|
+
// Send an atomic `$pull` op with just the removed value — not the whole
|
|
2130
|
+
// recomputed array.
|
|
2131
|
+
if (!this.isOptedOut) {
|
|
2132
|
+
this.sendAttributesAfterPendingIdentify({ remove: { [key]: value } });
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/**
|
|
2137
|
+
* Log the current user out: a NEW anonymous id, no user id, nothing of the
|
|
2138
|
+
* previous user's left behind.
|
|
2139
|
+
*
|
|
2140
|
+
* The identity changes SYNCHRONOUSLY, before anything is awaited (contract
|
|
2141
|
+
* addendum 16). It used to await a flush first, so the common
|
|
2142
|
+
* `reset(); identify('B')` ran identify while the old ids were still in
|
|
2143
|
+
* place: identify('B') was queued with user A's anonymousId - which asks the
|
|
2144
|
+
* server to merge A's profile into B's, two real customers - and the reset
|
|
2145
|
+
* then landed afterwards and set the user id back to null. Nothing queued
|
|
2146
|
+
* needs the old ids to still be current: every queued event, and a pending
|
|
2147
|
+
* identify, carries its own.
|
|
2148
|
+
*
|
|
2149
|
+
* It also used to leave the attribute debounce timer and the pending
|
|
2150
|
+
* attribute store running (so user A's last writes went out under user B's
|
|
2151
|
+
* ids), the in-app retry/delay timers alive, and the browser's push
|
|
2152
|
+
* subscription bound to user A on the server - so A's pushes kept arriving
|
|
2153
|
+
* on a shared device.
|
|
2154
|
+
*/
|
|
2155
|
+
async reset(): Promise<void> {
|
|
2156
|
+
if (!this.isInitialized) {
|
|
2157
|
+
this.logger.error('SDK not initialized');
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
this.logger.info('Resetting user identity');
|
|
2162
|
+
|
|
2163
|
+
// --- Synchronous: everything that decides WHO the next call is about ---
|
|
2164
|
+
|
|
2165
|
+
// The outgoing user's attribute and profile writes not acknowledged yet
|
|
2166
|
+
// are NOT dropped: each carries its own ids and goes out in order after
|
|
2167
|
+
// this (contract items 28 and 37). They used to get one best-effort send
|
|
2168
|
+
// here - which skipped any key that already had a request on the wire,
|
|
2169
|
+
// and ignored a pending identify - and were then forgotten.
|
|
2170
|
+
|
|
2171
|
+
// In-app FIRST: dismiss (tracked as the outgoing user, with that user's
|
|
2172
|
+
// token still the current one), cancel every timer, forget campaigns.
|
|
2173
|
+
// After the token was retired the dismissal went without a token, was
|
|
2174
|
+
// refused, and fired the host's auth handler for a logged-out user.
|
|
2175
|
+
if (this.config.inApp.enabled) {
|
|
2176
|
+
this.inapp.clearAll();
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// The previous user's SDK-authentication token is theirs, not the next
|
|
2180
|
+
// visitor's (contract item 39): the session forgets it, but it stays bound
|
|
2181
|
+
// to the outgoing user so their own queued events and writes still go out
|
|
2182
|
+
// authenticated - persisted until the last of them settles (D-a).
|
|
2183
|
+
// Leftovers refused with a 401 anyway are dropped, never reported to the
|
|
2184
|
+
// host's handler.
|
|
2185
|
+
this.network.retireSdkAuthToken();
|
|
2186
|
+
// A user kept only for withdrawals (D-b) is logged out too.
|
|
2187
|
+
this.forgetDeferredIdentify();
|
|
2188
|
+
|
|
2189
|
+
// New anonymous id, no user id.
|
|
2190
|
+
this.pinAnonymousWrites();
|
|
2191
|
+
this.identity.reset();
|
|
2192
|
+
this.network.setCurrentUserId(this.identity.getUserId());
|
|
2193
|
+
|
|
2194
|
+
// UTM before the session: the new session's Session Start (tracked by the
|
|
2195
|
+
// session's onNewSession hook) must not carry the outgoing user's campaign.
|
|
2196
|
+
this.utm.reset();
|
|
2197
|
+
this.session.resetSession();
|
|
2198
|
+
|
|
2199
|
+
// --- Asynchronous: delivery. Each queued event carries its own ids. ---
|
|
2200
|
+
await this.flush().catch(() => undefined);
|
|
2201
|
+
|
|
2202
|
+
if (this.canSend()) {
|
|
2203
|
+
// The new anonymous visitor's campaigns, and the push subscription moved
|
|
2204
|
+
// to them (force: the identity changed even if the endpoint did not).
|
|
2205
|
+
this.resyncInApp(true);
|
|
2206
|
+
this._push.syncExistingSubscription(true).catch(() => undefined);
|
|
2207
|
+
}
|
|
2208
|
+
this.outbox.kick();
|
|
2209
|
+
|
|
2210
|
+
this.logger.info('User identity reset');
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* Erase everything this SDK keeps about the visitor on this browser - for
|
|
2215
|
+
* an erasure request (contract addendum 21): the identity (a new anonymous
|
|
2216
|
+
* id, no user id), queued events and a pending identify, pending attribute
|
|
2217
|
+
* and profile writes, in-app state and impression history, the session,
|
|
2218
|
+
* UTM data, the device id, and the push subscription (unsubscribed in the
|
|
2219
|
+
* browser and unregistered on the server; it stays unsubscribed until
|
|
2220
|
+
* `push.subscribe()` is called again).
|
|
2221
|
+
*
|
|
2222
|
+
* KEEPS the opt-out: "delete what you have" is not "start collecting
|
|
2223
|
+
* again", and a wipe that silently re-enabled tracking would undo the more
|
|
2224
|
+
* important of the two requests. Does not opt anyone out either - that is
|
|
2225
|
+
* optOut(). The same split Android and iOS make.
|
|
2226
|
+
*/
|
|
2227
|
+
async wipeData(): Promise<void> {
|
|
2228
|
+
if (!this.isInitialized) {
|
|
2229
|
+
this.logger.error('SDK not initialized');
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
this.logger.info('Wiping local SDK data');
|
|
2233
|
+
|
|
2234
|
+
// Push first, while the outgoing ids are still current (it captures them).
|
|
2235
|
+
const pushWiped = this._push.forget().catch(() => undefined);
|
|
2236
|
+
|
|
2237
|
+
this.wipeLocalState();
|
|
2238
|
+
|
|
2239
|
+
// The fresh visitor starts a fresh session - with its one Session Start
|
|
2240
|
+
// when tracking is on (contract item 32).
|
|
2241
|
+
this.session.ensureSession();
|
|
2242
|
+
this.trackSessionStartIfMissing();
|
|
2243
|
+
|
|
2244
|
+
await pushWiped;
|
|
2245
|
+
if (this.canSend()) this.resyncInApp(true);
|
|
2246
|
+
this.logger.info('Local SDK data wiped');
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/**
|
|
2250
|
+
* The synchronous part of wipeData(), shared with a wipeData() queued
|
|
2251
|
+
* before init. Keeps the opt-out and a denied consent: both are decisions,
|
|
2252
|
+
* not data.
|
|
2253
|
+
*/
|
|
2254
|
+
private wipeLocalState(): void {
|
|
2255
|
+
this.clearPendingAttributeWrites();
|
|
2256
|
+
// Withdrawals (unsubscribes, the opt-out marker) stay and are delivered
|
|
2257
|
+
// under the ids they were made with (contract item 36).
|
|
2258
|
+
this.outbox.discardNonWithdrawals();
|
|
2259
|
+
this.storage.remove(REPORTED_PUSH_PERMISSION_KEY);
|
|
2260
|
+
// "No Session Start queued for any session yet" - NOT the absence of the
|
|
2261
|
+
// marker, which reads as "sent" (rule 75): a wipe while tracking was off,
|
|
2262
|
+
// then a reload and a grant, resumed the post-wipe session with no
|
|
2263
|
+
// Session Start at all.
|
|
2264
|
+
this.storage.set(SESSION_START_SENT_KEY, '');
|
|
2265
|
+
this.queue.clear();
|
|
2266
|
+
if (this.config.inApp.enabled) {
|
|
2267
|
+
this.inapp.clearAll();
|
|
2268
|
+
} else {
|
|
2269
|
+
this.inapp.invalidate();
|
|
2270
|
+
}
|
|
2271
|
+
this.inapp.forgetImpressions();
|
|
2272
|
+
|
|
2273
|
+
// The erased user's token, like at reset(): no longer the session's, still
|
|
2274
|
+
// bound to their withdrawals waiting in the outbox (the same on every SDK).
|
|
2275
|
+
// A token set for a NEXT user (standby) belongs to no one being kept:
|
|
2276
|
+
// deleted, not left in localStorage for 30 days (rule 82). Never the
|
|
2277
|
+
// erased (or held) user's own token, nor that of anyone with a withdrawal
|
|
2278
|
+
// still queued, here or in another tab's stored outbox (rule 89).
|
|
2279
|
+
const keepTokens = new Set<string>();
|
|
2280
|
+
for (const u of [this.effectiveUserId(), this.identity.getUserId(), this.network.getCurrentUserId()]) {
|
|
2281
|
+
if (u) keepTokens.add(u);
|
|
2282
|
+
}
|
|
2283
|
+
for (const r of this.outbox.records()) {
|
|
2284
|
+
const userId = (r.payload as any).userId;
|
|
2285
|
+
if (typeof userId === 'string' && userId) keepTokens.add(userId);
|
|
2286
|
+
}
|
|
2287
|
+
for (const u of this._push.pendingUnregisterUsers()) keepTokens.add(u);
|
|
2288
|
+
for (const key of [OUTBOX_KEY, PENDING_UNREGISTER_KEY]) collectUserIds(this.storage.get<unknown>(key), keepTokens);
|
|
2289
|
+
this.network.retireSdkAuthToken();
|
|
2290
|
+
this.network.dropStandbySdkAuthTokens(keepTokens);
|
|
2291
|
+
this.forgetDeferredIdentify();
|
|
2292
|
+
this.pinAnonymousWrites();
|
|
2293
|
+
this.identity.reset();
|
|
2294
|
+
this.network.setCurrentUserId(null);
|
|
2295
|
+
this.utm.reset();
|
|
2296
|
+
this.session.clear();
|
|
2297
|
+
this.lastTrackedUrl = null;
|
|
2298
|
+
this.storage.remove('device_id');
|
|
2299
|
+
this.deviceId = this.getOrCreateDeviceId();
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/** Ask the server for this visitor's campaigns again (identify, reset, new session). */
|
|
2303
|
+
private resyncInApp(fullInit: boolean = false): void {
|
|
2304
|
+
if (!this.config.inApp.enabled || !this.canSend()) return;
|
|
2305
|
+
const run = fullInit ? this.inapp.initialize() : this.inapp.sync().then(() => undefined);
|
|
2306
|
+
run.catch((error) => this.logger.debug('In-app re-sync failed:', error));
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
/**
|
|
2310
|
+
* Send what is queued now. Skips the SDK's own retry backoff, but never a
|
|
2311
|
+
* server's Retry-After (contract item 35).
|
|
2312
|
+
*/
|
|
2313
|
+
async flush(): Promise<void> {
|
|
2314
|
+
if (!this.isInitialized) {
|
|
2315
|
+
this.logger.error('SDK not initialized');
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
await this.queue.flush(false, true);
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/**
|
|
2323
|
+
* Classify one batch response for the queue (see FlushOutcome).
|
|
2324
|
+
*/
|
|
2325
|
+
private async flushEvents(events: QueuedEvent[], identify?: PendingIdentify): Promise<FlushOutcome> {
|
|
2326
|
+
if (!this.canSend()) {
|
|
2327
|
+
return { status: 'retry' };
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
this.logger.debug(`Sending ${events.length} events${identify ? ' + identify' : ''} to backend`);
|
|
2331
|
+
|
|
2332
|
+
const response = await this.network.sendEvents(this.config.apiEndpoint, events, identify);
|
|
2333
|
+
|
|
2334
|
+
// 2xx: `ok`, or `softFailure` when the body says `success: false` - some
|
|
2335
|
+
// (or all) events were not stored; `failedIndices` names them, and
|
|
2336
|
+
// without it all of them are kept. `rejectedIndices` (either way) names
|
|
2337
|
+
// events refused for GOOD: those are dropped, never retried.
|
|
2338
|
+
if (response.ok || response.softFailure) {
|
|
2339
|
+
const rejectedIndices = Array.isArray(response.data?.rejectedIndices) ? response.data.rejectedIndices : undefined;
|
|
2340
|
+
let identifyFailed = false;
|
|
2341
|
+
let identifyDropped = false;
|
|
2342
|
+
// Offline, 429 and 5xx never count toward the identify's cap (contract item 40).
|
|
2343
|
+
let identifyAttemptCounts = true;
|
|
2344
|
+
// If identify was included but the backend did not confirm it, send it
|
|
2345
|
+
// on its own. Failing that, the queue keeps it: it used to be dropped
|
|
2346
|
+
// with a log line, leaving the visitor unmerged for good.
|
|
2347
|
+
if (identify && !response.data?.identified && response.softFailure) {
|
|
2348
|
+
// The batch itself failed (in part): keep the identify for the retry
|
|
2349
|
+
// so it still travels with - and after - the events it follows.
|
|
2350
|
+
identifyFailed = true;
|
|
2351
|
+
} else if (identify && !response.data?.identified) {
|
|
2352
|
+
this.logger.debug('Backend did not confirm atomic identify, falling back to standalone');
|
|
2353
|
+
const res = await this.network.sendIdentify(
|
|
2354
|
+
this.config.apiEndpoint,
|
|
2355
|
+
identify.userId,
|
|
2356
|
+
identify.attributes || {},
|
|
2357
|
+
identify.anonymousId,
|
|
2358
|
+
);
|
|
2359
|
+
if (!res.ok) {
|
|
2360
|
+
// Retried only when the failure can heal (no answer, 408, 429, 5xx,
|
|
2361
|
+
// success: false). A refusal (400, 403, 404, ...) is final - it was
|
|
2362
|
+
// retried forever (scan 3, web #8) - and so is an SDK-auth refusal
|
|
2363
|
+
// for a user who is no longer the current one.
|
|
2364
|
+
const outage = !res.softFailure && (res.status === 0 || res.status === 408 || res.status === 429 || res.status >= 500);
|
|
2365
|
+
const retryable =
|
|
2366
|
+
res.softFailure === true ||
|
|
2367
|
+
outage ||
|
|
2368
|
+
(res.status === 401 && this.network.isSdkAuthError(res.status, res.data) && identify.userId === this.identity.getUserId()) ||
|
|
2369
|
+
res.status === 402;
|
|
2370
|
+
if (retryable) {
|
|
2371
|
+
this.logger.error('Standalone identify fallback failed; it is kept and retried:', res.error);
|
|
2372
|
+
identifyFailed = true;
|
|
2373
|
+
identifyAttemptCounts = !outage && res.status !== 402;
|
|
2374
|
+
} else {
|
|
2375
|
+
this.logger.error(`Standalone identify fallback refused (HTTP ${res.status}); dropping it:`, res.error);
|
|
2376
|
+
identifyDropped = true;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
if (response.softFailure) {
|
|
2381
|
+
const failedIndices = Array.isArray(response.data?.failedIndices)
|
|
2382
|
+
? response.data.failedIndices
|
|
2383
|
+
: events.map((_e, i) => i);
|
|
2384
|
+
return { status: 'ok', failedIndices, rejectedIndices, identifyFailed, identifyAttemptCounts, identifyDropped };
|
|
2385
|
+
}
|
|
2386
|
+
this.logger.debug('Events sent successfully');
|
|
2387
|
+
return { status: 'ok', rejectedIndices, identifyFailed, identifyAttemptCounts, identifyDropped };
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
return this.classifyFailure(response, events, identify);
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
private classifyFailure(response: NetworkResponse, events: QueuedEvent[] = [], identify?: PendingIdentify): FlushOutcome {
|
|
2394
|
+
const status = response.status;
|
|
2395
|
+
if (status === 400 || status === 413 || status === 422) {
|
|
2396
|
+
return { status: 'rejected', httpStatus: status };
|
|
2397
|
+
}
|
|
2398
|
+
if (status === 401) {
|
|
2399
|
+
if (this.network.isSdkAuthError(status, response.data)) {
|
|
2400
|
+
// A batch belongs to one user (the queue never mixes them). If that
|
|
2401
|
+
// user is no longer the current one, no token for them will ever be
|
|
2402
|
+
// set again: a `sub_mismatch` / `missing` refusal is final, and
|
|
2403
|
+
// retrying it at the head of the queue blocked every later event of
|
|
2404
|
+
// the NEW user (contract item 30).
|
|
2405
|
+
// ANY reason (missing, sub_mismatch, expired, invalid): reset()
|
|
2406
|
+
// forgot that user's token and no new one will be minted for them
|
|
2407
|
+
// (contract item 39).
|
|
2408
|
+
const reason = response.data?.reason;
|
|
2409
|
+
const batchUser = identify?.userId || events.find((e) => e.userId)?.userId || null;
|
|
2410
|
+
if (batchUser && batchUser !== this.identity.getUserId()) {
|
|
2411
|
+
return { status: 'dropped', reason: `401 ${reason || 'sdk_authentication_error'}` };
|
|
2412
|
+
}
|
|
2413
|
+
// The current user's token: the refresh already ran in request();
|
|
2414
|
+
// keep the events and try again after a backoff. A refusal, so it
|
|
2415
|
+
// counts against an identify riding along (contract item 40).
|
|
2416
|
+
return { status: 'retry', counts: true };
|
|
2417
|
+
}
|
|
2418
|
+
return { status: 'suspended', reason: 'the SDK key was refused (401)' };
|
|
2419
|
+
}
|
|
2420
|
+
if (status === 402) {
|
|
2421
|
+
return { status: 'suspended', reason: 'event ingestion is paused for this workspace (402)' };
|
|
2422
|
+
}
|
|
2423
|
+
if (status === 403) {
|
|
2424
|
+
return { status: 'suspended', reason: 'the request was forbidden (403)' };
|
|
2425
|
+
}
|
|
2426
|
+
if (status === 0 || status === 408 || status === 429 || status >= 500) {
|
|
2427
|
+
return { status: 'retry', retryAfterMs: response.retryAfterMs };
|
|
2428
|
+
}
|
|
2429
|
+
// Any other 4xx (404, 405, ...): a configuration problem no retry fixes,
|
|
2430
|
+
// and dropping the events for it would lose data. Stop and keep them.
|
|
2431
|
+
return { status: 'suspended', reason: `the API answered HTTP ${status}` };
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
/**
|
|
2435
|
+
* Page is going away: hand what is queued to the browser with fetch
|
|
2436
|
+
* keepalive, and stop holding ONLY what was actually handed off.
|
|
2437
|
+
*
|
|
2438
|
+
* This used navigator.sendBeacon with a JSON blob: a credentialed CORS
|
|
2439
|
+
* request the API's wildcard CORS must refuse - and since sendBeacon returns
|
|
2440
|
+
* true for "queued", the queue, the in-flight batch and the pending identify
|
|
2441
|
+
* were all cleared and lost on every navigation.
|
|
2442
|
+
*/
|
|
2443
|
+
private handlePageUnload(): void {
|
|
2444
|
+
if (!this.isInitialized) return;
|
|
2445
|
+
// Offline: nothing can leave. Everything stays stored for the next page
|
|
2446
|
+
// view - a hand-off would only take events out of storage for a request
|
|
2447
|
+
// that cannot arrive.
|
|
2448
|
+
if (isOffline()) {
|
|
2449
|
+
this.queue.persistNow();
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
// Profile writes first - withdrawals even while opted out. They stay
|
|
2453
|
+
// stored until acknowledged, so a page that survives sends them again.
|
|
2454
|
+
try {
|
|
2455
|
+
this.outbox.handOffAtUnload(UNLOAD_WRITES_BUDGET_BYTES);
|
|
2456
|
+
} catch (error) {
|
|
2457
|
+
this.logger.error('Page-unload write hand-off failed:', error);
|
|
2458
|
+
}
|
|
2459
|
+
if (!this.canSend() || this.queue.isSuspended() || this.queue.isBackingOff()) {
|
|
2460
|
+
// Opted out: nothing to keep. Pending / suspended: keep it all for
|
|
2461
|
+
// later. Backing off (the API failed, or asked to wait): a keepalive
|
|
2462
|
+
// would most likely fail too, and the events it took would be lost -
|
|
2463
|
+
// they stay queued for the next page view (contract item 3).
|
|
2464
|
+
this.queue.persistNow();
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
try {
|
|
2469
|
+
this.sendAttributesAtUnload();
|
|
2470
|
+
// What is left of the browser's 64 KiB keepalive budget - shared by
|
|
2471
|
+
// every hand-off of this page, so the second of `beforeunload` and
|
|
2472
|
+
// `pagehide` cannot hand off a batch the browser would refuse.
|
|
2473
|
+
// (Less the request overhead postKeepalive counts.)
|
|
2474
|
+
const budget = Math.max(0, this.network.keepaliveBudget() - 256);
|
|
2475
|
+
|
|
2476
|
+
const events = this.queue.getQueuedEvents();
|
|
2477
|
+
const head = this.queue.getPendingIdentify();
|
|
2478
|
+
if (events.length === 0 && !head) return;
|
|
2479
|
+
|
|
2480
|
+
if (!this.network.supportsKeepalive()) {
|
|
2481
|
+
// The request would die with the page; keep everything for the next one.
|
|
2482
|
+
this.queue.persistNow();
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// One request, within the browser's 64 KiB keepalive budget, and of ONE
|
|
2487
|
+
// identity (contract item 30): the first queued event's. The identify
|
|
2488
|
+
// rides along only when it belongs with those events. What does not fit
|
|
2489
|
+
// stays queued (and persisted) for the next page view.
|
|
2490
|
+
const first = events[0];
|
|
2491
|
+
const identify =
|
|
2492
|
+
head && (!first || (first.anonymousId === head.anonymousId && (!first.userId || first.userId === head.userId)))
|
|
2493
|
+
? head
|
|
2494
|
+
: null;
|
|
2495
|
+
let bytes = 64 + (identify ? utf8Length(JSON.stringify(identify)) : 0);
|
|
2496
|
+
const handed: QueuedEvent[] = [];
|
|
2497
|
+
let batchUser: string | null = null;
|
|
2498
|
+
for (const event of events) {
|
|
2499
|
+
if (first && event.anonymousId !== first.anonymousId) break;
|
|
2500
|
+
if (event.userId && batchUser && event.userId !== batchUser) break;
|
|
2501
|
+
if (identify && (event.anonymousId !== identify.anonymousId || (event.userId && event.userId !== identify.userId))) break;
|
|
2502
|
+
const size = utf8Length(JSON.stringify(event)) + 1;
|
|
2503
|
+
if (bytes + size > budget) break;
|
|
2504
|
+
handed.push(event);
|
|
2505
|
+
if (event.userId) batchUser = event.userId;
|
|
2506
|
+
bytes += size;
|
|
2507
|
+
}
|
|
2508
|
+
if (handed.length === 0 && !identify) {
|
|
2509
|
+
this.queue.persistNow();
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
this.logger.debug(`Page unload: handing ${handed.length} of ${events.length} events${identify ? ' + identify' : ''} to keepalive`);
|
|
2514
|
+
const pending = this.network.sendEventsKeepalive(this.config.apiEndpoint, handed, identify || undefined);
|
|
2515
|
+
if (!pending) {
|
|
2516
|
+
this.queue.persistNow();
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
this.queue.markHandedOff(handed, identify);
|
|
2520
|
+
// Only matters if the page survives (bfcache, a cancelled unload): a
|
|
2521
|
+
// failed hand-off comes back into the queue (event ids make a resend
|
|
2522
|
+
// harmless). A refusal (4xx) is left to the normal path's rules.
|
|
2523
|
+
pending.then((res) => {
|
|
2524
|
+
if (!res.ok && (res.softFailure || res.status === 0 || res.status === 429 || res.status >= 500)) {
|
|
2525
|
+
this.queue.takeBack(handed, identify);
|
|
2526
|
+
}
|
|
2527
|
+
}).catch(() => undefined);
|
|
2528
|
+
} catch (error) {
|
|
2529
|
+
this.logger.error('Page-unload send failed:', error);
|
|
2530
|
+
this.queue.persistNow();
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
/**
|
|
2535
|
+
* Hand the next attribute request to the browser at unload. The writes stay
|
|
2536
|
+
* stored (and are acknowledged on a later page view); only increments are
|
|
2537
|
+
* forgotten, since a second $inc would count twice. Returns the bytes used.
|
|
2538
|
+
*/
|
|
2539
|
+
private sendAttributesAtUnload(): number {
|
|
2540
|
+
if (this.queue.getPendingIdentify()) return 0; // would land on the anonymous profile
|
|
2541
|
+
const request = this.identity.pending.peekRequest((o) => this.resolveOwner(o ?? this.currentOwner()));
|
|
2542
|
+
if (!request || !this.network.supportsKeepalive()) return 0;
|
|
2543
|
+
try {
|
|
2544
|
+
const owner = this.resolveOwner(request.owner ?? this.currentOwner());
|
|
2545
|
+
const body = {
|
|
2546
|
+
userId: owner.userId || undefined,
|
|
2547
|
+
anonymousId: owner.userId ? undefined : owner.anonymousId,
|
|
2548
|
+
...request.ops,
|
|
2549
|
+
};
|
|
2550
|
+
const pending = this.network.postKeepalive(`${this.config.apiEndpoint}/v1/attributes`, body);
|
|
2551
|
+
if (!pending) return 0;
|
|
2552
|
+
pending.catch(() => undefined);
|
|
2553
|
+
// Handed off: an increment must not go out a second time if the page
|
|
2554
|
+
// survives (the idempotent writes stay, harmlessly, until acknowledged).
|
|
2555
|
+
this.identity.pending.dropIncrements(request.entries);
|
|
2556
|
+
this.persistAttributeWrites();
|
|
2557
|
+
return utf8Length(JSON.stringify(body)) + 256;
|
|
2558
|
+
} catch {
|
|
2559
|
+
return 0; // best-effort
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
/**
|
|
2564
|
+
* Opt out of tracking - remembered across page loads until optIn().
|
|
2565
|
+
*
|
|
2566
|
+
* TRACKING consent, not marketing subscription - this does not unsubscribe
|
|
2567
|
+
* anyone from email or SMS. Independent of updateConsent(): it never
|
|
2568
|
+
* changes the stored consent, and a later `updateConsent('granted')` does
|
|
2569
|
+
* not lift it - only optIn() does.
|
|
2570
|
+
*/
|
|
2571
|
+
optOut(): void {
|
|
2572
|
+
if (!this.isInitialized) {
|
|
2573
|
+
this.logger.error('SDK not initialized');
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
this.logger.info('User opted out of tracking');
|
|
2577
|
+
this.userOptedOut = true;
|
|
2578
|
+
this.writeOptOut();
|
|
2579
|
+
/*
|
|
2580
|
+
* The marker is queued FIRST, under the current ids: it is a consent
|
|
2581
|
+
* RECORD, not tracking, and the only way the server learns this device
|
|
2582
|
+
* opted out (a segment excluding opted-out people must catch web users as
|
|
2583
|
+
* well as mobile ones). A withdrawal: persisted and delivered whatever
|
|
2584
|
+
* happens next (contract item 36).
|
|
2585
|
+
*/
|
|
2586
|
+
this.storage.remove(OPT_OUT_MARKER_GIVEN_UP_KEY);
|
|
2587
|
+
this.syncOptOutMarker();
|
|
2588
|
+
this.applyPrivacyState();
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* Opt in to tracking: lifts the visitor's own opt-out. Does not grant
|
|
2593
|
+
* consent - a denied consent stays denied until updateConsent('granted').
|
|
2594
|
+
*/
|
|
2595
|
+
optIn(): void {
|
|
2596
|
+
if (!this.isInitialized) {
|
|
2597
|
+
this.logger.error('SDK not initialized');
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
this.logger.info('User opted in to tracking');
|
|
2601
|
+
this.userOptedOut = false;
|
|
2602
|
+
// A decision made inside a data-gated ready() callback is the visitor's
|
|
2603
|
+
// newer choice: the callback's later calls run under it (rule 83) - an
|
|
2604
|
+
// opt-out gate only; a denial or the purge stays (rule 92).
|
|
2605
|
+
this.liftDataGate('optedOut');
|
|
2606
|
+
this.writeOptIn();
|
|
2607
|
+
this.applyPrivacyState();
|
|
2608
|
+
// And clear it on the way back in, or a person who declined and later
|
|
2609
|
+
// accepted would stay excluded by `tracking_opted_out == true` forever.
|
|
2610
|
+
this.storage.remove(OPT_OUT_MARKER_GIVEN_UP_KEY);
|
|
2611
|
+
this.syncOptOutMarker();
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
/**
|
|
2615
|
+
* Update tracking consent at runtime (e.g., after user accepts cookie banner).
|
|
2616
|
+
*
|
|
2617
|
+
* - `granted`: release everything collected while consent was pending
|
|
2618
|
+
* (Session Start, events, attributes, identify, subscriptions, push
|
|
2619
|
+
* registration) and run normally. Lifts a stored denial.
|
|
2620
|
+
* - `denied`: collect nothing, and discard what was waiting. Remembered on
|
|
2621
|
+
* later page views until `granted`.
|
|
2622
|
+
* - `pending`: collect locally, send nothing - for this page view only, and
|
|
2623
|
+
* never over a stored denial.
|
|
2624
|
+
*
|
|
2625
|
+
* Independent of optOut(): a visitor who opted out stays opted out whatever
|
|
2626
|
+
* the consent banner says.
|
|
2627
|
+
*/
|
|
2628
|
+
updateConsent(consent: TrackingConsent): void {
|
|
2629
|
+
if (!this.isInitialized) {
|
|
2630
|
+
this.logger.error('SDK not initialized');
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
if (consent !== 'granted' && consent !== 'denied' && consent !== 'pending') {
|
|
2634
|
+
this.logger.error(`updateConsent expects 'granted', 'denied' or 'pending', got ${String(consent)}`);
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
this.trackingConsent = this.storeConsent(consent);
|
|
2638
|
+
this.config.trackingConsent = this.trackingConsent;
|
|
2639
|
+
if (consent === 'granted') this.liftDataGate('denied'); // see optIn()
|
|
2640
|
+
this.applyPrivacyState();
|
|
2641
|
+
// A marker change that waited for consent goes out once it is granted.
|
|
2642
|
+
this.syncOptOutMarker();
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
/**
|
|
2646
|
+
* Check if Do Not Track is enabled
|
|
2647
|
+
*/
|
|
2648
|
+
private isDoNotTrackEnabled(): boolean {
|
|
2649
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
2650
|
+
return false;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// Check various DNT implementations
|
|
2654
|
+
const dnt = (navigator as any).doNotTrack || (window as any).doNotTrack || (navigator as any).msDoNotTrack;
|
|
2655
|
+
|
|
2656
|
+
return dnt === '1' || dnt === 'yes';
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
/**
|
|
2660
|
+
* Load plugins
|
|
2661
|
+
*/
|
|
2662
|
+
private async loadPlugins(): Promise<void> {
|
|
2663
|
+
for (const plugin of this.config.plugins) {
|
|
2664
|
+
try {
|
|
2665
|
+
this.logger.debug(`Loading plugin: ${plugin.name} v${plugin.version}`);
|
|
2666
|
+
await plugin.load(this);
|
|
2667
|
+
this.logger.info(`Plugin loaded: ${plugin.name}`);
|
|
2668
|
+
} catch (error) {
|
|
2669
|
+
this.logger.error(`Failed to load plugin ${plugin.name}:`, error);
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
/**
|
|
2675
|
+
* Unload plugins
|
|
2676
|
+
*/
|
|
2677
|
+
private async unloadPlugins(): Promise<void> {
|
|
2678
|
+
for (const plugin of this.config.plugins) {
|
|
2679
|
+
try {
|
|
2680
|
+
this.logger.debug(`Unloading plugin: ${plugin.name}`);
|
|
2681
|
+
await plugin.unload();
|
|
2682
|
+
} catch (error) {
|
|
2683
|
+
this.logger.error(`Failed to unload plugin ${plugin.name}:`, error);
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
/**
|
|
2689
|
+
* User object for subscription management
|
|
2690
|
+
* Use this to update subscription preferences (write-only, no getters for security)
|
|
2691
|
+
*
|
|
2692
|
+
* @example
|
|
2693
|
+
* sdk.user.setSubscription('email', SubscriptionStatus.OPTED_IN);
|
|
2694
|
+
* sdk.user.setSubscription('sms', SubscriptionStatus.UNSUBSCRIBED);
|
|
2695
|
+
* sdk.user.addToSubscriptionGroup('list_abc123', 'email');
|
|
2696
|
+
*/
|
|
2697
|
+
get user(): User {
|
|
2698
|
+
if (!this.isInitialized) {
|
|
2699
|
+
// Logged, not thrown: an SDK must never throw into the host page. The
|
|
2700
|
+
// object's writes are refused while the SDK is not initialized.
|
|
2701
|
+
this.logger?.error('SDK not initialized');
|
|
2702
|
+
}
|
|
2703
|
+
return this._user;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* Web push subscription manager
|
|
2708
|
+
*
|
|
2709
|
+
* @example
|
|
2710
|
+
* if (sdk.push.isSupported() && sdk.push.getPermissionState() === 'notDetermined') {
|
|
2711
|
+
* button.onclick = () => sdk.push.subscribe();
|
|
2712
|
+
* }
|
|
2713
|
+
*/
|
|
2714
|
+
get push(): PushManager {
|
|
2715
|
+
if (!this.isInitialized) {
|
|
2716
|
+
this.logger?.error('SDK not initialized');
|
|
2717
|
+
}
|
|
2718
|
+
return this._push;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
/**
|
|
2722
|
+
* E-commerce tracking helper
|
|
2723
|
+
* Provides standardized e-commerce event tracking
|
|
2724
|
+
*
|
|
2725
|
+
* @example
|
|
2726
|
+
* // Initialize e-commerce tracking
|
|
2727
|
+
* const ecommerce = sdk.ecommerce({ currency: 'USD' });
|
|
2728
|
+
*
|
|
2729
|
+
* // Track product view
|
|
2730
|
+
* ecommerce.viewProduct({ productId: 'SKU123', name: 'Blue Shirt', price: 29.99 });
|
|
2731
|
+
*
|
|
2732
|
+
* // Track add to cart
|
|
2733
|
+
* ecommerce.addToCart({ productId: 'SKU123', name: 'Blue Shirt', price: 29.99, quantity: 1 });
|
|
2734
|
+
*
|
|
2735
|
+
* // Track purchase
|
|
2736
|
+
* ecommerce.purchase({
|
|
2737
|
+
* orderId: 'ORD123',
|
|
2738
|
+
* items: [{ productId: 'SKU123', name: 'Blue Shirt', price: 29.99, quantity: 1 }],
|
|
2739
|
+
* value: 29.99
|
|
2740
|
+
* });
|
|
2741
|
+
*/
|
|
2742
|
+
ecommerce(config?: EcommerceConfig): EcommerceTracker {
|
|
2743
|
+
if (!this.isInitialized) {
|
|
2744
|
+
this.logger?.error('SDK not initialized');
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// Create or return cached instance
|
|
2748
|
+
if (!this._ecommerce || config) {
|
|
2749
|
+
this._ecommerce = createEcommerceTracker(this, config, this.logger);
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
return this._ecommerce;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
/**
|
|
2756
|
+
* SDK Authentication — set / refresh the customer-minted JWT.
|
|
2757
|
+
*
|
|
2758
|
+
* When SDK authentication is enabled (`enableSdkAuthentication: true`), the
|
|
2759
|
+
* SDK attaches this token as `X-Joryio-Auth` to every ingest request. Call
|
|
2760
|
+
* this on init (or from the failure callback) whenever you mint a fresh JWT.
|
|
2761
|
+
*/
|
|
2762
|
+
setSdkAuthenticationToken(token: string): void {
|
|
2763
|
+
if (!this.isInitialized) {
|
|
2764
|
+
this.logger?.error('SDK not initialized');
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
if (!this.config.enableSdkAuthentication) {
|
|
2768
|
+
this.logger.warn('setSdkAuthenticationToken called but enableSdkAuthentication is false - ignoring');
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
this.network.setSdkAuthenticationToken(token);
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
/**
|
|
2775
|
+
* SDK Authentication — register a callback fired when the backend rejects the
|
|
2776
|
+
* token with a 401 `sdk_authentication_error`. The callback receives the
|
|
2777
|
+
* failure reason and the endpoint/user; mint a fresh JWT and either return it
|
|
2778
|
+
* (string / Promise<string>) or call setSdkAuthenticationToken(). The failed
|
|
2779
|
+
* request is then retried once with the new token.
|
|
2780
|
+
*/
|
|
2781
|
+
setSdkAuthenticationErrorCallback(cb: SdkAuthFailureCallback): void {
|
|
2782
|
+
if (!this.isInitialized) {
|
|
2783
|
+
this.logger?.error('SDK not initialized');
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
this.network.setSdkAuthenticationFailureCallback(cb);
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
/**
|
|
2790
|
+
* Get the current anonymous ID.
|
|
2791
|
+
*
|
|
2792
|
+
* Always present: generated on first init and persisted in localStorage under
|
|
2793
|
+
* `jry_<hash(sdkKey)>_anonymous_id`. Stable across calls for the life of the
|
|
2794
|
+
* anonymous visitor; `reset()` (logout) regenerates it. This is the exact
|
|
2795
|
+
* `anonymousId` attached to every event `track()` sends, so it's the right id
|
|
2796
|
+
* to stamp on a consent log or a server-side event for stitching.
|
|
2797
|
+
*
|
|
2798
|
+
* Async-snippet note: `window.joryio` is the command queue until the SDK
|
|
2799
|
+
* finishes loading, and a queued stub can't return a value. Read this after
|
|
2800
|
+
* load — call it on the resolved instance (`window.joryio.getAnonymousId()`),
|
|
2801
|
+
* or from inside `ready(cb)`.
|
|
2802
|
+
*/
|
|
2803
|
+
getAnonymousId(): string {
|
|
2804
|
+
return this.identity ? this.identity.getAnonymousId() : '';
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
/**
|
|
2808
|
+
* Get the current identified user ID, or `null` if the visitor is still
|
|
2809
|
+
* anonymous. Same async-snippet caveat as {@link getAnonymousId}: read it
|
|
2810
|
+
* after load (on the instance or inside `ready(cb)`).
|
|
2811
|
+
*/
|
|
2812
|
+
getUserId(): string | null {
|
|
2813
|
+
return this.identity ? this.identity.getUserId() : null;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
/**
|
|
2817
|
+
* Run a callback once the SDK is loaded and initialized, passing the SDK
|
|
2818
|
+
* instance. On the async snippet this is the safe way to read a value that
|
|
2819
|
+
* queued stubs can't return before load, e.g.:
|
|
2820
|
+
*
|
|
2821
|
+
* joryio.ready(function (sdk) {
|
|
2822
|
+
* var anonId = sdk.getAnonymousId();
|
|
2823
|
+
* });
|
|
2824
|
+
*
|
|
2825
|
+
* By the time this method exists on the resolved instance the SDK is already
|
|
2826
|
+
* initialized, so the callback runs synchronously.
|
|
2827
|
+
*/
|
|
2828
|
+
/**
|
|
2829
|
+
* Data calls dropped while non-empty: the synchronous run of a ready() callback
|
|
2830
|
+
* the snippet queued while tracking was off (rule 83). It runs at its
|
|
2831
|
+
* queue position's privacy state, not the state the later queued
|
|
2832
|
+
* decisions produced - its track(), attribute writes, aliases, conversions
|
|
2833
|
+
* and non-withdrawal subscription writes are dropped like a queued
|
|
2834
|
+
* track() made there (rule 73). Withdrawals, identify() and reads work,
|
|
2835
|
+
* and an optIn() / updateConsent('granted') the callback itself makes
|
|
2836
|
+
* lifts the matching reason for the rest of it (rule 92).
|
|
2837
|
+
*/
|
|
2838
|
+
private dataGate: Set<DataGateReason> = new Set();
|
|
2839
|
+
|
|
2840
|
+
private liftDataGate(reason: DataGateReason): void {
|
|
2841
|
+
if (!this.dataGate.has(reason)) return;
|
|
2842
|
+
const next = new Set(this.dataGate);
|
|
2843
|
+
next.delete(reason);
|
|
2844
|
+
this.dataGate = next;
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
/** See setClearedInflightCallback (rule 90). */
|
|
2848
|
+
private inflightSessionStartAtClear: string | null = null;
|
|
2849
|
+
|
|
2850
|
+
private isDroppingDataCalls(method: string): boolean {
|
|
2851
|
+
if (this.dataGate.size === 0) return false;
|
|
2852
|
+
this.logger.debug(`${method}() in a ready() callback queued while tracking was off: dropped`);
|
|
2853
|
+
return true;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
ready(callback: (sdk: JoryioSDK) => void): void {
|
|
2857
|
+
if (typeof callback !== 'function') {
|
|
2858
|
+
this.logger.error('ready(callback) requires a function');
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
try {
|
|
2862
|
+
callback(this);
|
|
2863
|
+
} catch (error) {
|
|
2864
|
+
this.logger.error('Error in ready() callback:', error);
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
/**
|
|
2869
|
+
* Get current user identity
|
|
2870
|
+
*/
|
|
2871
|
+
getIdentity() {
|
|
2872
|
+
return this.identity.getIdentity();
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
/**
|
|
2876
|
+
* Get current session info
|
|
2877
|
+
*/
|
|
2878
|
+
getSessionInfo() {
|
|
2879
|
+
return this.session.getSessionInfo();
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
/**
|
|
2883
|
+
* Get UTM data (current, first touch, last touch)
|
|
2884
|
+
*/
|
|
2885
|
+
getUTMData() {
|
|
2886
|
+
return this.utm.getUTMData();
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
/**
|
|
2890
|
+
* Manually update UTM parameters (useful for SPAs that change URL without reload)
|
|
2891
|
+
*/
|
|
2892
|
+
updateUTM(): void {
|
|
2893
|
+
if (!this.isInitialized) {
|
|
2894
|
+
this.logger.error('SDK not initialized');
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
this.utm.updateUTM();
|
|
2899
|
+
|
|
2900
|
+
// Check if session should be reset due to new campaign
|
|
2901
|
+
if (this.config.resetSessionOnNewCampaign && this.utm.hasNewCampaign()) {
|
|
2902
|
+
this.logger.info('New campaign detected, resetting session');
|
|
2903
|
+
// resetSession() starts the new session through the session's
|
|
2904
|
+
// onNewSession hook, which tracks its Session Start. Tracking one here
|
|
2905
|
+
// as well sent every campaign change's Session Start twice.
|
|
2906
|
+
this.session.resetSession();
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
/**
|
|
2911
|
+
* Sync in-app campaigns from backend
|
|
2912
|
+
* @param attributeChanges - Optional object with attribute changes for differential sync
|
|
2913
|
+
*/
|
|
2914
|
+
async syncInAppCampaigns(attributeChanges?: Record<string, any>): Promise<void> {
|
|
2915
|
+
if (!this.isInitialized) {
|
|
2916
|
+
this.logger.error('SDK not initialized');
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
if (!this.config.inApp.enabled) {
|
|
2921
|
+
this.logger.warn('In-app messaging is not enabled');
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
if (!this.canSend()) {
|
|
2926
|
+
this.logger.debug('Tracking disabled or consent pending, not syncing in-app campaigns');
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
try {
|
|
2931
|
+
await this.inapp.sync(attributeChanges);
|
|
2932
|
+
} catch (error) {
|
|
2933
|
+
this.logger.error('In-app sync failed:', error);
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
/**
|
|
2938
|
+
* Get all eligible in-app campaigns
|
|
2939
|
+
*/
|
|
2940
|
+
getInAppCampaigns(): InAppCampaign[] {
|
|
2941
|
+
if (!this.isInitialized) {
|
|
2942
|
+
this.logger.error('SDK not initialized');
|
|
2943
|
+
return [];
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
if (!this.config.inApp.enabled) {
|
|
2947
|
+
return [];
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
return this.inapp.getCampaigns();
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
/**
|
|
2954
|
+
* Manually display an in-app campaign
|
|
2955
|
+
* @param campaignId - Campaign ID to display
|
|
2956
|
+
*/
|
|
2957
|
+
async displayInAppCampaign(campaignId: string): Promise<void> {
|
|
2958
|
+
if (!this.isInitialized) {
|
|
2959
|
+
this.logger.error('SDK not initialized');
|
|
2960
|
+
return;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
if (!this.config.inApp.enabled) {
|
|
2964
|
+
this.logger.warn('In-app messaging is not enabled');
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
if (!this.canSend()) {
|
|
2969
|
+
this.logger.debug('Tracking disabled or consent pending, not displaying in-app campaign');
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
const campaigns = this.inapp.getCampaigns();
|
|
2974
|
+
const campaign = campaigns.find((c) => c.id === campaignId);
|
|
2975
|
+
|
|
2976
|
+
if (!campaign) {
|
|
2977
|
+
this.logger.error(`Campaign ${campaignId} not found`);
|
|
2978
|
+
return;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
try {
|
|
2982
|
+
await this.inapp.displayCampaign(campaign);
|
|
2983
|
+
} catch (error) {
|
|
2984
|
+
this.logger.error('In-app display failed:', error);
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
/**
|
|
2989
|
+
* Dismiss an active in-app message
|
|
2990
|
+
* @param campaignId - Campaign ID to dismiss
|
|
2991
|
+
*/
|
|
2992
|
+
dismissInAppMessage(campaignId: string): void {
|
|
2993
|
+
if (!this.isInitialized) {
|
|
2994
|
+
this.logger.error('SDK not initialized');
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
if (!this.config.inApp.enabled) {
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
this.inapp.dismiss(campaignId).catch((error) =>
|
|
3003
|
+
this.logger.error('In-app dismiss failed:', error));
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
/**
|
|
3007
|
+
* Track a conversion event for an in-app campaign
|
|
3008
|
+
* @param campaignId - Campaign ID
|
|
3009
|
+
* @param eventName - Conversion event name
|
|
3010
|
+
*/
|
|
3011
|
+
async trackInAppConversion(campaignId: string, eventName: string): Promise<void> {
|
|
3012
|
+
if (!this.isInitialized) {
|
|
3013
|
+
this.logger.error('SDK not initialized');
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
if (this.isDroppingDataCalls('trackInAppConversion')) return;
|
|
3017
|
+
|
|
3018
|
+
if (!this.config.inApp.enabled) {
|
|
3019
|
+
this.logger.warn('In-app messaging is not enabled');
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
if (!this.canSend()) {
|
|
3024
|
+
this.logger.debug('Tracking disabled or consent pending, not tracking in-app conversion');
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
try {
|
|
3029
|
+
await this.inapp.trackConversion(campaignId, eventName);
|
|
3030
|
+
} catch (error) {
|
|
3031
|
+
this.logger.error('In-app conversion failed:', error);
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
/**
|
|
3036
|
+
* Destroy the SDK instance
|
|
3037
|
+
*/
|
|
3038
|
+
async destroy(): Promise<void> {
|
|
3039
|
+
if (!this.isInitialized) {
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
this.logger.info('Destroying SDK instance');
|
|
3043
|
+
|
|
3044
|
+
// Send any debounced attributes NOW, then stop the timer.
|
|
3045
|
+
//
|
|
3046
|
+
// destroy() left this timer running, so a pending 800ms debounce fired
|
|
3047
|
+
// after teardown and tried to send from a destroyed SDK - and dropped the
|
|
3048
|
+
// attributes if the page went first. Flushing before clearing means a
|
|
3049
|
+
// setAttribute immediately followed by destroy() is delivered rather than
|
|
3050
|
+
// silently discarded.
|
|
3051
|
+
if (this.attributeFlushTimer !== null) {
|
|
3052
|
+
clearTimeout(this.attributeFlushTimer);
|
|
3053
|
+
this.attributeFlushTimer = null;
|
|
3054
|
+
if (this.canSend()) this.flushAttributeOps();
|
|
3055
|
+
}
|
|
3056
|
+
// Stored writes stay stored (the next page view sends them); only the timers stop.
|
|
3057
|
+
this.outbox.destroy();
|
|
3058
|
+
this._push.destroy();
|
|
3059
|
+
|
|
3060
|
+
// Remove page listeners
|
|
3061
|
+
if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
|
|
3062
|
+
if (this.boundHandlePageUnload) {
|
|
3063
|
+
window.removeEventListener('pagehide', this.boundHandlePageUnload);
|
|
3064
|
+
window.removeEventListener('beforeunload', this.boundHandlePageUnload);
|
|
3065
|
+
}
|
|
3066
|
+
if (this.boundHandleOnline) {
|
|
3067
|
+
window.removeEventListener('online', this.boundHandleOnline);
|
|
3068
|
+
}
|
|
3069
|
+
if (this.boundHandleStorage) {
|
|
3070
|
+
window.removeEventListener('storage', this.boundHandleStorage);
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
this.boundHandlePageUnload = null;
|
|
3074
|
+
this.boundHandleOnline = null;
|
|
3075
|
+
this.boundHandleStorage = null;
|
|
3076
|
+
if (this.pruneTimer !== null) {
|
|
3077
|
+
clearTimeout(this.pruneTimer);
|
|
3078
|
+
this.pruneTimer = null;
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
// Flush pending events
|
|
3082
|
+
await this.queue.flush().catch(() => undefined);
|
|
3083
|
+
|
|
3084
|
+
// Stop queue
|
|
3085
|
+
this.queue.stop();
|
|
3086
|
+
|
|
3087
|
+
// Unload plugins
|
|
3088
|
+
await this.unloadPlugins();
|
|
3089
|
+
|
|
3090
|
+
// Cleanup managers
|
|
3091
|
+
this.session.destroy();
|
|
3092
|
+
|
|
3093
|
+
// Clear in-app messaging (every timer included)
|
|
3094
|
+
this.inapp.clearAll();
|
|
3095
|
+
this.inapp.destroy();
|
|
3096
|
+
|
|
3097
|
+
this.isInitialized = false;
|
|
3098
|
+
if (JoryioSDK.instance === this) JoryioSDK.instance = null;
|
|
3099
|
+
this.logger.info('SDK destroyed');
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
/**
|
|
3103
|
+
* Get the current SDK instance (if initialized)
|
|
3104
|
+
*/
|
|
3105
|
+
static getInstance(): JoryioSDK | null {
|
|
3106
|
+
return JoryioSDK.instance;
|
|
3107
|
+
}
|
|
3108
|
+
}
|