@tracelog/capture-web 1.0.0 → 1.2.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 +14 -0
- package/README.md +19 -5
- package/dist/tracelog.esm.js +408 -17
- package/dist/tracelog.iife.js +408 -17
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,20 @@ patch, and neither requires reading your code.
|
|
|
18
18
|
Entries after 1.0.0 are drafted from the commits that touched this package and
|
|
19
19
|
edited before release.
|
|
20
20
|
|
|
21
|
+
## [1.2.0](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.1.0...capture-web@1.2.0) (2026-09-22)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
* **capture-web:** read Meta's form and report its beacon as unread ([bd78f98](https://github.com/nacorga/tracelog-sdk/commit/bd78f98a9c478b863399d9e86090b31a906743c5))
|
|
27
|
+
|
|
28
|
+
## [1.1.0](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.0.0...capture-web@1.1.0) (2026-09-21)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
* **capture-web:** read the page's tag requests through Resource Timing ([3de9b5c](https://github.com/nacorga/tracelog-sdk/commit/3de9b5ccea21ed82f198b9885c1d6143f7ca71c1))
|
|
34
|
+
|
|
21
35
|
## 1.0.0
|
|
22
36
|
|
|
23
37
|
The first published runtime.
|
package/README.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# @tracelog/capture-web
|
|
2
2
|
|
|
3
3
|
The TraceLog browser capture runtime. It captures the conversion path a project
|
|
4
|
-
declares — each conversion and the steps preceding it — and
|
|
4
|
+
declares — each conversion and the steps preceding it — and, beside each
|
|
5
|
+
conversion, which GA4, Meta and Google Ads tags the page requested; nothing
|
|
6
|
+
else.
|
|
5
7
|
|
|
6
8
|
TraceLog is Trustworthy Conversion Intelligence — conversion intelligence you
|
|
7
9
|
can verify: it verifies the conversion path a project declares against the
|
|
@@ -12,20 +14,21 @@ incomplete, and never makes up a number.
|
|
|
12
14
|
|
|
13
15
|
## Install
|
|
14
16
|
|
|
15
|
-
**Every integration names a version.**
|
|
16
|
-
|
|
17
|
+
**Every integration names a version, exactly.** Install with `--save-exact`,
|
|
18
|
+
so your manifest names this version rather than a range, and there is no
|
|
19
|
+
`v/latest/` on the CDN: the version you pin is the bytes you get, for as long
|
|
17
20
|
as they are served.
|
|
18
21
|
|
|
19
22
|
<!-- x-release-please-start-version -->
|
|
20
23
|
|
|
21
24
|
```bash
|
|
22
|
-
npm install @tracelog/capture-web@1.
|
|
25
|
+
npm install --save-exact @tracelog/capture-web@1.2.0
|
|
23
26
|
```
|
|
24
27
|
|
|
25
28
|
Without a build step, the same runtime as a script tag:
|
|
26
29
|
|
|
27
30
|
```html
|
|
28
|
-
<script src="https://cdn.tracelog.io/v/1.
|
|
31
|
+
<script src="https://cdn.tracelog.io/v/1.2.0/tracelog.js"></script>
|
|
29
32
|
```
|
|
30
33
|
|
|
31
34
|
<!-- x-release-please-end -->
|
|
@@ -86,6 +89,17 @@ The events your tracking plan declares, with their context. Not page views, not
|
|
|
86
89
|
clicks, not scroll, not keystrokes. Errors are captured only when they occur
|
|
87
90
|
inside the conversion path, attached to the step where they happened.
|
|
88
91
|
|
|
92
|
+
Beside each conversion, which GA4, Meta and Google Ads tags the page requested
|
|
93
|
+
just before and after it — the tag's kind, its id and, for Meta, the event —
|
|
94
|
+
read from the browser's own record of the page's requests and, for the one
|
|
95
|
+
Meta request that record leaves out, from the two fields of the form Meta's
|
|
96
|
+
script adds to the page that name the pixel and the event. Nothing else of
|
|
97
|
+
those requests is kept. A Meta request whose pixel the page does not show is
|
|
98
|
+
reported as Meta, unread. A conversion is held ten seconds so those requests can
|
|
99
|
+
be seen, sent at once when the page is hidden — its report then says whether it
|
|
100
|
+
was cut short — and never held in verification mode. Context keys beginning
|
|
101
|
+
with `__tl.` are TraceLog's and are removed.
|
|
102
|
+
|
|
89
103
|
Identity is first-party and per site: no cross-site tracking, no fingerprinting.
|
|
90
104
|
An IP address is read once when the event arrives to derive a two-letter country
|
|
91
105
|
code, then discarded.
|
package/dist/tracelog.esm.js
CHANGED
|
@@ -3,10 +3,17 @@ const MAX_BATCH_BYTES = 262144;
|
|
|
3
3
|
const MAX_BATCH_EVENTS = 50;
|
|
4
4
|
const MAX_CONTEXT_BYTES = 8192;
|
|
5
5
|
const MAX_ERROR_MESSAGE_BYTES = 1024;
|
|
6
|
+
const MAX_TAG_SIGHTINGS = 16;
|
|
7
|
+
const TAG_SIGHTINGS_CONTEXT_KEY = "__tl.tags";
|
|
8
|
+
const TAG_ID_PATTERNS = {"ga4":"^G-[A-Z0-9]{4,15}$","meta":"^[0-9]{6,20}$","google_ads":"^AW-[0-9]{6,15}(?:/[A-Za-z0-9_-]{1,64})?$"};
|
|
9
|
+
const TAG_EVENT_PATTERN = "^[A-Za-z0-9_]{1,64}$";
|
|
10
|
+
const tagSightingKinds = ["ga4","meta","google_ads"];
|
|
6
11
|
const CONSENT_KEY = "__tl.c";
|
|
7
12
|
const SESSION_KEY = "__tl.s";
|
|
8
13
|
const ACTIVITY_KEY = "__tl.a";
|
|
9
14
|
const QUEUE_KEY = "__tl.q";
|
|
15
|
+
/** The namespace the runtime owns, in storage and in an event's context. */
|
|
16
|
+
const RUNTIME_KEY_PREFIX = "__tl.";
|
|
10
17
|
/**
|
|
11
18
|
* How many events wait in memory before consent. A platform artifact that
|
|
12
19
|
* buffers its own platform's events ahead of this engine holds the same line,
|
|
@@ -30,6 +37,13 @@ const PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
|
30
37
|
const MAX_IDENTIFIER_LENGTH = 256;
|
|
31
38
|
const CURRENCY_PATTERN = /^[A-Z]{3}$/;
|
|
32
39
|
const textEncoder = new TextEncoder();
|
|
40
|
+
/**
|
|
41
|
+
* The tag sighting window around a conversion ([spec/capture.md] § Tag
|
|
42
|
+
* sightings). Ten seconds after is almost twice the longest hold measured:
|
|
43
|
+
* GA4 was measured holding a purchase 5.0 to 5.6 seconds.
|
|
44
|
+
*/
|
|
45
|
+
const TAG_SIGHTING_BEFORE_MS = 30000;
|
|
46
|
+
const TAG_SIGHTING_AFTER_MS = 10000;
|
|
33
47
|
function safeGet(storage, key) {
|
|
34
48
|
try {
|
|
35
49
|
return storage.getItem(key);
|
|
@@ -120,7 +134,12 @@ function jsonContext(value) {
|
|
|
120
134
|
Array.isArray(parsed)) {
|
|
121
135
|
return undefined;
|
|
122
136
|
}
|
|
123
|
-
|
|
137
|
+
const context = parsed;
|
|
138
|
+
for (const key of Object.keys(context)) {
|
|
139
|
+
if (key.startsWith(RUNTIME_KEY_PREFIX))
|
|
140
|
+
delete context[key];
|
|
141
|
+
}
|
|
142
|
+
return context;
|
|
124
143
|
}
|
|
125
144
|
catch {
|
|
126
145
|
return undefined;
|
|
@@ -149,6 +168,9 @@ function isConversionValid(options) {
|
|
|
149
168
|
(Number.isFinite(options.value) && options.value >= 0)) &&
|
|
150
169
|
(options.currency === undefined || CURRENCY_PATTERN.test(options.currency)));
|
|
151
170
|
}
|
|
171
|
+
function serializedBytes(value) {
|
|
172
|
+
return textEncoder.encode(JSON.stringify(value)).byteLength;
|
|
173
|
+
}
|
|
152
174
|
function batchFrom(events) {
|
|
153
175
|
const selected = [];
|
|
154
176
|
const unsendable = [];
|
|
@@ -159,7 +181,7 @@ function batchFrom(events) {
|
|
|
159
181
|
v: EVENT_ENVELOPE_VERSION,
|
|
160
182
|
events: [...selected, event],
|
|
161
183
|
};
|
|
162
|
-
if (
|
|
184
|
+
if (serializedBytes(candidate) > MAX_BATCH_BYTES) {
|
|
163
185
|
if (selected.length === 0) {
|
|
164
186
|
unsendable.push(event);
|
|
165
187
|
continue;
|
|
@@ -176,7 +198,20 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
176
198
|
let config = { key: "", endpoint: "/v1/events" };
|
|
177
199
|
let pending = [];
|
|
178
200
|
let lastDeclaredName;
|
|
179
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Every send in flight, by count and by the union of their event ids: a
|
|
203
|
+
* page hide sends alongside one rather than wait for it, so there can be
|
|
204
|
+
* two, and each must leave the other's events alone.
|
|
205
|
+
*/
|
|
206
|
+
let sendsInFlight = 0;
|
|
207
|
+
const inFlight = new Set();
|
|
208
|
+
/**
|
|
209
|
+
* The conversions this page emitted and holds for their tag sighting
|
|
210
|
+
* window: the instant each was emitted, and whether its report can be
|
|
211
|
+
* complete. In memory only — a held conversion is in the stored queue like
|
|
212
|
+
* any other, and a reload sends it as it is.
|
|
213
|
+
*/
|
|
214
|
+
const held = new Map();
|
|
180
215
|
let flushTimer;
|
|
181
216
|
let consecutiveFailures = 0;
|
|
182
217
|
let circuit = "closed";
|
|
@@ -224,7 +259,12 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
224
259
|
addDrop(queue, "invalid_event");
|
|
225
260
|
writeQueue(ports.storage, queue);
|
|
226
261
|
}
|
|
227
|
-
|
|
262
|
+
/**
|
|
263
|
+
* `buffered` is a conversion made before consent was granted and emitted
|
|
264
|
+
* at the grant: its window is placed there, after the conversion itself,
|
|
265
|
+
* so its report is never complete.
|
|
266
|
+
*/
|
|
267
|
+
function emit(pendingEvent, buffered = false) {
|
|
228
268
|
if (!EVENT_NAME_PATTERN.test(pendingEvent.name)) {
|
|
229
269
|
recordInvalidEvent();
|
|
230
270
|
return;
|
|
@@ -261,8 +301,13 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
261
301
|
else {
|
|
262
302
|
const options = pendingEvent.options;
|
|
263
303
|
const context = jsonContext(options.context);
|
|
304
|
+
const base = baseEvent(now, session.id);
|
|
305
|
+
if (acquisition.mode !== "verification" &&
|
|
306
|
+
ports.sightings?.observing() === true) {
|
|
307
|
+
held.set(base.eventId, { at: now.getTime(), whole: !buffered });
|
|
308
|
+
}
|
|
264
309
|
persistEvent({
|
|
265
|
-
...
|
|
310
|
+
...base,
|
|
266
311
|
kind: "conversion",
|
|
267
312
|
name: pendingEvent.name,
|
|
268
313
|
identifier: options.identifier,
|
|
@@ -285,12 +330,82 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
285
330
|
}
|
|
286
331
|
emit(pendingEvent);
|
|
287
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Gives each held conversion whose window has closed — or every one, at
|
|
335
|
+
* page hide — the report of what the port saw around it, and lets it go.
|
|
336
|
+
*/
|
|
337
|
+
function release(all) {
|
|
338
|
+
if (held.size === 0)
|
|
339
|
+
return;
|
|
340
|
+
const now = ports.clock.now().getTime();
|
|
341
|
+
const due = [...held].filter(([, hold]) => all || now - hold.at >= TAG_SIGHTING_AFTER_MS);
|
|
342
|
+
if (due.length === 0)
|
|
343
|
+
return;
|
|
344
|
+
const queue = readQueue(ports.storage);
|
|
345
|
+
let reported = false;
|
|
346
|
+
for (const [eventId, hold] of due) {
|
|
347
|
+
held.delete(eventId);
|
|
348
|
+
// Gone from the queue — sent by another tab, or dropped by overflow.
|
|
349
|
+
const event = queue.events.find((candidate) => candidate.eventId === eventId);
|
|
350
|
+
const answer = event === undefined
|
|
351
|
+
? null
|
|
352
|
+
: (ports.sightings?.around(new Date(hold.at)) ?? null);
|
|
353
|
+
if (event === undefined || answer === null)
|
|
354
|
+
continue;
|
|
355
|
+
const unread = ports.sightings?.unreadAround?.(new Date(hold.at)) ?? null;
|
|
356
|
+
const report = {
|
|
357
|
+
complete: hold.whole &&
|
|
358
|
+
now - hold.at >= TAG_SIGHTING_AFTER_MS &&
|
|
359
|
+
answer.length <= MAX_TAG_SIGHTINGS,
|
|
360
|
+
sightings: answer.slice(0, MAX_TAG_SIGHTINGS),
|
|
361
|
+
...(unread === null ? {} : { unread }),
|
|
362
|
+
};
|
|
363
|
+
const context = {
|
|
364
|
+
...event.context,
|
|
365
|
+
[TAG_SIGHTINGS_CONTEXT_KEY]: report,
|
|
366
|
+
};
|
|
367
|
+
if (serializedBytes(context) <= MAX_CONTEXT_BYTES) {
|
|
368
|
+
event.context = context;
|
|
369
|
+
reported = true;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (reported)
|
|
373
|
+
writeQueue(ports.storage, queue);
|
|
374
|
+
}
|
|
375
|
+
/** Until the earliest held conversion's window closes. */
|
|
376
|
+
function untilRelease() {
|
|
377
|
+
const now = ports.clock.now().getTime();
|
|
378
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
379
|
+
for (const hold of held.values()) {
|
|
380
|
+
earliest = Math.min(earliest, hold.at + TAG_SIGHTING_AFTER_MS);
|
|
381
|
+
}
|
|
382
|
+
return Math.max(0, earliest - now);
|
|
383
|
+
}
|
|
288
384
|
async function flush(keepalive = false) {
|
|
289
|
-
|
|
385
|
+
release(keepalive);
|
|
386
|
+
if (!initialized || consentState !== "granted")
|
|
387
|
+
return;
|
|
388
|
+
if (sendsInFlight > 0 && !keepalive)
|
|
290
389
|
return;
|
|
291
390
|
const queue = readQueue(ports.storage);
|
|
292
|
-
|
|
293
|
-
|
|
391
|
+
const ready = queue.events.filter((event) => !held.has(event.eventId) && !inFlight.has(event.eventId));
|
|
392
|
+
if (ready.length === 0 || !PUBLIC_KEY_PATTERN.test(config.key)) {
|
|
393
|
+
if (sendsInFlight === 0) {
|
|
394
|
+
scheduleFlush(ready.length === 0 && held.size > 0
|
|
395
|
+
? untilRelease()
|
|
396
|
+
: FLUSH_INTERVAL_MS);
|
|
397
|
+
}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* A page being hidden gets no later chance, so it sends what no send in
|
|
402
|
+
* flight carries at once, whatever the circuit's state, and the answer is
|
|
403
|
+
* read as any other's.
|
|
404
|
+
*/
|
|
405
|
+
if (sendsInFlight > 0) {
|
|
406
|
+
const { batch } = batchFrom(ready);
|
|
407
|
+
if (batch.events.length > 0)
|
|
408
|
+
await deliver(batch, true);
|
|
294
409
|
return;
|
|
295
410
|
}
|
|
296
411
|
const now = ports.clock.now().getTime();
|
|
@@ -301,7 +416,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
301
416
|
}
|
|
302
417
|
circuit = "half_open";
|
|
303
418
|
}
|
|
304
|
-
const { batch, unsendable } = batchFrom(
|
|
419
|
+
const { batch, unsendable } = batchFrom(ready);
|
|
305
420
|
if (unsendable.length > 0) {
|
|
306
421
|
const dropped = new Set(unsendable.map((event) => event.eventId));
|
|
307
422
|
queue.events = queue.events.filter((event) => !dropped.has(event.eventId));
|
|
@@ -312,7 +427,18 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
312
427
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
313
428
|
return;
|
|
314
429
|
}
|
|
315
|
-
|
|
430
|
+
await deliver(batch, keepalive);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* One send, and its answer. It removes from the stored queue exactly what
|
|
434
|
+
* it carried, by its own read, filter and write at the moment it settles,
|
|
435
|
+
* so a send beside it keeps what it delivered.
|
|
436
|
+
*/
|
|
437
|
+
async function deliver(batch, keepalive) {
|
|
438
|
+
const carried = new Set(batch.events.map((event) => event.eventId));
|
|
439
|
+
for (const eventId of carried)
|
|
440
|
+
inFlight.add(eventId);
|
|
441
|
+
sendsInFlight += 1;
|
|
316
442
|
try {
|
|
317
443
|
const response = await ports.transport.send({
|
|
318
444
|
endpoint: config.endpoint,
|
|
@@ -322,8 +448,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
322
448
|
});
|
|
323
449
|
if (response.status >= 200 && response.status < 300) {
|
|
324
450
|
const currentQueue = readQueue(ports.storage);
|
|
325
|
-
|
|
326
|
-
currentQueue.events = currentQueue.events.filter((event) => !delivered.has(event.eventId));
|
|
451
|
+
currentQueue.events = currentQueue.events.filter((event) => !carried.has(event.eventId));
|
|
327
452
|
if ((response.rejected ?? 0) > 0) {
|
|
328
453
|
addDrop(currentQueue, "server_rejected", response.rejected);
|
|
329
454
|
}
|
|
@@ -345,8 +470,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
345
470
|
response.status < 500 &&
|
|
346
471
|
response.status !== 429) {
|
|
347
472
|
const currentQueue = readQueue(ports.storage);
|
|
348
|
-
|
|
349
|
-
currentQueue.events = currentQueue.events.filter((event) => !rejected.has(event.eventId));
|
|
473
|
+
currentQueue.events = currentQueue.events.filter((event) => !carried.has(event.eventId));
|
|
350
474
|
addDrop(currentQueue, "server_rejected", batch.events.length);
|
|
351
475
|
writeQueue(ports.storage, currentQueue);
|
|
352
476
|
consecutiveFailures = 0;
|
|
@@ -369,7 +493,9 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
369
493
|
}
|
|
370
494
|
}
|
|
371
495
|
finally {
|
|
372
|
-
|
|
496
|
+
for (const eventId of carried)
|
|
497
|
+
inFlight.delete(eventId);
|
|
498
|
+
sendsInFlight -= 1;
|
|
373
499
|
}
|
|
374
500
|
}
|
|
375
501
|
const engine = {
|
|
@@ -384,8 +510,10 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
384
510
|
storedConsent === "granted" || storedConsent === "denied"
|
|
385
511
|
? storedConsent
|
|
386
512
|
: "unknown";
|
|
387
|
-
if (consentState === "granted")
|
|
513
|
+
if (consentState === "granted") {
|
|
514
|
+
ports.sightings?.start();
|
|
388
515
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
516
|
+
}
|
|
389
517
|
},
|
|
390
518
|
consent: {
|
|
391
519
|
grant() {
|
|
@@ -393,10 +521,11 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
393
521
|
return;
|
|
394
522
|
consentState = "granted";
|
|
395
523
|
safeSet(ports.storage, CONSENT_KEY, "granted");
|
|
524
|
+
ports.sightings?.start();
|
|
396
525
|
const buffered = pending;
|
|
397
526
|
pending = [];
|
|
398
527
|
for (const pendingEvent of buffered)
|
|
399
|
-
emit(pendingEvent);
|
|
528
|
+
emit(pendingEvent, true);
|
|
400
529
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
401
530
|
},
|
|
402
531
|
deny() {
|
|
@@ -405,6 +534,8 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
405
534
|
consentState = "denied";
|
|
406
535
|
pending = [];
|
|
407
536
|
lastDeclaredName = undefined;
|
|
537
|
+
ports.sightings?.stop();
|
|
538
|
+
held.clear();
|
|
408
539
|
safeSet(ports.storage, CONSENT_KEY, "denied");
|
|
409
540
|
safeRemove(ports.storage, SESSION_KEY);
|
|
410
541
|
safeRemove(ports.storage, ACTIVITY_KEY);
|
|
@@ -480,6 +611,265 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
480
611
|
const systemClock = {
|
|
481
612
|
now: () => new Date(Date.now()),
|
|
482
613
|
};
|
|
614
|
+
/**
|
|
615
|
+
* A Resource Timing entry's start, on the system clock: its reading minus the
|
|
616
|
+
* entry's monotonic age. `performance.timeOrigin` would be a second clock, and
|
|
617
|
+
* it can disagree with this one by as much as the machine's clock drifted
|
|
618
|
+
* since the page loaded; the age is read on one clock and the instant on the
|
|
619
|
+
* other, so a sighting and the conversion it is compared with are dated alike
|
|
620
|
+
* ([spec/capture.md] § Tag sightings).
|
|
621
|
+
*/
|
|
622
|
+
function instantOfEntry(startTime) {
|
|
623
|
+
return new Date(systemClock.now().getTime() - (performance.now() - startTime));
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* How many matches the port keeps. A page making more tag requests than this
|
|
627
|
+
* inside one window is one whose report the port declines to give, never one
|
|
628
|
+
* it gives short.
|
|
629
|
+
*/
|
|
630
|
+
const TAG_SIGHTING_MEMORY = 100;
|
|
631
|
+
const ga4IdPattern = new RegExp(TAG_ID_PATTERNS.ga4);
|
|
632
|
+
const metaIdPattern = new RegExp(TAG_ID_PATTERNS.meta);
|
|
633
|
+
const adsIdPattern = new RegExp(TAG_ID_PATTERNS.google_ads);
|
|
634
|
+
const tagEventPattern = new RegExp(TAG_EVENT_PATTERN);
|
|
635
|
+
const adsPathPattern = /^\/pagead\/(?:conversion|viewthroughconversion|1p-conversion)\/([0-9]{6,15})\/$/;
|
|
636
|
+
function onMetaEndpoint(url) {
|
|
637
|
+
const host = url.hostname;
|
|
638
|
+
return ((host === "facebook.com" || host.endsWith(".facebook.com")) &&
|
|
639
|
+
(url.pathname === "/tr" || url.pathname === "/tr/"));
|
|
640
|
+
}
|
|
641
|
+
/** A request on Meta's endpoint whose pixel id does not fit is Meta, unread. */
|
|
642
|
+
function metaMatch(id, event) {
|
|
643
|
+
return id !== null && metaIdPattern.test(id)
|
|
644
|
+
? {
|
|
645
|
+
sighting: {
|
|
646
|
+
kind: "meta",
|
|
647
|
+
id,
|
|
648
|
+
event: event !== null && tagEventPattern.test(event) ? event : null,
|
|
649
|
+
},
|
|
650
|
+
}
|
|
651
|
+
: { unread: "meta" };
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* The matching table ([spec/capture.md] § Tag sightings): GA4 and Google Ads
|
|
655
|
+
* key on the path and the id, so a regional host and a first-party tagging
|
|
656
|
+
* domain that keeps the path both match; Meta keys on its host as well. What
|
|
657
|
+
* is answered is the tag's kind, its id and Meta's event — nothing else of
|
|
658
|
+
* the URL, which carries the page address and the vendor's client id. Meta's
|
|
659
|
+
* beacon leaves an entry named by its bare endpoint, which names no pixel:
|
|
660
|
+
* that is Meta, unread.
|
|
661
|
+
*/
|
|
662
|
+
function matchTagRequest(url) {
|
|
663
|
+
let parsed;
|
|
664
|
+
try {
|
|
665
|
+
parsed = new URL(url);
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
const path = parsed.pathname;
|
|
671
|
+
const parameters = parsed.searchParams;
|
|
672
|
+
if (path.endsWith("/g/collect")) {
|
|
673
|
+
const id = parameters.get("tid");
|
|
674
|
+
return id !== null && ga4IdPattern.test(id)
|
|
675
|
+
? { sighting: { kind: "ga4", id, event: null } }
|
|
676
|
+
: null;
|
|
677
|
+
}
|
|
678
|
+
if (onMetaEndpoint(parsed)) {
|
|
679
|
+
return metaMatch(parameters.get("id"), parameters.get("ev"));
|
|
680
|
+
}
|
|
681
|
+
const ads = adsPathPattern.exec(path);
|
|
682
|
+
if (ads !== null) {
|
|
683
|
+
const id = `AW-${ads[1]}`;
|
|
684
|
+
const labelled = `${id}/${parameters.get("label") ?? ""}`;
|
|
685
|
+
return {
|
|
686
|
+
sighting: {
|
|
687
|
+
kind: "google_ads",
|
|
688
|
+
id: adsIdPattern.test(labelled) ? labelled : id,
|
|
689
|
+
event: null,
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Meta's long request in Chrome: a form posted into a hidden frame, which
|
|
697
|
+
* leaves no Resource Timing entry ([spec/capture.md] § Tag sightings, _Meta's
|
|
698
|
+
* long request_). Two fields are asked for, the ones that name the pixel and
|
|
699
|
+
* the event, and nothing else of the form is read: its other fields carry the
|
|
700
|
+
* visitor's identifiers, hashed.
|
|
701
|
+
*/
|
|
702
|
+
function matchTagForm(form) {
|
|
703
|
+
let action;
|
|
704
|
+
try {
|
|
705
|
+
action = new URL(form.action);
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
if (!onMetaEndpoint(action))
|
|
711
|
+
return null;
|
|
712
|
+
const id = form.querySelector('input[name="id"]');
|
|
713
|
+
const event = form.querySelector('input[name="ev"]');
|
|
714
|
+
return metaMatch(id?.value ?? null, event?.value ?? null);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The browser's own record of the page's requests, read through a buffered
|
|
718
|
+
* `PerformanceObserver` once consent is granted and never before — and, for
|
|
719
|
+
* the one request that record leaves out, the forms the page's body gains,
|
|
720
|
+
* read through a `MutationObserver` on the body's own children. Nothing is
|
|
721
|
+
* wrapped, patched or replaced — no `fetch`, no beacon, no vendor's global —
|
|
722
|
+
* because the runtime runs on somebody else's page and nothing it does may
|
|
723
|
+
* change what another script sees.
|
|
724
|
+
*/
|
|
725
|
+
function createTagSightingPort() {
|
|
726
|
+
let observer;
|
|
727
|
+
let bodyObserver;
|
|
728
|
+
/**
|
|
729
|
+
* From when the body's children were watched, on the system clock: minus
|
|
730
|
+
* infinity when observation began before the body existed, and so saw every
|
|
731
|
+
* form a script added to it; undefined while no body observer runs — never
|
|
732
|
+
* created, still waiting for the body, or failed.
|
|
733
|
+
*/
|
|
734
|
+
let watchedFrom;
|
|
735
|
+
/** Ordered by `at`, oldest first; at most `TAG_SIGHTING_MEMORY`. */
|
|
736
|
+
let matches = [];
|
|
737
|
+
function remember(match, at) {
|
|
738
|
+
if (match === null)
|
|
739
|
+
return;
|
|
740
|
+
let index = matches.length;
|
|
741
|
+
while (index > 0 && matches[index - 1].at > at)
|
|
742
|
+
index -= 1;
|
|
743
|
+
matches.splice(index, 0, { match, at });
|
|
744
|
+
if (matches.length > TAG_SIGHTING_MEMORY)
|
|
745
|
+
matches.shift();
|
|
746
|
+
}
|
|
747
|
+
function record(entries) {
|
|
748
|
+
for (const entry of entries) {
|
|
749
|
+
remember(matchTagRequest(entry.name), instantOfEntry(entry.startTime).getTime());
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* One observer, on the body's children and nothing below them. Before the
|
|
754
|
+
* body exists it watches the root's children for the body, and moves to it.
|
|
755
|
+
*/
|
|
756
|
+
function watchBody() {
|
|
757
|
+
try {
|
|
758
|
+
const document = window.document;
|
|
759
|
+
let body = document.body;
|
|
760
|
+
const created = new window.MutationObserver((records) => {
|
|
761
|
+
try {
|
|
762
|
+
for (const change of records) {
|
|
763
|
+
const added = change.addedNodes;
|
|
764
|
+
for (let index = 0; index < added.length; index += 1) {
|
|
765
|
+
const node = added[index];
|
|
766
|
+
if (change.target === body) {
|
|
767
|
+
if (node.nodeName === "FORM") {
|
|
768
|
+
remember(matchTagForm(node), systemClock.now().getTime());
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
else if (body === null && node.nodeName === "BODY") {
|
|
772
|
+
body = node;
|
|
773
|
+
created.disconnect();
|
|
774
|
+
created.observe(node, { childList: true });
|
|
775
|
+
watchedFrom = Number.NEGATIVE_INFINITY;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
// Capture must never break the host page.
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
if (body === null) {
|
|
785
|
+
created.observe(document.documentElement, { childList: true });
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
created.observe(body, { childList: true });
|
|
789
|
+
watchedFrom = systemClock.now().getTime();
|
|
790
|
+
}
|
|
791
|
+
bodyObserver = created;
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
watchedFrom = undefined;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/** What was matched inside the window around `at`, or null. */
|
|
798
|
+
function within(at) {
|
|
799
|
+
if (observer === undefined)
|
|
800
|
+
return null;
|
|
801
|
+
// What the browser recorded and has not delivered yet: a page being
|
|
802
|
+
// hidden may not run the callback before the conversion leaves.
|
|
803
|
+
record(observer.takeRecords());
|
|
804
|
+
const from = at.getTime() - TAG_SIGHTING_BEFORE_MS;
|
|
805
|
+
const to = at.getTime() + TAG_SIGHTING_AFTER_MS;
|
|
806
|
+
if (matches.length >= TAG_SIGHTING_MEMORY && matches[0].at > from) {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
return matches
|
|
810
|
+
.filter(({ at: started }) => started >= from && started <= to)
|
|
811
|
+
.map(({ match }) => match);
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
start() {
|
|
815
|
+
if (observer !== undefined)
|
|
816
|
+
return;
|
|
817
|
+
try {
|
|
818
|
+
const Observer = window.PerformanceObserver;
|
|
819
|
+
if (Observer === undefined ||
|
|
820
|
+
!Observer.supportedEntryTypes.includes("resource")) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const created = new Observer((list) => record(list.getEntries()));
|
|
824
|
+
created.observe({ type: "resource", buffered: true });
|
|
825
|
+
observer = created;
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
observer = undefined;
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
watchBody();
|
|
832
|
+
},
|
|
833
|
+
stop() {
|
|
834
|
+
observer?.disconnect();
|
|
835
|
+
observer = undefined;
|
|
836
|
+
bodyObserver?.disconnect();
|
|
837
|
+
bodyObserver = undefined;
|
|
838
|
+
watchedFrom = undefined;
|
|
839
|
+
matches = [];
|
|
840
|
+
},
|
|
841
|
+
observing() {
|
|
842
|
+
return observer !== undefined;
|
|
843
|
+
},
|
|
844
|
+
around(at) {
|
|
845
|
+
const found = within(at);
|
|
846
|
+
if (found === null)
|
|
847
|
+
return null;
|
|
848
|
+
const seen = new Set();
|
|
849
|
+
const sightings = [];
|
|
850
|
+
for (const match of found) {
|
|
851
|
+
if (!("sighting" in match))
|
|
852
|
+
continue;
|
|
853
|
+
const { sighting } = match;
|
|
854
|
+
const key = `${sighting.kind} ${sighting.id} ${sighting.event ?? ""}`;
|
|
855
|
+
if (seen.has(key))
|
|
856
|
+
continue;
|
|
857
|
+
seen.add(key);
|
|
858
|
+
sightings.push(sighting);
|
|
859
|
+
}
|
|
860
|
+
return sightings;
|
|
861
|
+
},
|
|
862
|
+
unreadAround(at) {
|
|
863
|
+
const found = within(at);
|
|
864
|
+
if (found === null ||
|
|
865
|
+
watchedFrom === undefined ||
|
|
866
|
+
watchedFrom > at.getTime() - TAG_SIGHTING_BEFORE_MS) {
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
return tagSightingKinds.filter((kind) => found.some((match) => "unread" in match && match.unread === kind));
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
}
|
|
483
873
|
const WEB_PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
484
874
|
class MemoryStorage {
|
|
485
875
|
constructor() {
|
|
@@ -656,6 +1046,7 @@ function init(options) {
|
|
|
656
1046
|
}
|
|
657
1047
|
},
|
|
658
1048
|
},
|
|
1049
|
+
sightings: createTagSightingPort(),
|
|
659
1050
|
}, acquisition());
|
|
660
1051
|
engine = runtime.engine;
|
|
661
1052
|
}
|
package/dist/tracelog.iife.js
CHANGED
|
@@ -4,10 +4,17 @@ const MAX_BATCH_BYTES = 262144;
|
|
|
4
4
|
const MAX_BATCH_EVENTS = 50;
|
|
5
5
|
const MAX_CONTEXT_BYTES = 8192;
|
|
6
6
|
const MAX_ERROR_MESSAGE_BYTES = 1024;
|
|
7
|
+
const MAX_TAG_SIGHTINGS = 16;
|
|
8
|
+
const TAG_SIGHTINGS_CONTEXT_KEY = "__tl.tags";
|
|
9
|
+
const TAG_ID_PATTERNS = {"ga4":"^G-[A-Z0-9]{4,15}$","meta":"^[0-9]{6,20}$","google_ads":"^AW-[0-9]{6,15}(?:/[A-Za-z0-9_-]{1,64})?$"};
|
|
10
|
+
const TAG_EVENT_PATTERN = "^[A-Za-z0-9_]{1,64}$";
|
|
11
|
+
const tagSightingKinds = ["ga4","meta","google_ads"];
|
|
7
12
|
const CONSENT_KEY = "__tl.c";
|
|
8
13
|
const SESSION_KEY = "__tl.s";
|
|
9
14
|
const ACTIVITY_KEY = "__tl.a";
|
|
10
15
|
const QUEUE_KEY = "__tl.q";
|
|
16
|
+
/** The namespace the runtime owns, in storage and in an event's context. */
|
|
17
|
+
const RUNTIME_KEY_PREFIX = "__tl.";
|
|
11
18
|
/**
|
|
12
19
|
* How many events wait in memory before consent. A platform artifact that
|
|
13
20
|
* buffers its own platform's events ahead of this engine holds the same line,
|
|
@@ -31,6 +38,13 @@ const PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
|
31
38
|
const MAX_IDENTIFIER_LENGTH = 256;
|
|
32
39
|
const CURRENCY_PATTERN = /^[A-Z]{3}$/;
|
|
33
40
|
const textEncoder = new TextEncoder();
|
|
41
|
+
/**
|
|
42
|
+
* The tag sighting window around a conversion ([spec/capture.md] § Tag
|
|
43
|
+
* sightings). Ten seconds after is almost twice the longest hold measured:
|
|
44
|
+
* GA4 was measured holding a purchase 5.0 to 5.6 seconds.
|
|
45
|
+
*/
|
|
46
|
+
const TAG_SIGHTING_BEFORE_MS = 30000;
|
|
47
|
+
const TAG_SIGHTING_AFTER_MS = 10000;
|
|
34
48
|
function safeGet(storage, key) {
|
|
35
49
|
try {
|
|
36
50
|
return storage.getItem(key);
|
|
@@ -121,7 +135,12 @@ function jsonContext(value) {
|
|
|
121
135
|
Array.isArray(parsed)) {
|
|
122
136
|
return undefined;
|
|
123
137
|
}
|
|
124
|
-
|
|
138
|
+
const context = parsed;
|
|
139
|
+
for (const key of Object.keys(context)) {
|
|
140
|
+
if (key.startsWith(RUNTIME_KEY_PREFIX))
|
|
141
|
+
delete context[key];
|
|
142
|
+
}
|
|
143
|
+
return context;
|
|
125
144
|
}
|
|
126
145
|
catch {
|
|
127
146
|
return undefined;
|
|
@@ -150,6 +169,9 @@ function isConversionValid(options) {
|
|
|
150
169
|
(Number.isFinite(options.value) && options.value >= 0)) &&
|
|
151
170
|
(options.currency === undefined || CURRENCY_PATTERN.test(options.currency)));
|
|
152
171
|
}
|
|
172
|
+
function serializedBytes(value) {
|
|
173
|
+
return textEncoder.encode(JSON.stringify(value)).byteLength;
|
|
174
|
+
}
|
|
153
175
|
function batchFrom(events) {
|
|
154
176
|
const selected = [];
|
|
155
177
|
const unsendable = [];
|
|
@@ -160,7 +182,7 @@ function batchFrom(events) {
|
|
|
160
182
|
v: EVENT_ENVELOPE_VERSION,
|
|
161
183
|
events: [...selected, event],
|
|
162
184
|
};
|
|
163
|
-
if (
|
|
185
|
+
if (serializedBytes(candidate) > MAX_BATCH_BYTES) {
|
|
164
186
|
if (selected.length === 0) {
|
|
165
187
|
unsendable.push(event);
|
|
166
188
|
continue;
|
|
@@ -177,7 +199,20 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
177
199
|
let config = { key: "", endpoint: "/v1/events" };
|
|
178
200
|
let pending = [];
|
|
179
201
|
let lastDeclaredName;
|
|
180
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Every send in flight, by count and by the union of their event ids: a
|
|
204
|
+
* page hide sends alongside one rather than wait for it, so there can be
|
|
205
|
+
* two, and each must leave the other's events alone.
|
|
206
|
+
*/
|
|
207
|
+
let sendsInFlight = 0;
|
|
208
|
+
const inFlight = new Set();
|
|
209
|
+
/**
|
|
210
|
+
* The conversions this page emitted and holds for their tag sighting
|
|
211
|
+
* window: the instant each was emitted, and whether its report can be
|
|
212
|
+
* complete. In memory only — a held conversion is in the stored queue like
|
|
213
|
+
* any other, and a reload sends it as it is.
|
|
214
|
+
*/
|
|
215
|
+
const held = new Map();
|
|
181
216
|
let flushTimer;
|
|
182
217
|
let consecutiveFailures = 0;
|
|
183
218
|
let circuit = "closed";
|
|
@@ -225,7 +260,12 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
225
260
|
addDrop(queue, "invalid_event");
|
|
226
261
|
writeQueue(ports.storage, queue);
|
|
227
262
|
}
|
|
228
|
-
|
|
263
|
+
/**
|
|
264
|
+
* `buffered` is a conversion made before consent was granted and emitted
|
|
265
|
+
* at the grant: its window is placed there, after the conversion itself,
|
|
266
|
+
* so its report is never complete.
|
|
267
|
+
*/
|
|
268
|
+
function emit(pendingEvent, buffered = false) {
|
|
229
269
|
if (!EVENT_NAME_PATTERN.test(pendingEvent.name)) {
|
|
230
270
|
recordInvalidEvent();
|
|
231
271
|
return;
|
|
@@ -262,8 +302,13 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
262
302
|
else {
|
|
263
303
|
const options = pendingEvent.options;
|
|
264
304
|
const context = jsonContext(options.context);
|
|
305
|
+
const base = baseEvent(now, session.id);
|
|
306
|
+
if (acquisition.mode !== "verification" &&
|
|
307
|
+
ports.sightings?.observing() === true) {
|
|
308
|
+
held.set(base.eventId, { at: now.getTime(), whole: !buffered });
|
|
309
|
+
}
|
|
265
310
|
persistEvent({
|
|
266
|
-
...
|
|
311
|
+
...base,
|
|
267
312
|
kind: "conversion",
|
|
268
313
|
name: pendingEvent.name,
|
|
269
314
|
identifier: options.identifier,
|
|
@@ -286,12 +331,82 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
286
331
|
}
|
|
287
332
|
emit(pendingEvent);
|
|
288
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Gives each held conversion whose window has closed — or every one, at
|
|
336
|
+
* page hide — the report of what the port saw around it, and lets it go.
|
|
337
|
+
*/
|
|
338
|
+
function release(all) {
|
|
339
|
+
if (held.size === 0)
|
|
340
|
+
return;
|
|
341
|
+
const now = ports.clock.now().getTime();
|
|
342
|
+
const due = [...held].filter(([, hold]) => all || now - hold.at >= TAG_SIGHTING_AFTER_MS);
|
|
343
|
+
if (due.length === 0)
|
|
344
|
+
return;
|
|
345
|
+
const queue = readQueue(ports.storage);
|
|
346
|
+
let reported = false;
|
|
347
|
+
for (const [eventId, hold] of due) {
|
|
348
|
+
held.delete(eventId);
|
|
349
|
+
// Gone from the queue — sent by another tab, or dropped by overflow.
|
|
350
|
+
const event = queue.events.find((candidate) => candidate.eventId === eventId);
|
|
351
|
+
const answer = event === undefined
|
|
352
|
+
? null
|
|
353
|
+
: (ports.sightings?.around(new Date(hold.at)) ?? null);
|
|
354
|
+
if (event === undefined || answer === null)
|
|
355
|
+
continue;
|
|
356
|
+
const unread = ports.sightings?.unreadAround?.(new Date(hold.at)) ?? null;
|
|
357
|
+
const report = {
|
|
358
|
+
complete: hold.whole &&
|
|
359
|
+
now - hold.at >= TAG_SIGHTING_AFTER_MS &&
|
|
360
|
+
answer.length <= MAX_TAG_SIGHTINGS,
|
|
361
|
+
sightings: answer.slice(0, MAX_TAG_SIGHTINGS),
|
|
362
|
+
...(unread === null ? {} : { unread }),
|
|
363
|
+
};
|
|
364
|
+
const context = {
|
|
365
|
+
...event.context,
|
|
366
|
+
[TAG_SIGHTINGS_CONTEXT_KEY]: report,
|
|
367
|
+
};
|
|
368
|
+
if (serializedBytes(context) <= MAX_CONTEXT_BYTES) {
|
|
369
|
+
event.context = context;
|
|
370
|
+
reported = true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (reported)
|
|
374
|
+
writeQueue(ports.storage, queue);
|
|
375
|
+
}
|
|
376
|
+
/** Until the earliest held conversion's window closes. */
|
|
377
|
+
function untilRelease() {
|
|
378
|
+
const now = ports.clock.now().getTime();
|
|
379
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
380
|
+
for (const hold of held.values()) {
|
|
381
|
+
earliest = Math.min(earliest, hold.at + TAG_SIGHTING_AFTER_MS);
|
|
382
|
+
}
|
|
383
|
+
return Math.max(0, earliest - now);
|
|
384
|
+
}
|
|
289
385
|
async function flush(keepalive = false) {
|
|
290
|
-
|
|
386
|
+
release(keepalive);
|
|
387
|
+
if (!initialized || consentState !== "granted")
|
|
388
|
+
return;
|
|
389
|
+
if (sendsInFlight > 0 && !keepalive)
|
|
291
390
|
return;
|
|
292
391
|
const queue = readQueue(ports.storage);
|
|
293
|
-
|
|
294
|
-
|
|
392
|
+
const ready = queue.events.filter((event) => !held.has(event.eventId) && !inFlight.has(event.eventId));
|
|
393
|
+
if (ready.length === 0 || !PUBLIC_KEY_PATTERN.test(config.key)) {
|
|
394
|
+
if (sendsInFlight === 0) {
|
|
395
|
+
scheduleFlush(ready.length === 0 && held.size > 0
|
|
396
|
+
? untilRelease()
|
|
397
|
+
: FLUSH_INTERVAL_MS);
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* A page being hidden gets no later chance, so it sends what no send in
|
|
403
|
+
* flight carries at once, whatever the circuit's state, and the answer is
|
|
404
|
+
* read as any other's.
|
|
405
|
+
*/
|
|
406
|
+
if (sendsInFlight > 0) {
|
|
407
|
+
const { batch } = batchFrom(ready);
|
|
408
|
+
if (batch.events.length > 0)
|
|
409
|
+
await deliver(batch, true);
|
|
295
410
|
return;
|
|
296
411
|
}
|
|
297
412
|
const now = ports.clock.now().getTime();
|
|
@@ -302,7 +417,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
302
417
|
}
|
|
303
418
|
circuit = "half_open";
|
|
304
419
|
}
|
|
305
|
-
const { batch, unsendable } = batchFrom(
|
|
420
|
+
const { batch, unsendable } = batchFrom(ready);
|
|
306
421
|
if (unsendable.length > 0) {
|
|
307
422
|
const dropped = new Set(unsendable.map((event) => event.eventId));
|
|
308
423
|
queue.events = queue.events.filter((event) => !dropped.has(event.eventId));
|
|
@@ -313,7 +428,18 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
313
428
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
314
429
|
return;
|
|
315
430
|
}
|
|
316
|
-
|
|
431
|
+
await deliver(batch, keepalive);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* One send, and its answer. It removes from the stored queue exactly what
|
|
435
|
+
* it carried, by its own read, filter and write at the moment it settles,
|
|
436
|
+
* so a send beside it keeps what it delivered.
|
|
437
|
+
*/
|
|
438
|
+
async function deliver(batch, keepalive) {
|
|
439
|
+
const carried = new Set(batch.events.map((event) => event.eventId));
|
|
440
|
+
for (const eventId of carried)
|
|
441
|
+
inFlight.add(eventId);
|
|
442
|
+
sendsInFlight += 1;
|
|
317
443
|
try {
|
|
318
444
|
const response = await ports.transport.send({
|
|
319
445
|
endpoint: config.endpoint,
|
|
@@ -323,8 +449,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
323
449
|
});
|
|
324
450
|
if (response.status >= 200 && response.status < 300) {
|
|
325
451
|
const currentQueue = readQueue(ports.storage);
|
|
326
|
-
|
|
327
|
-
currentQueue.events = currentQueue.events.filter((event) => !delivered.has(event.eventId));
|
|
452
|
+
currentQueue.events = currentQueue.events.filter((event) => !carried.has(event.eventId));
|
|
328
453
|
if ((response.rejected ?? 0) > 0) {
|
|
329
454
|
addDrop(currentQueue, "server_rejected", response.rejected);
|
|
330
455
|
}
|
|
@@ -346,8 +471,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
346
471
|
response.status < 500 &&
|
|
347
472
|
response.status !== 429) {
|
|
348
473
|
const currentQueue = readQueue(ports.storage);
|
|
349
|
-
|
|
350
|
-
currentQueue.events = currentQueue.events.filter((event) => !rejected.has(event.eventId));
|
|
474
|
+
currentQueue.events = currentQueue.events.filter((event) => !carried.has(event.eventId));
|
|
351
475
|
addDrop(currentQueue, "server_rejected", batch.events.length);
|
|
352
476
|
writeQueue(ports.storage, currentQueue);
|
|
353
477
|
consecutiveFailures = 0;
|
|
@@ -370,7 +494,9 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
370
494
|
}
|
|
371
495
|
}
|
|
372
496
|
finally {
|
|
373
|
-
|
|
497
|
+
for (const eventId of carried)
|
|
498
|
+
inFlight.delete(eventId);
|
|
499
|
+
sendsInFlight -= 1;
|
|
374
500
|
}
|
|
375
501
|
}
|
|
376
502
|
const engine = {
|
|
@@ -385,8 +511,10 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
385
511
|
storedConsent === "granted" || storedConsent === "denied"
|
|
386
512
|
? storedConsent
|
|
387
513
|
: "unknown";
|
|
388
|
-
if (consentState === "granted")
|
|
514
|
+
if (consentState === "granted") {
|
|
515
|
+
ports.sightings?.start();
|
|
389
516
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
517
|
+
}
|
|
390
518
|
},
|
|
391
519
|
consent: {
|
|
392
520
|
grant() {
|
|
@@ -394,10 +522,11 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
394
522
|
return;
|
|
395
523
|
consentState = "granted";
|
|
396
524
|
safeSet(ports.storage, CONSENT_KEY, "granted");
|
|
525
|
+
ports.sightings?.start();
|
|
397
526
|
const buffered = pending;
|
|
398
527
|
pending = [];
|
|
399
528
|
for (const pendingEvent of buffered)
|
|
400
|
-
emit(pendingEvent);
|
|
529
|
+
emit(pendingEvent, true);
|
|
401
530
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
402
531
|
},
|
|
403
532
|
deny() {
|
|
@@ -406,6 +535,8 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
406
535
|
consentState = "denied";
|
|
407
536
|
pending = [];
|
|
408
537
|
lastDeclaredName = undefined;
|
|
538
|
+
ports.sightings?.stop();
|
|
539
|
+
held.clear();
|
|
409
540
|
safeSet(ports.storage, CONSENT_KEY, "denied");
|
|
410
541
|
safeRemove(ports.storage, SESSION_KEY);
|
|
411
542
|
safeRemove(ports.storage, ACTIVITY_KEY);
|
|
@@ -481,6 +612,265 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
481
612
|
const systemClock = {
|
|
482
613
|
now: () => new Date(Date.now()),
|
|
483
614
|
};
|
|
615
|
+
/**
|
|
616
|
+
* A Resource Timing entry's start, on the system clock: its reading minus the
|
|
617
|
+
* entry's monotonic age. `performance.timeOrigin` would be a second clock, and
|
|
618
|
+
* it can disagree with this one by as much as the machine's clock drifted
|
|
619
|
+
* since the page loaded; the age is read on one clock and the instant on the
|
|
620
|
+
* other, so a sighting and the conversion it is compared with are dated alike
|
|
621
|
+
* ([spec/capture.md] § Tag sightings).
|
|
622
|
+
*/
|
|
623
|
+
function instantOfEntry(startTime) {
|
|
624
|
+
return new Date(systemClock.now().getTime() - (performance.now() - startTime));
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* How many matches the port keeps. A page making more tag requests than this
|
|
628
|
+
* inside one window is one whose report the port declines to give, never one
|
|
629
|
+
* it gives short.
|
|
630
|
+
*/
|
|
631
|
+
const TAG_SIGHTING_MEMORY = 100;
|
|
632
|
+
const ga4IdPattern = new RegExp(TAG_ID_PATTERNS.ga4);
|
|
633
|
+
const metaIdPattern = new RegExp(TAG_ID_PATTERNS.meta);
|
|
634
|
+
const adsIdPattern = new RegExp(TAG_ID_PATTERNS.google_ads);
|
|
635
|
+
const tagEventPattern = new RegExp(TAG_EVENT_PATTERN);
|
|
636
|
+
const adsPathPattern = /^\/pagead\/(?:conversion|viewthroughconversion|1p-conversion)\/([0-9]{6,15})\/$/;
|
|
637
|
+
function onMetaEndpoint(url) {
|
|
638
|
+
const host = url.hostname;
|
|
639
|
+
return ((host === "facebook.com" || host.endsWith(".facebook.com")) &&
|
|
640
|
+
(url.pathname === "/tr" || url.pathname === "/tr/"));
|
|
641
|
+
}
|
|
642
|
+
/** A request on Meta's endpoint whose pixel id does not fit is Meta, unread. */
|
|
643
|
+
function metaMatch(id, event) {
|
|
644
|
+
return id !== null && metaIdPattern.test(id)
|
|
645
|
+
? {
|
|
646
|
+
sighting: {
|
|
647
|
+
kind: "meta",
|
|
648
|
+
id,
|
|
649
|
+
event: event !== null && tagEventPattern.test(event) ? event : null,
|
|
650
|
+
},
|
|
651
|
+
}
|
|
652
|
+
: { unread: "meta" };
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The matching table ([spec/capture.md] § Tag sightings): GA4 and Google Ads
|
|
656
|
+
* key on the path and the id, so a regional host and a first-party tagging
|
|
657
|
+
* domain that keeps the path both match; Meta keys on its host as well. What
|
|
658
|
+
* is answered is the tag's kind, its id and Meta's event — nothing else of
|
|
659
|
+
* the URL, which carries the page address and the vendor's client id. Meta's
|
|
660
|
+
* beacon leaves an entry named by its bare endpoint, which names no pixel:
|
|
661
|
+
* that is Meta, unread.
|
|
662
|
+
*/
|
|
663
|
+
function matchTagRequest(url) {
|
|
664
|
+
let parsed;
|
|
665
|
+
try {
|
|
666
|
+
parsed = new URL(url);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
const path = parsed.pathname;
|
|
672
|
+
const parameters = parsed.searchParams;
|
|
673
|
+
if (path.endsWith("/g/collect")) {
|
|
674
|
+
const id = parameters.get("tid");
|
|
675
|
+
return id !== null && ga4IdPattern.test(id)
|
|
676
|
+
? { sighting: { kind: "ga4", id, event: null } }
|
|
677
|
+
: null;
|
|
678
|
+
}
|
|
679
|
+
if (onMetaEndpoint(parsed)) {
|
|
680
|
+
return metaMatch(parameters.get("id"), parameters.get("ev"));
|
|
681
|
+
}
|
|
682
|
+
const ads = adsPathPattern.exec(path);
|
|
683
|
+
if (ads !== null) {
|
|
684
|
+
const id = `AW-${ads[1]}`;
|
|
685
|
+
const labelled = `${id}/${parameters.get("label") ?? ""}`;
|
|
686
|
+
return {
|
|
687
|
+
sighting: {
|
|
688
|
+
kind: "google_ads",
|
|
689
|
+
id: adsIdPattern.test(labelled) ? labelled : id,
|
|
690
|
+
event: null,
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Meta's long request in Chrome: a form posted into a hidden frame, which
|
|
698
|
+
* leaves no Resource Timing entry ([spec/capture.md] § Tag sightings, _Meta's
|
|
699
|
+
* long request_). Two fields are asked for, the ones that name the pixel and
|
|
700
|
+
* the event, and nothing else of the form is read: its other fields carry the
|
|
701
|
+
* visitor's identifiers, hashed.
|
|
702
|
+
*/
|
|
703
|
+
function matchTagForm(form) {
|
|
704
|
+
let action;
|
|
705
|
+
try {
|
|
706
|
+
action = new URL(form.action);
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
if (!onMetaEndpoint(action))
|
|
712
|
+
return null;
|
|
713
|
+
const id = form.querySelector('input[name="id"]');
|
|
714
|
+
const event = form.querySelector('input[name="ev"]');
|
|
715
|
+
return metaMatch(id?.value ?? null, event?.value ?? null);
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* The browser's own record of the page's requests, read through a buffered
|
|
719
|
+
* `PerformanceObserver` once consent is granted and never before — and, for
|
|
720
|
+
* the one request that record leaves out, the forms the page's body gains,
|
|
721
|
+
* read through a `MutationObserver` on the body's own children. Nothing is
|
|
722
|
+
* wrapped, patched or replaced — no `fetch`, no beacon, no vendor's global —
|
|
723
|
+
* because the runtime runs on somebody else's page and nothing it does may
|
|
724
|
+
* change what another script sees.
|
|
725
|
+
*/
|
|
726
|
+
function createTagSightingPort() {
|
|
727
|
+
let observer;
|
|
728
|
+
let bodyObserver;
|
|
729
|
+
/**
|
|
730
|
+
* From when the body's children were watched, on the system clock: minus
|
|
731
|
+
* infinity when observation began before the body existed, and so saw every
|
|
732
|
+
* form a script added to it; undefined while no body observer runs — never
|
|
733
|
+
* created, still waiting for the body, or failed.
|
|
734
|
+
*/
|
|
735
|
+
let watchedFrom;
|
|
736
|
+
/** Ordered by `at`, oldest first; at most `TAG_SIGHTING_MEMORY`. */
|
|
737
|
+
let matches = [];
|
|
738
|
+
function remember(match, at) {
|
|
739
|
+
if (match === null)
|
|
740
|
+
return;
|
|
741
|
+
let index = matches.length;
|
|
742
|
+
while (index > 0 && matches[index - 1].at > at)
|
|
743
|
+
index -= 1;
|
|
744
|
+
matches.splice(index, 0, { match, at });
|
|
745
|
+
if (matches.length > TAG_SIGHTING_MEMORY)
|
|
746
|
+
matches.shift();
|
|
747
|
+
}
|
|
748
|
+
function record(entries) {
|
|
749
|
+
for (const entry of entries) {
|
|
750
|
+
remember(matchTagRequest(entry.name), instantOfEntry(entry.startTime).getTime());
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* One observer, on the body's children and nothing below them. Before the
|
|
755
|
+
* body exists it watches the root's children for the body, and moves to it.
|
|
756
|
+
*/
|
|
757
|
+
function watchBody() {
|
|
758
|
+
try {
|
|
759
|
+
const document = window.document;
|
|
760
|
+
let body = document.body;
|
|
761
|
+
const created = new window.MutationObserver((records) => {
|
|
762
|
+
try {
|
|
763
|
+
for (const change of records) {
|
|
764
|
+
const added = change.addedNodes;
|
|
765
|
+
for (let index = 0; index < added.length; index += 1) {
|
|
766
|
+
const node = added[index];
|
|
767
|
+
if (change.target === body) {
|
|
768
|
+
if (node.nodeName === "FORM") {
|
|
769
|
+
remember(matchTagForm(node), systemClock.now().getTime());
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
else if (body === null && node.nodeName === "BODY") {
|
|
773
|
+
body = node;
|
|
774
|
+
created.disconnect();
|
|
775
|
+
created.observe(node, { childList: true });
|
|
776
|
+
watchedFrom = Number.NEGATIVE_INFINITY;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
// Capture must never break the host page.
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
if (body === null) {
|
|
786
|
+
created.observe(document.documentElement, { childList: true });
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
created.observe(body, { childList: true });
|
|
790
|
+
watchedFrom = systemClock.now().getTime();
|
|
791
|
+
}
|
|
792
|
+
bodyObserver = created;
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
watchedFrom = undefined;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/** What was matched inside the window around `at`, or null. */
|
|
799
|
+
function within(at) {
|
|
800
|
+
if (observer === undefined)
|
|
801
|
+
return null;
|
|
802
|
+
// What the browser recorded and has not delivered yet: a page being
|
|
803
|
+
// hidden may not run the callback before the conversion leaves.
|
|
804
|
+
record(observer.takeRecords());
|
|
805
|
+
const from = at.getTime() - TAG_SIGHTING_BEFORE_MS;
|
|
806
|
+
const to = at.getTime() + TAG_SIGHTING_AFTER_MS;
|
|
807
|
+
if (matches.length >= TAG_SIGHTING_MEMORY && matches[0].at > from) {
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
return matches
|
|
811
|
+
.filter(({ at: started }) => started >= from && started <= to)
|
|
812
|
+
.map(({ match }) => match);
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
start() {
|
|
816
|
+
if (observer !== undefined)
|
|
817
|
+
return;
|
|
818
|
+
try {
|
|
819
|
+
const Observer = window.PerformanceObserver;
|
|
820
|
+
if (Observer === undefined ||
|
|
821
|
+
!Observer.supportedEntryTypes.includes("resource")) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const created = new Observer((list) => record(list.getEntries()));
|
|
825
|
+
created.observe({ type: "resource", buffered: true });
|
|
826
|
+
observer = created;
|
|
827
|
+
}
|
|
828
|
+
catch {
|
|
829
|
+
observer = undefined;
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
watchBody();
|
|
833
|
+
},
|
|
834
|
+
stop() {
|
|
835
|
+
observer?.disconnect();
|
|
836
|
+
observer = undefined;
|
|
837
|
+
bodyObserver?.disconnect();
|
|
838
|
+
bodyObserver = undefined;
|
|
839
|
+
watchedFrom = undefined;
|
|
840
|
+
matches = [];
|
|
841
|
+
},
|
|
842
|
+
observing() {
|
|
843
|
+
return observer !== undefined;
|
|
844
|
+
},
|
|
845
|
+
around(at) {
|
|
846
|
+
const found = within(at);
|
|
847
|
+
if (found === null)
|
|
848
|
+
return null;
|
|
849
|
+
const seen = new Set();
|
|
850
|
+
const sightings = [];
|
|
851
|
+
for (const match of found) {
|
|
852
|
+
if (!("sighting" in match))
|
|
853
|
+
continue;
|
|
854
|
+
const { sighting } = match;
|
|
855
|
+
const key = `${sighting.kind} ${sighting.id} ${sighting.event ?? ""}`;
|
|
856
|
+
if (seen.has(key))
|
|
857
|
+
continue;
|
|
858
|
+
seen.add(key);
|
|
859
|
+
sightings.push(sighting);
|
|
860
|
+
}
|
|
861
|
+
return sightings;
|
|
862
|
+
},
|
|
863
|
+
unreadAround(at) {
|
|
864
|
+
const found = within(at);
|
|
865
|
+
if (found === null ||
|
|
866
|
+
watchedFrom === undefined ||
|
|
867
|
+
watchedFrom > at.getTime() - TAG_SIGHTING_BEFORE_MS) {
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
return tagSightingKinds.filter((kind) => found.some((match) => "unread" in match && match.unread === kind));
|
|
871
|
+
},
|
|
872
|
+
};
|
|
873
|
+
}
|
|
484
874
|
const WEB_PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
485
875
|
class MemoryStorage {
|
|
486
876
|
constructor() {
|
|
@@ -657,6 +1047,7 @@ function init(options) {
|
|
|
657
1047
|
}
|
|
658
1048
|
},
|
|
659
1049
|
},
|
|
1050
|
+
sightings: createTagSightingPort(),
|
|
660
1051
|
}, acquisition());
|
|
661
1052
|
engine = runtime.engine;
|
|
662
1053
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tracelog/capture-web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "The TraceLog browser capture runtime: consent-first, pinned per version.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"conversion",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@tracelog/capture-core": "1.
|
|
48
|
+
"@tracelog/capture-core": "1.2.0",
|
|
49
|
+
"@tracelog/event-contract": "1.2.0"
|
|
49
50
|
},
|
|
50
51
|
"scripts": {
|
|
51
52
|
"build": "tsc -p tsconfig.build.json && node build.mjs",
|