@quacklytics/client 0.0.0-stage → 0.2.1-nightly.sha-cb7631015021
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/LICENSE +22 -0
- package/README.md +104 -2
- package/dist/autotrack.d.ts +20 -0
- package/dist/autotrack.d.ts.map +1 -0
- package/dist/autotrack.js +50 -0
- package/dist/autotrack.js.map +1 -0
- package/dist/consent.d.ts +33 -0
- package/dist/consent.d.ts.map +1 -0
- package/dist/consent.js +126 -0
- package/dist/consent.js.map +1 -0
- package/dist/context.d.ts +11 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +109 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +694 -0
- package/dist/index.js.map +1 -0
- package/dist/legacy.d.ts +44 -0
- package/dist/legacy.d.ts.map +1 -0
- package/dist/legacy.js +125 -0
- package/dist/legacy.js.map +1 -0
- package/dist/protocol.d.ts +181 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +90 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +11 -0
- package/dist/server.js.map +1 -0
- package/package.json +63 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { sdkEvent, sdkOperation, sdkRequest, identifyOperation, ingestAcknowledgment, jsonProperties, } from "./protocol.js";
|
|
3
|
+
import { context, observeNavigation, sanitizeUrl } from "./context.js";
|
|
4
|
+
import { enableClickAutotracking } from "./autotrack.js";
|
|
5
|
+
import { createClient as createLegacyClient } from "./legacy.js";
|
|
6
|
+
export { enableClickAutotracking, trackPageview, DEFAULT_CLICK_SELECTORS, } from "./autotrack.js";
|
|
7
|
+
const savedIdentity = z.object({
|
|
8
|
+
anonymousId: z.uuid(),
|
|
9
|
+
sessionId: z.uuid(),
|
|
10
|
+
userId: z.string().optional(),
|
|
11
|
+
lastActivity: z.number(),
|
|
12
|
+
});
|
|
13
|
+
const savedBatch = z.object({ body: z.string(), createdAt: z.number() });
|
|
14
|
+
const savedQueue = z.object({
|
|
15
|
+
pending: z.array(sdkOperation),
|
|
16
|
+
batches: z.array(savedBatch),
|
|
17
|
+
});
|
|
18
|
+
// Captured before a consent decision; identity is attached once the visitor decides.
|
|
19
|
+
const bufferedOperation = z.discriminatedUnion("kind", [
|
|
20
|
+
sdkEvent,
|
|
21
|
+
identifyOperation.omit({ anonymousId: true }),
|
|
22
|
+
]);
|
|
23
|
+
const savedBuffer = z.array(bufferedOperation);
|
|
24
|
+
const instances = new Map();
|
|
25
|
+
const bytes = (value) => new TextEncoder().encode(value).byteLength;
|
|
26
|
+
export function createClient(config) {
|
|
27
|
+
if (!config.apiKey)
|
|
28
|
+
throw new Error("apiKey is required");
|
|
29
|
+
if (!config.hostname)
|
|
30
|
+
throw new Error("hostname is required");
|
|
31
|
+
const url = new URL(config.hostname.includes("://")
|
|
32
|
+
? config.hostname
|
|
33
|
+
: `${config.protocol ?? "https"}://${config.hostname}`);
|
|
34
|
+
if (!["https:", "http:"].includes(url.protocol) ||
|
|
35
|
+
url.username ||
|
|
36
|
+
url.password)
|
|
37
|
+
throw new Error("hostname must be an HTTP(S) URL without credentials");
|
|
38
|
+
if (config.port !== undefined)
|
|
39
|
+
url.port = String(config.port);
|
|
40
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/api/ingest`;
|
|
41
|
+
url.search = "";
|
|
42
|
+
url.hash = "";
|
|
43
|
+
const endpoint = url.toString();
|
|
44
|
+
const browser = config.runtime !== "server" && typeof window !== "undefined";
|
|
45
|
+
const key = `ql:${endpoint}:${config.apiKey}`;
|
|
46
|
+
// A second initialization must not install another set of automatic collectors.
|
|
47
|
+
if (browser && instances.has(key))
|
|
48
|
+
throw new Error("Quacklytics is already initialized for this project; reuse the existing client");
|
|
49
|
+
const numberOption = (value, fallback, max = Number.MAX_SAFE_INTEGER) => z
|
|
50
|
+
.number()
|
|
51
|
+
.int()
|
|
52
|
+
.positive()
|
|
53
|
+
.max(max)
|
|
54
|
+
.parse(value ?? fallback);
|
|
55
|
+
const batchSize = numberOption(config.batchSize, 20, 1000);
|
|
56
|
+
const maxQueueSize = numberOption(config.maxQueueSize, 1000);
|
|
57
|
+
const maxQueueBytes = numberOption(config.maxQueueBytes, 1000000);
|
|
58
|
+
const maxEventBytes = numberOption(config.maxEventBytes, 48000, 48000);
|
|
59
|
+
const maxAge = numberOption(config.maxQueueAgeMs, 24 * 60 * 60 * 1000);
|
|
60
|
+
const sessionTimeout = numberOption(config.sessionTimeoutMs, 30 * 60 * 1000);
|
|
61
|
+
const intervalMs = numberOption(config.flushIntervalMs, 5000);
|
|
62
|
+
const attempts = numberOption(config.maxAttempts, 3, 10);
|
|
63
|
+
const retryDelay = numberOption(config.retryDelayMs, 250);
|
|
64
|
+
const timeoutMs = numberOption(config.requestTimeoutMs, 10000);
|
|
65
|
+
const transport = config.fetch ?? globalThis.fetch;
|
|
66
|
+
if (!transport)
|
|
67
|
+
throw new Error("fetch is unavailable");
|
|
68
|
+
const legacy = createLegacyClient({
|
|
69
|
+
...config,
|
|
70
|
+
protocol: config.protocol ?? "https",
|
|
71
|
+
autoTrackPageviews: false,
|
|
72
|
+
});
|
|
73
|
+
const allowed = config.allowedQueryParameters ?? [];
|
|
74
|
+
let dead = false, generation = 0;
|
|
75
|
+
let identity;
|
|
76
|
+
let identityMemoryOnly = false;
|
|
77
|
+
let pending = [], batches = [];
|
|
78
|
+
let running;
|
|
79
|
+
let controller;
|
|
80
|
+
let retryWake;
|
|
81
|
+
let pageUrl, pageViewId;
|
|
82
|
+
const report = (error) => {
|
|
83
|
+
try {
|
|
84
|
+
config.onError?.(error);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* Application error handlers cannot break collection. */
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
function storageRead(name, tab = false) {
|
|
91
|
+
if (!browser)
|
|
92
|
+
return null;
|
|
93
|
+
try {
|
|
94
|
+
return (tab ? sessionStorage : localStorage).getItem(`${key}:${name}`);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function storageWrite(name, value, tab = false) {
|
|
101
|
+
if (!browser)
|
|
102
|
+
return false;
|
|
103
|
+
try {
|
|
104
|
+
const storage = tab ? sessionStorage : localStorage;
|
|
105
|
+
if (value === null)
|
|
106
|
+
storage.removeItem(`${key}:${name}`);
|
|
107
|
+
else
|
|
108
|
+
storage.setItem(`${key}:${name}`, value);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
report(error);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function readIdentity() {
|
|
117
|
+
try {
|
|
118
|
+
const value = JSON.parse(localStorage.getItem(`${key}:identity`) ?? "null");
|
|
119
|
+
const parsed = savedIdentity.safeParse(value);
|
|
120
|
+
return parsed.success
|
|
121
|
+
? parsed.data
|
|
122
|
+
: identityMemoryOnly
|
|
123
|
+
? identity
|
|
124
|
+
: undefined;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return identity;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let consent = !browser
|
|
131
|
+
? "granted"
|
|
132
|
+
: storageRead("consent") === "denied"
|
|
133
|
+
? "denied"
|
|
134
|
+
: storageRead("consent") === "granted" || config.consent === "granted"
|
|
135
|
+
? "granted"
|
|
136
|
+
: "pending";
|
|
137
|
+
if (consent === "denied") {
|
|
138
|
+
storageWrite("identity", null);
|
|
139
|
+
storageWrite("outbox", null, true);
|
|
140
|
+
}
|
|
141
|
+
let bufferInMemory = false, memoryBuffer = [];
|
|
142
|
+
/** Full collection: persistent visitor identity, identify, and a stored outbox. */
|
|
143
|
+
function active() {
|
|
144
|
+
if (browser && consent === "granted" && storageRead("consent") === "denied")
|
|
145
|
+
setConsent("denied", false);
|
|
146
|
+
return !dead && consent === "granted";
|
|
147
|
+
}
|
|
148
|
+
/** Declined collection: the service assigns a cookieless daily visitor; nothing is stored. */
|
|
149
|
+
function anonymous() {
|
|
150
|
+
return !active() && !dead && consent === "denied";
|
|
151
|
+
}
|
|
152
|
+
function sending() {
|
|
153
|
+
return active() || anonymous();
|
|
154
|
+
}
|
|
155
|
+
function readBuffer() {
|
|
156
|
+
if (bufferInMemory)
|
|
157
|
+
return memoryBuffer;
|
|
158
|
+
try {
|
|
159
|
+
const parsed = savedBuffer.safeParse(JSON.parse(storageRead("buffer") ?? "[]"));
|
|
160
|
+
return parsed.success ? parsed.data : [];
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function writeBuffer(entries) {
|
|
167
|
+
memoryBuffer = entries;
|
|
168
|
+
if (!bufferInMemory)
|
|
169
|
+
bufferInMemory = !storageWrite("buffer", entries.length ? JSON.stringify(entries) : null);
|
|
170
|
+
}
|
|
171
|
+
function buffer(operation) {
|
|
172
|
+
const snapshot = bufferedOperation.parse(JSON.parse(JSON.stringify(operation)));
|
|
173
|
+
if (bytes(JSON.stringify(snapshot)) > maxEventBytes)
|
|
174
|
+
throw new Error("Analytics event exceeds maxEventBytes");
|
|
175
|
+
const entries = [
|
|
176
|
+
...readBuffer().filter((entry) => Date.now() - Date.parse(entry.timestamp) <= maxAge),
|
|
177
|
+
snapshot,
|
|
178
|
+
];
|
|
179
|
+
if (entries.length > maxQueueSize ||
|
|
180
|
+
bytes(JSON.stringify(entries)) > maxQueueBytes)
|
|
181
|
+
throw new Error("Analytics consent buffer is full");
|
|
182
|
+
writeBuffer(entries);
|
|
183
|
+
return { status: "buffered", id: snapshot.id };
|
|
184
|
+
}
|
|
185
|
+
/** Sends pre-consent captures under the identity the visitor's decision allows. */
|
|
186
|
+
function release() {
|
|
187
|
+
const entries = readBuffer().filter((entry) => Date.now() - Date.parse(entry.timestamp) <= maxAge);
|
|
188
|
+
if (!entries.length || !sending())
|
|
189
|
+
return;
|
|
190
|
+
writeBuffer([]);
|
|
191
|
+
const full = active();
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
try {
|
|
194
|
+
const who = full ? currentIdentity() : undefined;
|
|
195
|
+
if (entry.kind === "identify") {
|
|
196
|
+
// Declining consent never sends person data.
|
|
197
|
+
if (!who)
|
|
198
|
+
continue;
|
|
199
|
+
if (who.userId && who.userId !== entry.userId)
|
|
200
|
+
throw new Error("Reset before switching users");
|
|
201
|
+
enqueue(sdkOperation.parse({ ...entry, anonymousId: who.anonymousId }));
|
|
202
|
+
}
|
|
203
|
+
else
|
|
204
|
+
enqueue(sdkOperation.parse({
|
|
205
|
+
...entry,
|
|
206
|
+
...(who
|
|
207
|
+
? { anonymousId: who.anonymousId, sessionId: who.sessionId }
|
|
208
|
+
: { anonymous: true }),
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
report(error);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
void flush().catch(report);
|
|
216
|
+
}
|
|
217
|
+
function currentIdentity() {
|
|
218
|
+
if (!browser)
|
|
219
|
+
return undefined;
|
|
220
|
+
identity = readIdentity();
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
identity ?? (identity = {
|
|
223
|
+
anonymousId: crypto.randomUUID(),
|
|
224
|
+
sessionId: crypto.randomUUID(),
|
|
225
|
+
lastActivity: now,
|
|
226
|
+
});
|
|
227
|
+
if (now - identity.lastActivity >= sessionTimeout ||
|
|
228
|
+
now < identity.lastActivity)
|
|
229
|
+
identity.sessionId = crypto.randomUUID();
|
|
230
|
+
identity.lastActivity = now;
|
|
231
|
+
identityMemoryOnly = !storageWrite("identity", JSON.stringify(identity));
|
|
232
|
+
return identity;
|
|
233
|
+
}
|
|
234
|
+
function persist() {
|
|
235
|
+
if (active())
|
|
236
|
+
storageWrite("outbox", JSON.stringify({ pending, batches }), true);
|
|
237
|
+
}
|
|
238
|
+
function prune() {
|
|
239
|
+
const before = pending.length + batches.length;
|
|
240
|
+
pending = pending.filter((operation) => Date.now() - Date.parse(operation.timestamp) <= maxAge);
|
|
241
|
+
batches = batches.filter((batch) => Date.now() - batch.createdAt <= maxAge);
|
|
242
|
+
if (before !== pending.length + batches.length)
|
|
243
|
+
report(new Error("Expired analytics outbox entries were dropped"));
|
|
244
|
+
}
|
|
245
|
+
if (active() && browser) {
|
|
246
|
+
try {
|
|
247
|
+
const raw = JSON.parse(storageRead("outbox", true) ?? "null");
|
|
248
|
+
const restored = savedQueue.safeParse(raw);
|
|
249
|
+
if (restored.success) {
|
|
250
|
+
// Validate serialized envelopes as well; storage is an external input.
|
|
251
|
+
for (const batch of restored.data.batches)
|
|
252
|
+
sdkRequest.parse(JSON.parse(batch.body));
|
|
253
|
+
const count = restored.data.pending.length +
|
|
254
|
+
restored.data.batches.reduce((sum, batch) => sum + sdkRequest.parse(JSON.parse(batch.body)).operations.length, 0);
|
|
255
|
+
if (count <= maxQueueSize &&
|
|
256
|
+
bytes(JSON.stringify(restored.data)) <= maxQueueBytes) {
|
|
257
|
+
pending = restored.data.pending;
|
|
258
|
+
batches = restored.data.batches;
|
|
259
|
+
prune();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
report(error);
|
|
265
|
+
storageWrite("outbox", null, true);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function enqueue(operation) {
|
|
269
|
+
if (!sending())
|
|
270
|
+
return { status: "disabled" };
|
|
271
|
+
prune();
|
|
272
|
+
const snapshot = sdkOperation.parse(JSON.parse(JSON.stringify(operation)));
|
|
273
|
+
const serialized = JSON.stringify(snapshot);
|
|
274
|
+
if (bytes(serialized) > maxEventBytes)
|
|
275
|
+
throw new Error("Analytics event exceeds maxEventBytes");
|
|
276
|
+
const queued = pending.length +
|
|
277
|
+
batches.reduce((sum, batch) => sum + sdkRequest.parse(JSON.parse(batch.body)).operations.length, 0);
|
|
278
|
+
if (queued >= maxQueueSize ||
|
|
279
|
+
bytes(JSON.stringify({ pending: [...pending, snapshot], batches })) >
|
|
280
|
+
maxQueueBytes)
|
|
281
|
+
throw new Error("Analytics outbox is full");
|
|
282
|
+
pending.push(snapshot);
|
|
283
|
+
persist();
|
|
284
|
+
if (pending.length >= batchSize)
|
|
285
|
+
queueMicrotask(() => {
|
|
286
|
+
void flush().catch(report);
|
|
287
|
+
});
|
|
288
|
+
return { status: "queued", id: operation.id };
|
|
289
|
+
}
|
|
290
|
+
function capture(name, properties, extra = {}) {
|
|
291
|
+
const undecided = browser && !dead && consent === "pending";
|
|
292
|
+
if (!undecided && !sending())
|
|
293
|
+
return { status: "disabled" };
|
|
294
|
+
const timestamp = extra.timestamp ?? new Date().toISOString();
|
|
295
|
+
const who = active() ? currentIdentity() : undefined;
|
|
296
|
+
const capturedContext = context(allowed, browser);
|
|
297
|
+
if (capturedContext.page && pageViewId)
|
|
298
|
+
capturedContext.page.pageViewId = pageViewId;
|
|
299
|
+
const event = sdkEvent.parse({
|
|
300
|
+
kind: "event",
|
|
301
|
+
id: crypto.randomUUID(),
|
|
302
|
+
timestamp,
|
|
303
|
+
eventType: name,
|
|
304
|
+
...(who
|
|
305
|
+
? {
|
|
306
|
+
anonymousId: who.anonymousId,
|
|
307
|
+
sessionId: who.sessionId,
|
|
308
|
+
...(who.userId ? { userId: who.userId } : {}),
|
|
309
|
+
}
|
|
310
|
+
: anonymous() && browser
|
|
311
|
+
? { anonymous: true }
|
|
312
|
+
: {}),
|
|
313
|
+
properties: jsonProperties.parse(properties),
|
|
314
|
+
context: capturedContext,
|
|
315
|
+
...extra,
|
|
316
|
+
});
|
|
317
|
+
const transformed = config.beforeSend ? config.beforeSend(event) : event;
|
|
318
|
+
if (transformed === null)
|
|
319
|
+
return { status: "dropped" };
|
|
320
|
+
// Hooks may redact content, but cannot rewrite identity, timestamps, or idempotency keys.
|
|
321
|
+
const final = sdkEvent.parse({
|
|
322
|
+
...transformed,
|
|
323
|
+
id: event.id,
|
|
324
|
+
timestamp: event.timestamp,
|
|
325
|
+
anonymousId: event.anonymousId,
|
|
326
|
+
sessionId: event.sessionId,
|
|
327
|
+
userId: event.userId,
|
|
328
|
+
anonymous: event.anonymous,
|
|
329
|
+
});
|
|
330
|
+
return undecided ? buffer(final) : enqueue(final);
|
|
331
|
+
}
|
|
332
|
+
async function page(properties = {}) {
|
|
333
|
+
if (dead || !browser)
|
|
334
|
+
return { status: "disabled" };
|
|
335
|
+
const nextUrl = sanitizeUrl(location.href, allowed);
|
|
336
|
+
if (pageUrl === nextUrl)
|
|
337
|
+
return { status: "dropped" };
|
|
338
|
+
const previous = pageUrl;
|
|
339
|
+
const nextId = crypto.randomUUID();
|
|
340
|
+
const capturedContext = context(allowed, browser);
|
|
341
|
+
if (capturedContext.page)
|
|
342
|
+
capturedContext.page = {
|
|
343
|
+
...capturedContext.page,
|
|
344
|
+
pageViewId: nextId,
|
|
345
|
+
...(previous ? { previousUrl: previous } : {}),
|
|
346
|
+
};
|
|
347
|
+
const result = capture("$pageview", properties, {
|
|
348
|
+
context: capturedContext,
|
|
349
|
+
});
|
|
350
|
+
if (result.status === "queued" || result.status === "buffered") {
|
|
351
|
+
pageUrl = nextUrl;
|
|
352
|
+
pageViewId = nextId;
|
|
353
|
+
}
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
function makeBatch() {
|
|
357
|
+
if (batches[0])
|
|
358
|
+
return batches[0];
|
|
359
|
+
if (!pending.length)
|
|
360
|
+
return undefined;
|
|
361
|
+
const operations = [];
|
|
362
|
+
const requestId = crypto.randomUUID();
|
|
363
|
+
while (pending[0] && operations.length < batchSize) {
|
|
364
|
+
const next = pending[0];
|
|
365
|
+
if (operations.length &&
|
|
366
|
+
bytes(JSON.stringify({
|
|
367
|
+
protocolVersion: 1,
|
|
368
|
+
requestId,
|
|
369
|
+
operations: [...operations, next],
|
|
370
|
+
})) > 55000)
|
|
371
|
+
break;
|
|
372
|
+
operations.push(next);
|
|
373
|
+
pending.shift();
|
|
374
|
+
}
|
|
375
|
+
const batch = {
|
|
376
|
+
body: JSON.stringify(sdkRequest.parse({ protocolVersion: 1, requestId, operations })),
|
|
377
|
+
createdAt: Date.now(),
|
|
378
|
+
};
|
|
379
|
+
batches.push(batch);
|
|
380
|
+
persist();
|
|
381
|
+
return batch;
|
|
382
|
+
}
|
|
383
|
+
async function deliver(batch, epoch) {
|
|
384
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
385
|
+
if (!sending() || epoch !== generation)
|
|
386
|
+
throw new Error("Analytics delivery cancelled");
|
|
387
|
+
controller = new AbortController();
|
|
388
|
+
const timeout = setTimeout(() => controller?.abort(), timeoutMs);
|
|
389
|
+
let delay = retryDelay * 2 ** attempt * (0.75 + Math.random() * 0.5);
|
|
390
|
+
try {
|
|
391
|
+
const response = await transport(endpoint, {
|
|
392
|
+
method: "POST",
|
|
393
|
+
headers: {
|
|
394
|
+
"Content-Type": "application/json",
|
|
395
|
+
"X-API-KEY": config.apiKey,
|
|
396
|
+
},
|
|
397
|
+
body: batch.body,
|
|
398
|
+
signal: controller.signal,
|
|
399
|
+
keepalive: browser,
|
|
400
|
+
});
|
|
401
|
+
if (response.ok) {
|
|
402
|
+
const acknowledgment = ingestAcknowledgment.parse(await response.json());
|
|
403
|
+
if (acknowledgment.committed +
|
|
404
|
+
acknowledgment.duplicates +
|
|
405
|
+
acknowledgment.rejected !==
|
|
406
|
+
sdkRequest.parse(JSON.parse(batch.body)).operations.length)
|
|
407
|
+
throw new Error("Invalid analytics acknowledgment count");
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (response.status !== 429 && response.status < 500) {
|
|
411
|
+
if (batches[0] === batch)
|
|
412
|
+
batches.shift();
|
|
413
|
+
persist();
|
|
414
|
+
throw new PermanentDeliveryError(`Analytics rejected (${response.status}): ${await response.text()}`);
|
|
415
|
+
}
|
|
416
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
417
|
+
if (retryAfter) {
|
|
418
|
+
const seconds = Number(retryAfter);
|
|
419
|
+
const requestedDelay = Number.isFinite(seconds)
|
|
420
|
+
? seconds * 1000
|
|
421
|
+
: Date.parse(retryAfter) - Date.now();
|
|
422
|
+
if (Number.isFinite(requestedDelay))
|
|
423
|
+
delay = Math.min(60000, Math.max(delay, requestedDelay));
|
|
424
|
+
}
|
|
425
|
+
if (attempt === attempts - 1)
|
|
426
|
+
throw new Error(`Analytics unavailable (${response.status}); retained for retry`);
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (error instanceof PermanentDeliveryError ||
|
|
430
|
+
attempt === attempts - 1 ||
|
|
431
|
+
!sending() ||
|
|
432
|
+
epoch !== generation)
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
clearTimeout(timeout);
|
|
437
|
+
}
|
|
438
|
+
if (attempt < attempts - 1)
|
|
439
|
+
await new Promise((resolve) => {
|
|
440
|
+
const timer = setTimeout(() => {
|
|
441
|
+
retryWake = undefined;
|
|
442
|
+
resolve();
|
|
443
|
+
}, delay);
|
|
444
|
+
retryWake = () => {
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
retryWake = undefined;
|
|
447
|
+
resolve();
|
|
448
|
+
};
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async function drain() {
|
|
453
|
+
const epoch = generation;
|
|
454
|
+
prune();
|
|
455
|
+
while (sending() && epoch === generation) {
|
|
456
|
+
const batch = makeBatch();
|
|
457
|
+
if (!batch)
|
|
458
|
+
return;
|
|
459
|
+
await deliver(batch, epoch);
|
|
460
|
+
if (!sending() || epoch !== generation)
|
|
461
|
+
return;
|
|
462
|
+
for (const operation of sdkRequest.parse(JSON.parse(batch.body))
|
|
463
|
+
.operations) {
|
|
464
|
+
if (operation.kind === "identify" && browser && active()) {
|
|
465
|
+
const who = currentIdentity();
|
|
466
|
+
if (who?.anonymousId === operation.anonymousId) {
|
|
467
|
+
who.userId = operation.userId;
|
|
468
|
+
identity = who;
|
|
469
|
+
identityMemoryOnly = !storageWrite("identity", JSON.stringify(who));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (batches[0] === batch)
|
|
474
|
+
batches.shift();
|
|
475
|
+
persist();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function flush() {
|
|
479
|
+
if (!running)
|
|
480
|
+
running = drain().finally(() => {
|
|
481
|
+
running = undefined;
|
|
482
|
+
});
|
|
483
|
+
// A caller may enqueue in the microtask between the drain ending and its
|
|
484
|
+
// promise settling. Its flush must also acknowledge that newly queued work.
|
|
485
|
+
return running.then(() => {
|
|
486
|
+
if (sending() && (pending.length || batches.length))
|
|
487
|
+
return flush();
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
function clearDelivery() {
|
|
491
|
+
generation++;
|
|
492
|
+
controller?.abort();
|
|
493
|
+
retryWake?.();
|
|
494
|
+
pending = [];
|
|
495
|
+
batches = [];
|
|
496
|
+
storageWrite("outbox", null, true);
|
|
497
|
+
}
|
|
498
|
+
function setConsent(state, save = true) {
|
|
499
|
+
if (dead)
|
|
500
|
+
return;
|
|
501
|
+
const previous = consent;
|
|
502
|
+
consent = state;
|
|
503
|
+
if (save)
|
|
504
|
+
storageWrite("consent", state);
|
|
505
|
+
if (state === "denied") {
|
|
506
|
+
clearDelivery();
|
|
507
|
+
identity = undefined;
|
|
508
|
+
storageWrite("identity", null);
|
|
509
|
+
}
|
|
510
|
+
// Pre-consent pageviews are released below; a new identity needs its own pageview.
|
|
511
|
+
if (previous !== "pending" && previous !== state) {
|
|
512
|
+
pageUrl = undefined;
|
|
513
|
+
pageViewId = undefined;
|
|
514
|
+
}
|
|
515
|
+
release();
|
|
516
|
+
if (previous !== state && config.autoTrackPageviews !== false)
|
|
517
|
+
void page().catch(report);
|
|
518
|
+
}
|
|
519
|
+
function track(name, properties = {}, options = {}) {
|
|
520
|
+
if (browser && (options.userId || options.sessionId))
|
|
521
|
+
throw new Error("Use identify/reset for browser identity");
|
|
522
|
+
return capture(name, properties, {
|
|
523
|
+
...(options.userId ? { userId: options.userId } : {}),
|
|
524
|
+
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
|
525
|
+
...(options.id ? { id: options.id } : {}),
|
|
526
|
+
...(options.timestamp !== undefined
|
|
527
|
+
? { timestamp: new Date(options.timestamp).toISOString() }
|
|
528
|
+
: {}),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
const cleanups = [];
|
|
532
|
+
const client = {
|
|
533
|
+
track: async (name, ...args) => track(name, args[0], args[1]),
|
|
534
|
+
page,
|
|
535
|
+
async identify(userId, traits = {}) {
|
|
536
|
+
if (browser && !dead && consent === "pending")
|
|
537
|
+
return buffer({
|
|
538
|
+
kind: "identify",
|
|
539
|
+
id: crypto.randomUUID(),
|
|
540
|
+
timestamp: new Date().toISOString(),
|
|
541
|
+
userId,
|
|
542
|
+
traits,
|
|
543
|
+
});
|
|
544
|
+
if (!active())
|
|
545
|
+
return { status: "disabled" };
|
|
546
|
+
const who = currentIdentity();
|
|
547
|
+
if (!who)
|
|
548
|
+
throw new Error("Server events must supply userId explicitly; identify requires a browser visitor");
|
|
549
|
+
if (who.userId && who.userId !== userId)
|
|
550
|
+
throw new Error("Reset before switching users");
|
|
551
|
+
const result = enqueue(sdkOperation.parse({
|
|
552
|
+
kind: "identify",
|
|
553
|
+
id: crypto.randomUUID(),
|
|
554
|
+
timestamp: new Date().toISOString(),
|
|
555
|
+
anonymousId: who.anonymousId,
|
|
556
|
+
userId,
|
|
557
|
+
traits,
|
|
558
|
+
}));
|
|
559
|
+
await flush();
|
|
560
|
+
return result;
|
|
561
|
+
},
|
|
562
|
+
reset() {
|
|
563
|
+
if (!active())
|
|
564
|
+
return;
|
|
565
|
+
identity = undefined;
|
|
566
|
+
storageWrite("identity", null);
|
|
567
|
+
pageUrl = undefined;
|
|
568
|
+
pageViewId = undefined;
|
|
569
|
+
},
|
|
570
|
+
setConsent,
|
|
571
|
+
getConsent: () => consent,
|
|
572
|
+
flush,
|
|
573
|
+
async destroy() {
|
|
574
|
+
if (dead)
|
|
575
|
+
return;
|
|
576
|
+
dead = true;
|
|
577
|
+
generation++;
|
|
578
|
+
controller?.abort();
|
|
579
|
+
retryWake?.();
|
|
580
|
+
for (const cleanup of cleanups)
|
|
581
|
+
cleanup();
|
|
582
|
+
if (browser)
|
|
583
|
+
instances.delete(key);
|
|
584
|
+
await running?.catch(report);
|
|
585
|
+
},
|
|
586
|
+
async send(event, options) {
|
|
587
|
+
await client.sendBatch([event], options);
|
|
588
|
+
},
|
|
589
|
+
async sendBatch(events, options) {
|
|
590
|
+
if (dead)
|
|
591
|
+
return;
|
|
592
|
+
if (!browser) {
|
|
593
|
+
await legacy.sendBatch(events, options);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (options?.signal)
|
|
597
|
+
throw new Error("AbortSignal is supported only by the server batch importer");
|
|
598
|
+
if (options?.deferExport || options?.backfill)
|
|
599
|
+
throw new Error("deferExport and backfill are supported only by the server batch importer");
|
|
600
|
+
for (const event of events) {
|
|
601
|
+
if (event.personId || event.sessionId)
|
|
602
|
+
throw new Error("Use identify/reset for browser identity");
|
|
603
|
+
jsonProperties.parse(event.properties ?? {});
|
|
604
|
+
if (event.personProperties &&
|
|
605
|
+
Object.keys(event.personProperties).length)
|
|
606
|
+
throw new Error("Use identify(userId, traits) to update browser person properties");
|
|
607
|
+
}
|
|
608
|
+
for (const event of events)
|
|
609
|
+
capture(event.eventType, event.properties ?? {}, {
|
|
610
|
+
...(event.id ? { id: event.id } : {}),
|
|
611
|
+
...(event.timestamp !== undefined
|
|
612
|
+
? { timestamp: new Date(event.timestamp).toISOString() }
|
|
613
|
+
: {}),
|
|
614
|
+
...(event.sessionId ? { sessionId: event.sessionId } : {}),
|
|
615
|
+
...(event.personId ? { userId: event.personId } : {}),
|
|
616
|
+
});
|
|
617
|
+
await flush();
|
|
618
|
+
},
|
|
619
|
+
async trackPageview(options = {}) {
|
|
620
|
+
await page(options.properties);
|
|
621
|
+
await flush();
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
if (browser) {
|
|
625
|
+
// Store only the lifecycle surface; generic event maps remain local to callers.
|
|
626
|
+
instances.set(key, {
|
|
627
|
+
...client,
|
|
628
|
+
track: async (name, ...args) => track(name, args[0], args[1]),
|
|
629
|
+
});
|
|
630
|
+
if (config.autoTrackPageviews !== false) {
|
|
631
|
+
const initial = () => {
|
|
632
|
+
void page().catch(report);
|
|
633
|
+
};
|
|
634
|
+
if (document.readyState === "loading") {
|
|
635
|
+
document.addEventListener("DOMContentLoaded", initial, { once: true });
|
|
636
|
+
cleanups.push(() => document.removeEventListener("DOMContentLoaded", initial));
|
|
637
|
+
}
|
|
638
|
+
else
|
|
639
|
+
queueMicrotask(initial);
|
|
640
|
+
cleanups.push(observeNavigation(() => queueMicrotask(initial)));
|
|
641
|
+
}
|
|
642
|
+
if (config.autocapture)
|
|
643
|
+
cleanups.push(enableClickAutotracking({
|
|
644
|
+
client: {
|
|
645
|
+
track: async (name, ...args) => track(name, args[0], args[1]),
|
|
646
|
+
},
|
|
647
|
+
onError: report,
|
|
648
|
+
enabled: () => !dead,
|
|
649
|
+
}).stop);
|
|
650
|
+
const visibility = () => {
|
|
651
|
+
if (document.visibilityState === "hidden")
|
|
652
|
+
void flush().catch(report);
|
|
653
|
+
};
|
|
654
|
+
const pagehide = () => {
|
|
655
|
+
void flush().catch(report);
|
|
656
|
+
};
|
|
657
|
+
const storage = (event) => {
|
|
658
|
+
if (event.key === `${key}:consent`) {
|
|
659
|
+
// Storage events may be delayed; never replay an older decision over the current one.
|
|
660
|
+
const next = storageRead("consent") === "granted" ? "granted" : "denied";
|
|
661
|
+
if (consent !== next)
|
|
662
|
+
setConsent(next, false);
|
|
663
|
+
}
|
|
664
|
+
if (event.key === `${key}:identity`) {
|
|
665
|
+
const previous = identity?.anonymousId;
|
|
666
|
+
identity = readIdentity();
|
|
667
|
+
if (previous !== identity?.anonymousId) {
|
|
668
|
+
pageUrl = undefined;
|
|
669
|
+
pageViewId = undefined;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
document.addEventListener("visibilitychange", visibility);
|
|
674
|
+
window.addEventListener("pagehide", pagehide);
|
|
675
|
+
window.addEventListener("storage", storage);
|
|
676
|
+
const timer = setInterval(() => {
|
|
677
|
+
if (sending())
|
|
678
|
+
void flush().catch(report);
|
|
679
|
+
}, intervalMs);
|
|
680
|
+
// Another tab may have decided while this one was closed, or config granted consent.
|
|
681
|
+
if (consent !== "pending")
|
|
682
|
+
release();
|
|
683
|
+
cleanups.push(() => {
|
|
684
|
+
clearInterval(timer);
|
|
685
|
+
document.removeEventListener("visibilitychange", visibility);
|
|
686
|
+
window.removeEventListener("pagehide", pagehide);
|
|
687
|
+
window.removeEventListener("storage", storage);
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
return client;
|
|
691
|
+
}
|
|
692
|
+
class PermanentDeliveryError extends Error {
|
|
693
|
+
}
|
|
694
|
+
//# sourceMappingURL=index.js.map
|