@tracelog/capture-web 1.0.0 → 1.1.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 +7 -0
- package/README.md +16 -5
- package/dist/tracelog.esm.js +293 -17
- package/dist/tracelog.iife.js +293 -17
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,13 @@ 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.1.0](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.0.0...capture-web@1.1.0) (2026-09-21)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
* **capture-web:** read the page's tag requests through Resource Timing ([3de9b5c](https://github.com/nacorga/tracelog-sdk/commit/3de9b5ccea21ed82f198b9885c1d6143f7ca71c1))
|
|
27
|
+
|
|
21
28
|
## 1.0.0
|
|
22
29
|
|
|
23
30
|
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.1.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.1.0/tracelog.js"></script>
|
|
29
32
|
```
|
|
30
33
|
|
|
31
34
|
<!-- x-release-please-end -->
|
|
@@ -86,6 +89,14 @@ 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. Nothing else of
|
|
95
|
+
those requests is kept. A conversion is held ten seconds so those requests can
|
|
96
|
+
be seen, sent at once when the page is hidden — its report then says whether it
|
|
97
|
+
was cut short — and never held in verification mode. Context keys beginning
|
|
98
|
+
with `__tl.` are TraceLog's and are removed.
|
|
99
|
+
|
|
89
100
|
Identity is first-party and per site: no cross-site tracking, no fingerprinting.
|
|
90
101
|
An IP address is read once when the event arrives to derive a two-letter country
|
|
91
102
|
code, then discarded.
|
package/dist/tracelog.esm.js
CHANGED
|
@@ -3,10 +3,16 @@ 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}$";
|
|
6
10
|
const CONSENT_KEY = "__tl.c";
|
|
7
11
|
const SESSION_KEY = "__tl.s";
|
|
8
12
|
const ACTIVITY_KEY = "__tl.a";
|
|
9
13
|
const QUEUE_KEY = "__tl.q";
|
|
14
|
+
/** The namespace the runtime owns, in storage and in an event's context. */
|
|
15
|
+
const RUNTIME_KEY_PREFIX = "__tl.";
|
|
10
16
|
/**
|
|
11
17
|
* How many events wait in memory before consent. A platform artifact that
|
|
12
18
|
* buffers its own platform's events ahead of this engine holds the same line,
|
|
@@ -30,6 +36,13 @@ const PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
|
30
36
|
const MAX_IDENTIFIER_LENGTH = 256;
|
|
31
37
|
const CURRENCY_PATTERN = /^[A-Z]{3}$/;
|
|
32
38
|
const textEncoder = new TextEncoder();
|
|
39
|
+
/**
|
|
40
|
+
* The tag sighting window around a conversion ([spec/capture.md] § Tag
|
|
41
|
+
* sightings). Ten seconds after is almost twice the longest hold measured:
|
|
42
|
+
* GA4 was measured holding a purchase 5.0 to 5.6 seconds.
|
|
43
|
+
*/
|
|
44
|
+
const TAG_SIGHTING_BEFORE_MS = 30000;
|
|
45
|
+
const TAG_SIGHTING_AFTER_MS = 10000;
|
|
33
46
|
function safeGet(storage, key) {
|
|
34
47
|
try {
|
|
35
48
|
return storage.getItem(key);
|
|
@@ -120,7 +133,12 @@ function jsonContext(value) {
|
|
|
120
133
|
Array.isArray(parsed)) {
|
|
121
134
|
return undefined;
|
|
122
135
|
}
|
|
123
|
-
|
|
136
|
+
const context = parsed;
|
|
137
|
+
for (const key of Object.keys(context)) {
|
|
138
|
+
if (key.startsWith(RUNTIME_KEY_PREFIX))
|
|
139
|
+
delete context[key];
|
|
140
|
+
}
|
|
141
|
+
return context;
|
|
124
142
|
}
|
|
125
143
|
catch {
|
|
126
144
|
return undefined;
|
|
@@ -149,6 +167,9 @@ function isConversionValid(options) {
|
|
|
149
167
|
(Number.isFinite(options.value) && options.value >= 0)) &&
|
|
150
168
|
(options.currency === undefined || CURRENCY_PATTERN.test(options.currency)));
|
|
151
169
|
}
|
|
170
|
+
function serializedBytes(value) {
|
|
171
|
+
return textEncoder.encode(JSON.stringify(value)).byteLength;
|
|
172
|
+
}
|
|
152
173
|
function batchFrom(events) {
|
|
153
174
|
const selected = [];
|
|
154
175
|
const unsendable = [];
|
|
@@ -159,7 +180,7 @@ function batchFrom(events) {
|
|
|
159
180
|
v: EVENT_ENVELOPE_VERSION,
|
|
160
181
|
events: [...selected, event],
|
|
161
182
|
};
|
|
162
|
-
if (
|
|
183
|
+
if (serializedBytes(candidate) > MAX_BATCH_BYTES) {
|
|
163
184
|
if (selected.length === 0) {
|
|
164
185
|
unsendable.push(event);
|
|
165
186
|
continue;
|
|
@@ -176,7 +197,20 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
176
197
|
let config = { key: "", endpoint: "/v1/events" };
|
|
177
198
|
let pending = [];
|
|
178
199
|
let lastDeclaredName;
|
|
179
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Every send in flight, by count and by the union of their event ids: a
|
|
202
|
+
* page hide sends alongside one rather than wait for it, so there can be
|
|
203
|
+
* two, and each must leave the other's events alone.
|
|
204
|
+
*/
|
|
205
|
+
let sendsInFlight = 0;
|
|
206
|
+
const inFlight = new Set();
|
|
207
|
+
/**
|
|
208
|
+
* The conversions this page emitted and holds for their tag sighting
|
|
209
|
+
* window: the instant each was emitted, and whether its report can be
|
|
210
|
+
* complete. In memory only — a held conversion is in the stored queue like
|
|
211
|
+
* any other, and a reload sends it as it is.
|
|
212
|
+
*/
|
|
213
|
+
const held = new Map();
|
|
180
214
|
let flushTimer;
|
|
181
215
|
let consecutiveFailures = 0;
|
|
182
216
|
let circuit = "closed";
|
|
@@ -224,7 +258,12 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
224
258
|
addDrop(queue, "invalid_event");
|
|
225
259
|
writeQueue(ports.storage, queue);
|
|
226
260
|
}
|
|
227
|
-
|
|
261
|
+
/**
|
|
262
|
+
* `buffered` is a conversion made before consent was granted and emitted
|
|
263
|
+
* at the grant: its window is placed there, after the conversion itself,
|
|
264
|
+
* so its report is never complete.
|
|
265
|
+
*/
|
|
266
|
+
function emit(pendingEvent, buffered = false) {
|
|
228
267
|
if (!EVENT_NAME_PATTERN.test(pendingEvent.name)) {
|
|
229
268
|
recordInvalidEvent();
|
|
230
269
|
return;
|
|
@@ -261,8 +300,13 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
261
300
|
else {
|
|
262
301
|
const options = pendingEvent.options;
|
|
263
302
|
const context = jsonContext(options.context);
|
|
303
|
+
const base = baseEvent(now, session.id);
|
|
304
|
+
if (acquisition.mode !== "verification" &&
|
|
305
|
+
ports.sightings?.observing() === true) {
|
|
306
|
+
held.set(base.eventId, { at: now.getTime(), whole: !buffered });
|
|
307
|
+
}
|
|
264
308
|
persistEvent({
|
|
265
|
-
...
|
|
309
|
+
...base,
|
|
266
310
|
kind: "conversion",
|
|
267
311
|
name: pendingEvent.name,
|
|
268
312
|
identifier: options.identifier,
|
|
@@ -285,12 +329,80 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
285
329
|
}
|
|
286
330
|
emit(pendingEvent);
|
|
287
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Gives each held conversion whose window has closed — or every one, at
|
|
334
|
+
* page hide — the report of what the port saw around it, and lets it go.
|
|
335
|
+
*/
|
|
336
|
+
function release(all) {
|
|
337
|
+
if (held.size === 0)
|
|
338
|
+
return;
|
|
339
|
+
const now = ports.clock.now().getTime();
|
|
340
|
+
const due = [...held].filter(([, hold]) => all || now - hold.at >= TAG_SIGHTING_AFTER_MS);
|
|
341
|
+
if (due.length === 0)
|
|
342
|
+
return;
|
|
343
|
+
const queue = readQueue(ports.storage);
|
|
344
|
+
let reported = false;
|
|
345
|
+
for (const [eventId, hold] of due) {
|
|
346
|
+
held.delete(eventId);
|
|
347
|
+
// Gone from the queue — sent by another tab, or dropped by overflow.
|
|
348
|
+
const event = queue.events.find((candidate) => candidate.eventId === eventId);
|
|
349
|
+
const answer = event === undefined
|
|
350
|
+
? null
|
|
351
|
+
: (ports.sightings?.around(new Date(hold.at)) ?? null);
|
|
352
|
+
if (event === undefined || answer === null)
|
|
353
|
+
continue;
|
|
354
|
+
const report = {
|
|
355
|
+
complete: hold.whole &&
|
|
356
|
+
now - hold.at >= TAG_SIGHTING_AFTER_MS &&
|
|
357
|
+
answer.length <= MAX_TAG_SIGHTINGS,
|
|
358
|
+
sightings: answer.slice(0, MAX_TAG_SIGHTINGS),
|
|
359
|
+
};
|
|
360
|
+
const context = {
|
|
361
|
+
...event.context,
|
|
362
|
+
[TAG_SIGHTINGS_CONTEXT_KEY]: report,
|
|
363
|
+
};
|
|
364
|
+
if (serializedBytes(context) <= MAX_CONTEXT_BYTES) {
|
|
365
|
+
event.context = context;
|
|
366
|
+
reported = true;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (reported)
|
|
370
|
+
writeQueue(ports.storage, queue);
|
|
371
|
+
}
|
|
372
|
+
/** Until the earliest held conversion's window closes. */
|
|
373
|
+
function untilRelease() {
|
|
374
|
+
const now = ports.clock.now().getTime();
|
|
375
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
376
|
+
for (const hold of held.values()) {
|
|
377
|
+
earliest = Math.min(earliest, hold.at + TAG_SIGHTING_AFTER_MS);
|
|
378
|
+
}
|
|
379
|
+
return Math.max(0, earliest - now);
|
|
380
|
+
}
|
|
288
381
|
async function flush(keepalive = false) {
|
|
289
|
-
|
|
382
|
+
release(keepalive);
|
|
383
|
+
if (!initialized || consentState !== "granted")
|
|
384
|
+
return;
|
|
385
|
+
if (sendsInFlight > 0 && !keepalive)
|
|
290
386
|
return;
|
|
291
387
|
const queue = readQueue(ports.storage);
|
|
292
|
-
|
|
293
|
-
|
|
388
|
+
const ready = queue.events.filter((event) => !held.has(event.eventId) && !inFlight.has(event.eventId));
|
|
389
|
+
if (ready.length === 0 || !PUBLIC_KEY_PATTERN.test(config.key)) {
|
|
390
|
+
if (sendsInFlight === 0) {
|
|
391
|
+
scheduleFlush(ready.length === 0 && held.size > 0
|
|
392
|
+
? untilRelease()
|
|
393
|
+
: FLUSH_INTERVAL_MS);
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* A page being hidden gets no later chance, so it sends what no send in
|
|
399
|
+
* flight carries at once, whatever the circuit's state, and the answer is
|
|
400
|
+
* read as any other's.
|
|
401
|
+
*/
|
|
402
|
+
if (sendsInFlight > 0) {
|
|
403
|
+
const { batch } = batchFrom(ready);
|
|
404
|
+
if (batch.events.length > 0)
|
|
405
|
+
await deliver(batch, true);
|
|
294
406
|
return;
|
|
295
407
|
}
|
|
296
408
|
const now = ports.clock.now().getTime();
|
|
@@ -301,7 +413,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
301
413
|
}
|
|
302
414
|
circuit = "half_open";
|
|
303
415
|
}
|
|
304
|
-
const { batch, unsendable } = batchFrom(
|
|
416
|
+
const { batch, unsendable } = batchFrom(ready);
|
|
305
417
|
if (unsendable.length > 0) {
|
|
306
418
|
const dropped = new Set(unsendable.map((event) => event.eventId));
|
|
307
419
|
queue.events = queue.events.filter((event) => !dropped.has(event.eventId));
|
|
@@ -312,7 +424,18 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
312
424
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
313
425
|
return;
|
|
314
426
|
}
|
|
315
|
-
|
|
427
|
+
await deliver(batch, keepalive);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* One send, and its answer. It removes from the stored queue exactly what
|
|
431
|
+
* it carried, by its own read, filter and write at the moment it settles,
|
|
432
|
+
* so a send beside it keeps what it delivered.
|
|
433
|
+
*/
|
|
434
|
+
async function deliver(batch, keepalive) {
|
|
435
|
+
const carried = new Set(batch.events.map((event) => event.eventId));
|
|
436
|
+
for (const eventId of carried)
|
|
437
|
+
inFlight.add(eventId);
|
|
438
|
+
sendsInFlight += 1;
|
|
316
439
|
try {
|
|
317
440
|
const response = await ports.transport.send({
|
|
318
441
|
endpoint: config.endpoint,
|
|
@@ -322,8 +445,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
322
445
|
});
|
|
323
446
|
if (response.status >= 200 && response.status < 300) {
|
|
324
447
|
const currentQueue = readQueue(ports.storage);
|
|
325
|
-
|
|
326
|
-
currentQueue.events = currentQueue.events.filter((event) => !delivered.has(event.eventId));
|
|
448
|
+
currentQueue.events = currentQueue.events.filter((event) => !carried.has(event.eventId));
|
|
327
449
|
if ((response.rejected ?? 0) > 0) {
|
|
328
450
|
addDrop(currentQueue, "server_rejected", response.rejected);
|
|
329
451
|
}
|
|
@@ -345,8 +467,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
345
467
|
response.status < 500 &&
|
|
346
468
|
response.status !== 429) {
|
|
347
469
|
const currentQueue = readQueue(ports.storage);
|
|
348
|
-
|
|
349
|
-
currentQueue.events = currentQueue.events.filter((event) => !rejected.has(event.eventId));
|
|
470
|
+
currentQueue.events = currentQueue.events.filter((event) => !carried.has(event.eventId));
|
|
350
471
|
addDrop(currentQueue, "server_rejected", batch.events.length);
|
|
351
472
|
writeQueue(ports.storage, currentQueue);
|
|
352
473
|
consecutiveFailures = 0;
|
|
@@ -369,7 +490,9 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
369
490
|
}
|
|
370
491
|
}
|
|
371
492
|
finally {
|
|
372
|
-
|
|
493
|
+
for (const eventId of carried)
|
|
494
|
+
inFlight.delete(eventId);
|
|
495
|
+
sendsInFlight -= 1;
|
|
373
496
|
}
|
|
374
497
|
}
|
|
375
498
|
const engine = {
|
|
@@ -384,8 +507,10 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
384
507
|
storedConsent === "granted" || storedConsent === "denied"
|
|
385
508
|
? storedConsent
|
|
386
509
|
: "unknown";
|
|
387
|
-
if (consentState === "granted")
|
|
510
|
+
if (consentState === "granted") {
|
|
511
|
+
ports.sightings?.start();
|
|
388
512
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
513
|
+
}
|
|
389
514
|
},
|
|
390
515
|
consent: {
|
|
391
516
|
grant() {
|
|
@@ -393,10 +518,11 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
393
518
|
return;
|
|
394
519
|
consentState = "granted";
|
|
395
520
|
safeSet(ports.storage, CONSENT_KEY, "granted");
|
|
521
|
+
ports.sightings?.start();
|
|
396
522
|
const buffered = pending;
|
|
397
523
|
pending = [];
|
|
398
524
|
for (const pendingEvent of buffered)
|
|
399
|
-
emit(pendingEvent);
|
|
525
|
+
emit(pendingEvent, true);
|
|
400
526
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
401
527
|
},
|
|
402
528
|
deny() {
|
|
@@ -405,6 +531,8 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
405
531
|
consentState = "denied";
|
|
406
532
|
pending = [];
|
|
407
533
|
lastDeclaredName = undefined;
|
|
534
|
+
ports.sightings?.stop();
|
|
535
|
+
held.clear();
|
|
408
536
|
safeSet(ports.storage, CONSENT_KEY, "denied");
|
|
409
537
|
safeRemove(ports.storage, SESSION_KEY);
|
|
410
538
|
safeRemove(ports.storage, ACTIVITY_KEY);
|
|
@@ -480,6 +608,153 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
480
608
|
const systemClock = {
|
|
481
609
|
now: () => new Date(Date.now()),
|
|
482
610
|
};
|
|
611
|
+
/**
|
|
612
|
+
* A Resource Timing entry's start, on the system clock: its reading minus the
|
|
613
|
+
* entry's monotonic age. `performance.timeOrigin` would be a second clock, and
|
|
614
|
+
* it can disagree with this one by as much as the machine's clock drifted
|
|
615
|
+
* since the page loaded; the age is read on one clock and the instant on the
|
|
616
|
+
* other, so a sighting and the conversion it is compared with are dated alike
|
|
617
|
+
* ([spec/capture.md] § Tag sightings).
|
|
618
|
+
*/
|
|
619
|
+
function instantOfEntry(startTime) {
|
|
620
|
+
return new Date(systemClock.now().getTime() - (performance.now() - startTime));
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* How many matches the port keeps. A page making more tag requests than this
|
|
624
|
+
* inside one window is one whose report the port declines to give, never one
|
|
625
|
+
* it gives short.
|
|
626
|
+
*/
|
|
627
|
+
const TAG_SIGHTING_MEMORY = 100;
|
|
628
|
+
const ga4IdPattern = new RegExp(TAG_ID_PATTERNS.ga4);
|
|
629
|
+
const metaIdPattern = new RegExp(TAG_ID_PATTERNS.meta);
|
|
630
|
+
const adsIdPattern = new RegExp(TAG_ID_PATTERNS.google_ads);
|
|
631
|
+
const tagEventPattern = new RegExp(TAG_EVENT_PATTERN);
|
|
632
|
+
const adsPathPattern = /^\/pagead\/(?:conversion|viewthroughconversion|1p-conversion)\/([0-9]{6,15})\/$/;
|
|
633
|
+
/**
|
|
634
|
+
* The matching table ([spec/capture.md] § Tag sightings): GA4 and Google Ads
|
|
635
|
+
* key on the path and the id, so a regional host and a first-party tagging
|
|
636
|
+
* domain that keeps the path both match; Meta keys on its host as well. What
|
|
637
|
+
* is answered is the tag's kind, its id and Meta's event — nothing else of
|
|
638
|
+
* the URL, which carries the page address and the vendor's client id.
|
|
639
|
+
*/
|
|
640
|
+
function matchTagRequest(url) {
|
|
641
|
+
let parsed;
|
|
642
|
+
try {
|
|
643
|
+
parsed = new URL(url);
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
const path = parsed.pathname;
|
|
649
|
+
const parameters = parsed.searchParams;
|
|
650
|
+
if (path.endsWith("/g/collect")) {
|
|
651
|
+
const id = parameters.get("tid");
|
|
652
|
+
return id !== null && ga4IdPattern.test(id)
|
|
653
|
+
? { kind: "ga4", id, event: null }
|
|
654
|
+
: null;
|
|
655
|
+
}
|
|
656
|
+
const host = parsed.hostname;
|
|
657
|
+
if ((host === "facebook.com" || host.endsWith(".facebook.com")) &&
|
|
658
|
+
(path === "/tr" || path === "/tr/")) {
|
|
659
|
+
const id = parameters.get("id");
|
|
660
|
+
if (id === null || !metaIdPattern.test(id))
|
|
661
|
+
return null;
|
|
662
|
+
const event = parameters.get("ev");
|
|
663
|
+
return {
|
|
664
|
+
kind: "meta",
|
|
665
|
+
id,
|
|
666
|
+
event: event !== null && tagEventPattern.test(event) ? event : null,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
const ads = adsPathPattern.exec(path);
|
|
670
|
+
if (ads !== null) {
|
|
671
|
+
const id = `AW-${ads[1]}`;
|
|
672
|
+
const labelled = `${id}/${parameters.get("label") ?? ""}`;
|
|
673
|
+
return {
|
|
674
|
+
kind: "google_ads",
|
|
675
|
+
id: adsIdPattern.test(labelled) ? labelled : id,
|
|
676
|
+
event: null,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* The browser's own record of the page's requests, read through a buffered
|
|
683
|
+
* `PerformanceObserver` once consent is granted and never before. Nothing is
|
|
684
|
+
* wrapped, patched or replaced — no `fetch`, no beacon, no vendor's global —
|
|
685
|
+
* because the runtime runs on somebody else's page and nothing it does may
|
|
686
|
+
* change what another script sees.
|
|
687
|
+
*/
|
|
688
|
+
function createTagSightingPort() {
|
|
689
|
+
let observer;
|
|
690
|
+
/** Ordered by `at`, oldest first; at most `TAG_SIGHTING_MEMORY`. */
|
|
691
|
+
let matches = [];
|
|
692
|
+
function record(entries) {
|
|
693
|
+
for (const entry of entries) {
|
|
694
|
+
const sighting = matchTagRequest(entry.name);
|
|
695
|
+
if (sighting === null)
|
|
696
|
+
continue;
|
|
697
|
+
const match = { sighting, at: instantOfEntry(entry.startTime).getTime() };
|
|
698
|
+
let index = matches.length;
|
|
699
|
+
while (index > 0 && matches[index - 1].at > match.at)
|
|
700
|
+
index -= 1;
|
|
701
|
+
matches.splice(index, 0, match);
|
|
702
|
+
if (matches.length > TAG_SIGHTING_MEMORY)
|
|
703
|
+
matches.shift();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
start() {
|
|
708
|
+
if (observer !== undefined)
|
|
709
|
+
return;
|
|
710
|
+
try {
|
|
711
|
+
const Observer = window.PerformanceObserver;
|
|
712
|
+
if (Observer === undefined ||
|
|
713
|
+
!Observer.supportedEntryTypes.includes("resource")) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const created = new Observer((list) => record(list.getEntries()));
|
|
717
|
+
created.observe({ type: "resource", buffered: true });
|
|
718
|
+
observer = created;
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
observer = undefined;
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
stop() {
|
|
725
|
+
observer?.disconnect();
|
|
726
|
+
observer = undefined;
|
|
727
|
+
matches = [];
|
|
728
|
+
},
|
|
729
|
+
observing() {
|
|
730
|
+
return observer !== undefined;
|
|
731
|
+
},
|
|
732
|
+
around(at) {
|
|
733
|
+
if (observer === undefined)
|
|
734
|
+
return null;
|
|
735
|
+
// What the browser recorded and has not delivered yet: a page being
|
|
736
|
+
// hidden may not run the callback before the conversion leaves.
|
|
737
|
+
record(observer.takeRecords());
|
|
738
|
+
const from = at.getTime() - TAG_SIGHTING_BEFORE_MS;
|
|
739
|
+
const to = at.getTime() + TAG_SIGHTING_AFTER_MS;
|
|
740
|
+
if (matches.length >= TAG_SIGHTING_MEMORY && matches[0].at > from) {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
const seen = new Set();
|
|
744
|
+
const sightings = [];
|
|
745
|
+
for (const { sighting, at: started } of matches) {
|
|
746
|
+
if (started < from || started > to)
|
|
747
|
+
continue;
|
|
748
|
+
const key = `${sighting.kind} ${sighting.id} ${sighting.event ?? ""}`;
|
|
749
|
+
if (seen.has(key))
|
|
750
|
+
continue;
|
|
751
|
+
seen.add(key);
|
|
752
|
+
sightings.push(sighting);
|
|
753
|
+
}
|
|
754
|
+
return sightings;
|
|
755
|
+
},
|
|
756
|
+
};
|
|
757
|
+
}
|
|
483
758
|
const WEB_PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
484
759
|
class MemoryStorage {
|
|
485
760
|
constructor() {
|
|
@@ -656,6 +931,7 @@ function init(options) {
|
|
|
656
931
|
}
|
|
657
932
|
},
|
|
658
933
|
},
|
|
934
|
+
sightings: createTagSightingPort(),
|
|
659
935
|
}, acquisition());
|
|
660
936
|
engine = runtime.engine;
|
|
661
937
|
}
|
package/dist/tracelog.iife.js
CHANGED
|
@@ -4,10 +4,16 @@ 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}$";
|
|
7
11
|
const CONSENT_KEY = "__tl.c";
|
|
8
12
|
const SESSION_KEY = "__tl.s";
|
|
9
13
|
const ACTIVITY_KEY = "__tl.a";
|
|
10
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.";
|
|
11
17
|
/**
|
|
12
18
|
* How many events wait in memory before consent. A platform artifact that
|
|
13
19
|
* buffers its own platform's events ahead of this engine holds the same line,
|
|
@@ -31,6 +37,13 @@ const PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
|
31
37
|
const MAX_IDENTIFIER_LENGTH = 256;
|
|
32
38
|
const CURRENCY_PATTERN = /^[A-Z]{3}$/;
|
|
33
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;
|
|
34
47
|
function safeGet(storage, key) {
|
|
35
48
|
try {
|
|
36
49
|
return storage.getItem(key);
|
|
@@ -121,7 +134,12 @@ function jsonContext(value) {
|
|
|
121
134
|
Array.isArray(parsed)) {
|
|
122
135
|
return undefined;
|
|
123
136
|
}
|
|
124
|
-
|
|
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;
|
|
125
143
|
}
|
|
126
144
|
catch {
|
|
127
145
|
return undefined;
|
|
@@ -150,6 +168,9 @@ function isConversionValid(options) {
|
|
|
150
168
|
(Number.isFinite(options.value) && options.value >= 0)) &&
|
|
151
169
|
(options.currency === undefined || CURRENCY_PATTERN.test(options.currency)));
|
|
152
170
|
}
|
|
171
|
+
function serializedBytes(value) {
|
|
172
|
+
return textEncoder.encode(JSON.stringify(value)).byteLength;
|
|
173
|
+
}
|
|
153
174
|
function batchFrom(events) {
|
|
154
175
|
const selected = [];
|
|
155
176
|
const unsendable = [];
|
|
@@ -160,7 +181,7 @@ function batchFrom(events) {
|
|
|
160
181
|
v: EVENT_ENVELOPE_VERSION,
|
|
161
182
|
events: [...selected, event],
|
|
162
183
|
};
|
|
163
|
-
if (
|
|
184
|
+
if (serializedBytes(candidate) > MAX_BATCH_BYTES) {
|
|
164
185
|
if (selected.length === 0) {
|
|
165
186
|
unsendable.push(event);
|
|
166
187
|
continue;
|
|
@@ -177,7 +198,20 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
177
198
|
let config = { key: "", endpoint: "/v1/events" };
|
|
178
199
|
let pending = [];
|
|
179
200
|
let lastDeclaredName;
|
|
180
|
-
|
|
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();
|
|
181
215
|
let flushTimer;
|
|
182
216
|
let consecutiveFailures = 0;
|
|
183
217
|
let circuit = "closed";
|
|
@@ -225,7 +259,12 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
225
259
|
addDrop(queue, "invalid_event");
|
|
226
260
|
writeQueue(ports.storage, queue);
|
|
227
261
|
}
|
|
228
|
-
|
|
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) {
|
|
229
268
|
if (!EVENT_NAME_PATTERN.test(pendingEvent.name)) {
|
|
230
269
|
recordInvalidEvent();
|
|
231
270
|
return;
|
|
@@ -262,8 +301,13 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
262
301
|
else {
|
|
263
302
|
const options = pendingEvent.options;
|
|
264
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
|
+
}
|
|
265
309
|
persistEvent({
|
|
266
|
-
...
|
|
310
|
+
...base,
|
|
267
311
|
kind: "conversion",
|
|
268
312
|
name: pendingEvent.name,
|
|
269
313
|
identifier: options.identifier,
|
|
@@ -286,12 +330,80 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
286
330
|
}
|
|
287
331
|
emit(pendingEvent);
|
|
288
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 report = {
|
|
356
|
+
complete: hold.whole &&
|
|
357
|
+
now - hold.at >= TAG_SIGHTING_AFTER_MS &&
|
|
358
|
+
answer.length <= MAX_TAG_SIGHTINGS,
|
|
359
|
+
sightings: answer.slice(0, MAX_TAG_SIGHTINGS),
|
|
360
|
+
};
|
|
361
|
+
const context = {
|
|
362
|
+
...event.context,
|
|
363
|
+
[TAG_SIGHTINGS_CONTEXT_KEY]: report,
|
|
364
|
+
};
|
|
365
|
+
if (serializedBytes(context) <= MAX_CONTEXT_BYTES) {
|
|
366
|
+
event.context = context;
|
|
367
|
+
reported = true;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (reported)
|
|
371
|
+
writeQueue(ports.storage, queue);
|
|
372
|
+
}
|
|
373
|
+
/** Until the earliest held conversion's window closes. */
|
|
374
|
+
function untilRelease() {
|
|
375
|
+
const now = ports.clock.now().getTime();
|
|
376
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
377
|
+
for (const hold of held.values()) {
|
|
378
|
+
earliest = Math.min(earliest, hold.at + TAG_SIGHTING_AFTER_MS);
|
|
379
|
+
}
|
|
380
|
+
return Math.max(0, earliest - now);
|
|
381
|
+
}
|
|
289
382
|
async function flush(keepalive = false) {
|
|
290
|
-
|
|
383
|
+
release(keepalive);
|
|
384
|
+
if (!initialized || consentState !== "granted")
|
|
385
|
+
return;
|
|
386
|
+
if (sendsInFlight > 0 && !keepalive)
|
|
291
387
|
return;
|
|
292
388
|
const queue = readQueue(ports.storage);
|
|
293
|
-
|
|
294
|
-
|
|
389
|
+
const ready = queue.events.filter((event) => !held.has(event.eventId) && !inFlight.has(event.eventId));
|
|
390
|
+
if (ready.length === 0 || !PUBLIC_KEY_PATTERN.test(config.key)) {
|
|
391
|
+
if (sendsInFlight === 0) {
|
|
392
|
+
scheduleFlush(ready.length === 0 && held.size > 0
|
|
393
|
+
? untilRelease()
|
|
394
|
+
: FLUSH_INTERVAL_MS);
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* A page being hidden gets no later chance, so it sends what no send in
|
|
400
|
+
* flight carries at once, whatever the circuit's state, and the answer is
|
|
401
|
+
* read as any other's.
|
|
402
|
+
*/
|
|
403
|
+
if (sendsInFlight > 0) {
|
|
404
|
+
const { batch } = batchFrom(ready);
|
|
405
|
+
if (batch.events.length > 0)
|
|
406
|
+
await deliver(batch, true);
|
|
295
407
|
return;
|
|
296
408
|
}
|
|
297
409
|
const now = ports.clock.now().getTime();
|
|
@@ -302,7 +414,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
302
414
|
}
|
|
303
415
|
circuit = "half_open";
|
|
304
416
|
}
|
|
305
|
-
const { batch, unsendable } = batchFrom(
|
|
417
|
+
const { batch, unsendable } = batchFrom(ready);
|
|
306
418
|
if (unsendable.length > 0) {
|
|
307
419
|
const dropped = new Set(unsendable.map((event) => event.eventId));
|
|
308
420
|
queue.events = queue.events.filter((event) => !dropped.has(event.eventId));
|
|
@@ -313,7 +425,18 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
313
425
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
314
426
|
return;
|
|
315
427
|
}
|
|
316
|
-
|
|
428
|
+
await deliver(batch, keepalive);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* One send, and its answer. It removes from the stored queue exactly what
|
|
432
|
+
* it carried, by its own read, filter and write at the moment it settles,
|
|
433
|
+
* so a send beside it keeps what it delivered.
|
|
434
|
+
*/
|
|
435
|
+
async function deliver(batch, keepalive) {
|
|
436
|
+
const carried = new Set(batch.events.map((event) => event.eventId));
|
|
437
|
+
for (const eventId of carried)
|
|
438
|
+
inFlight.add(eventId);
|
|
439
|
+
sendsInFlight += 1;
|
|
317
440
|
try {
|
|
318
441
|
const response = await ports.transport.send({
|
|
319
442
|
endpoint: config.endpoint,
|
|
@@ -323,8 +446,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
323
446
|
});
|
|
324
447
|
if (response.status >= 200 && response.status < 300) {
|
|
325
448
|
const currentQueue = readQueue(ports.storage);
|
|
326
|
-
|
|
327
|
-
currentQueue.events = currentQueue.events.filter((event) => !delivered.has(event.eventId));
|
|
449
|
+
currentQueue.events = currentQueue.events.filter((event) => !carried.has(event.eventId));
|
|
328
450
|
if ((response.rejected ?? 0) > 0) {
|
|
329
451
|
addDrop(currentQueue, "server_rejected", response.rejected);
|
|
330
452
|
}
|
|
@@ -346,8 +468,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
346
468
|
response.status < 500 &&
|
|
347
469
|
response.status !== 429) {
|
|
348
470
|
const currentQueue = readQueue(ports.storage);
|
|
349
|
-
|
|
350
|
-
currentQueue.events = currentQueue.events.filter((event) => !rejected.has(event.eventId));
|
|
471
|
+
currentQueue.events = currentQueue.events.filter((event) => !carried.has(event.eventId));
|
|
351
472
|
addDrop(currentQueue, "server_rejected", batch.events.length);
|
|
352
473
|
writeQueue(ports.storage, currentQueue);
|
|
353
474
|
consecutiveFailures = 0;
|
|
@@ -370,7 +491,9 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
370
491
|
}
|
|
371
492
|
}
|
|
372
493
|
finally {
|
|
373
|
-
|
|
494
|
+
for (const eventId of carried)
|
|
495
|
+
inFlight.delete(eventId);
|
|
496
|
+
sendsInFlight -= 1;
|
|
374
497
|
}
|
|
375
498
|
}
|
|
376
499
|
const engine = {
|
|
@@ -385,8 +508,10 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
385
508
|
storedConsent === "granted" || storedConsent === "denied"
|
|
386
509
|
? storedConsent
|
|
387
510
|
: "unknown";
|
|
388
|
-
if (consentState === "granted")
|
|
511
|
+
if (consentState === "granted") {
|
|
512
|
+
ports.sightings?.start();
|
|
389
513
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
514
|
+
}
|
|
390
515
|
},
|
|
391
516
|
consent: {
|
|
392
517
|
grant() {
|
|
@@ -394,10 +519,11 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
394
519
|
return;
|
|
395
520
|
consentState = "granted";
|
|
396
521
|
safeSet(ports.storage, CONSENT_KEY, "granted");
|
|
522
|
+
ports.sightings?.start();
|
|
397
523
|
const buffered = pending;
|
|
398
524
|
pending = [];
|
|
399
525
|
for (const pendingEvent of buffered)
|
|
400
|
-
emit(pendingEvent);
|
|
526
|
+
emit(pendingEvent, true);
|
|
401
527
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
402
528
|
},
|
|
403
529
|
deny() {
|
|
@@ -406,6 +532,8 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
406
532
|
consentState = "denied";
|
|
407
533
|
pending = [];
|
|
408
534
|
lastDeclaredName = undefined;
|
|
535
|
+
ports.sightings?.stop();
|
|
536
|
+
held.clear();
|
|
409
537
|
safeSet(ports.storage, CONSENT_KEY, "denied");
|
|
410
538
|
safeRemove(ports.storage, SESSION_KEY);
|
|
411
539
|
safeRemove(ports.storage, ACTIVITY_KEY);
|
|
@@ -481,6 +609,153 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
481
609
|
const systemClock = {
|
|
482
610
|
now: () => new Date(Date.now()),
|
|
483
611
|
};
|
|
612
|
+
/**
|
|
613
|
+
* A Resource Timing entry's start, on the system clock: its reading minus the
|
|
614
|
+
* entry's monotonic age. `performance.timeOrigin` would be a second clock, and
|
|
615
|
+
* it can disagree with this one by as much as the machine's clock drifted
|
|
616
|
+
* since the page loaded; the age is read on one clock and the instant on the
|
|
617
|
+
* other, so a sighting and the conversion it is compared with are dated alike
|
|
618
|
+
* ([spec/capture.md] § Tag sightings).
|
|
619
|
+
*/
|
|
620
|
+
function instantOfEntry(startTime) {
|
|
621
|
+
return new Date(systemClock.now().getTime() - (performance.now() - startTime));
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* How many matches the port keeps. A page making more tag requests than this
|
|
625
|
+
* inside one window is one whose report the port declines to give, never one
|
|
626
|
+
* it gives short.
|
|
627
|
+
*/
|
|
628
|
+
const TAG_SIGHTING_MEMORY = 100;
|
|
629
|
+
const ga4IdPattern = new RegExp(TAG_ID_PATTERNS.ga4);
|
|
630
|
+
const metaIdPattern = new RegExp(TAG_ID_PATTERNS.meta);
|
|
631
|
+
const adsIdPattern = new RegExp(TAG_ID_PATTERNS.google_ads);
|
|
632
|
+
const tagEventPattern = new RegExp(TAG_EVENT_PATTERN);
|
|
633
|
+
const adsPathPattern = /^\/pagead\/(?:conversion|viewthroughconversion|1p-conversion)\/([0-9]{6,15})\/$/;
|
|
634
|
+
/**
|
|
635
|
+
* The matching table ([spec/capture.md] § Tag sightings): GA4 and Google Ads
|
|
636
|
+
* key on the path and the id, so a regional host and a first-party tagging
|
|
637
|
+
* domain that keeps the path both match; Meta keys on its host as well. What
|
|
638
|
+
* is answered is the tag's kind, its id and Meta's event — nothing else of
|
|
639
|
+
* the URL, which carries the page address and the vendor's client id.
|
|
640
|
+
*/
|
|
641
|
+
function matchTagRequest(url) {
|
|
642
|
+
let parsed;
|
|
643
|
+
try {
|
|
644
|
+
parsed = new URL(url);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const path = parsed.pathname;
|
|
650
|
+
const parameters = parsed.searchParams;
|
|
651
|
+
if (path.endsWith("/g/collect")) {
|
|
652
|
+
const id = parameters.get("tid");
|
|
653
|
+
return id !== null && ga4IdPattern.test(id)
|
|
654
|
+
? { kind: "ga4", id, event: null }
|
|
655
|
+
: null;
|
|
656
|
+
}
|
|
657
|
+
const host = parsed.hostname;
|
|
658
|
+
if ((host === "facebook.com" || host.endsWith(".facebook.com")) &&
|
|
659
|
+
(path === "/tr" || path === "/tr/")) {
|
|
660
|
+
const id = parameters.get("id");
|
|
661
|
+
if (id === null || !metaIdPattern.test(id))
|
|
662
|
+
return null;
|
|
663
|
+
const event = parameters.get("ev");
|
|
664
|
+
return {
|
|
665
|
+
kind: "meta",
|
|
666
|
+
id,
|
|
667
|
+
event: event !== null && tagEventPattern.test(event) ? event : null,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
const ads = adsPathPattern.exec(path);
|
|
671
|
+
if (ads !== null) {
|
|
672
|
+
const id = `AW-${ads[1]}`;
|
|
673
|
+
const labelled = `${id}/${parameters.get("label") ?? ""}`;
|
|
674
|
+
return {
|
|
675
|
+
kind: "google_ads",
|
|
676
|
+
id: adsIdPattern.test(labelled) ? labelled : id,
|
|
677
|
+
event: null,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* The browser's own record of the page's requests, read through a buffered
|
|
684
|
+
* `PerformanceObserver` once consent is granted and never before. Nothing is
|
|
685
|
+
* wrapped, patched or replaced — no `fetch`, no beacon, no vendor's global —
|
|
686
|
+
* because the runtime runs on somebody else's page and nothing it does may
|
|
687
|
+
* change what another script sees.
|
|
688
|
+
*/
|
|
689
|
+
function createTagSightingPort() {
|
|
690
|
+
let observer;
|
|
691
|
+
/** Ordered by `at`, oldest first; at most `TAG_SIGHTING_MEMORY`. */
|
|
692
|
+
let matches = [];
|
|
693
|
+
function record(entries) {
|
|
694
|
+
for (const entry of entries) {
|
|
695
|
+
const sighting = matchTagRequest(entry.name);
|
|
696
|
+
if (sighting === null)
|
|
697
|
+
continue;
|
|
698
|
+
const match = { sighting, at: instantOfEntry(entry.startTime).getTime() };
|
|
699
|
+
let index = matches.length;
|
|
700
|
+
while (index > 0 && matches[index - 1].at > match.at)
|
|
701
|
+
index -= 1;
|
|
702
|
+
matches.splice(index, 0, match);
|
|
703
|
+
if (matches.length > TAG_SIGHTING_MEMORY)
|
|
704
|
+
matches.shift();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
start() {
|
|
709
|
+
if (observer !== undefined)
|
|
710
|
+
return;
|
|
711
|
+
try {
|
|
712
|
+
const Observer = window.PerformanceObserver;
|
|
713
|
+
if (Observer === undefined ||
|
|
714
|
+
!Observer.supportedEntryTypes.includes("resource")) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const created = new Observer((list) => record(list.getEntries()));
|
|
718
|
+
created.observe({ type: "resource", buffered: true });
|
|
719
|
+
observer = created;
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
observer = undefined;
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
stop() {
|
|
726
|
+
observer?.disconnect();
|
|
727
|
+
observer = undefined;
|
|
728
|
+
matches = [];
|
|
729
|
+
},
|
|
730
|
+
observing() {
|
|
731
|
+
return observer !== undefined;
|
|
732
|
+
},
|
|
733
|
+
around(at) {
|
|
734
|
+
if (observer === undefined)
|
|
735
|
+
return null;
|
|
736
|
+
// What the browser recorded and has not delivered yet: a page being
|
|
737
|
+
// hidden may not run the callback before the conversion leaves.
|
|
738
|
+
record(observer.takeRecords());
|
|
739
|
+
const from = at.getTime() - TAG_SIGHTING_BEFORE_MS;
|
|
740
|
+
const to = at.getTime() + TAG_SIGHTING_AFTER_MS;
|
|
741
|
+
if (matches.length >= TAG_SIGHTING_MEMORY && matches[0].at > from) {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
const seen = new Set();
|
|
745
|
+
const sightings = [];
|
|
746
|
+
for (const { sighting, at: started } of matches) {
|
|
747
|
+
if (started < from || started > to)
|
|
748
|
+
continue;
|
|
749
|
+
const key = `${sighting.kind} ${sighting.id} ${sighting.event ?? ""}`;
|
|
750
|
+
if (seen.has(key))
|
|
751
|
+
continue;
|
|
752
|
+
seen.add(key);
|
|
753
|
+
sightings.push(sighting);
|
|
754
|
+
}
|
|
755
|
+
return sightings;
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
}
|
|
484
759
|
const WEB_PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
485
760
|
class MemoryStorage {
|
|
486
761
|
constructor() {
|
|
@@ -657,6 +932,7 @@ function init(options) {
|
|
|
657
932
|
}
|
|
658
933
|
},
|
|
659
934
|
},
|
|
935
|
+
sightings: createTagSightingPort(),
|
|
660
936
|
}, acquisition());
|
|
661
937
|
engine = runtime.engine;
|
|
662
938
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tracelog/capture-web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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.1.0",
|
|
49
|
+
"@tracelog/event-contract": "1.1.0"
|
|
49
50
|
},
|
|
50
51
|
"scripts": {
|
|
51
52
|
"build": "tsc -p tsconfig.build.json && node build.mjs",
|