@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 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 nothing else.
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.** There is no `latest` on npm and no
16
- `v/latest/` on the CDN, so the version you pin is the bytes you get, for as long
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.0.0
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.0.0/tracelog.js"></script>
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.
@@ -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
- return parsed;
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 (textEncoder.encode(JSON.stringify(candidate)).byteLength > MAX_BATCH_BYTES) {
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
- let sending = false;
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
- function emit(pendingEvent) {
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
- ...baseEvent(now, session.id),
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
- if (!initialized || consentState !== "granted" || sending)
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
- if (queue.events.length === 0 || !PUBLIC_KEY_PATTERN.test(config.key)) {
293
- scheduleFlush(FLUSH_INTERVAL_MS);
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(queue.events);
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
- sending = true;
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
- const delivered = new Set(batch.events.map((event) => event.eventId));
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
- const rejected = new Set(batch.events.map((event) => event.eventId));
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
- sending = false;
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
  }
@@ -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
- return parsed;
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 (textEncoder.encode(JSON.stringify(candidate)).byteLength > MAX_BATCH_BYTES) {
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
- let sending = false;
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
- function emit(pendingEvent) {
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
- ...baseEvent(now, session.id),
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
- if (!initialized || consentState !== "granted" || sending)
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
- if (queue.events.length === 0 || !PUBLIC_KEY_PATTERN.test(config.key)) {
294
- scheduleFlush(FLUSH_INTERVAL_MS);
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(queue.events);
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
- sending = true;
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
- const delivered = new Set(batch.events.map((event) => event.eventId));
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
- const rejected = new Set(batch.events.map((event) => event.eventId));
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
- sending = false;
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.0.0",
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.0.0"
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",