@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 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 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.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.0.0/tracelog.js"></script>
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.
@@ -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
- 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;
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 (textEncoder.encode(JSON.stringify(candidate)).byteLength > MAX_BATCH_BYTES) {
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
- 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();
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
- 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) {
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
- ...baseEvent(now, session.id),
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
- if (!initialized || consentState !== "granted" || sending)
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
- if (queue.events.length === 0 || !PUBLIC_KEY_PATTERN.test(config.key)) {
293
- scheduleFlush(FLUSH_INTERVAL_MS);
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(queue.events);
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
- sending = true;
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
- const delivered = new Set(batch.events.map((event) => event.eventId));
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
- const rejected = new Set(batch.events.map((event) => event.eventId));
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
- sending = false;
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
  }
@@ -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
- return parsed;
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 (textEncoder.encode(JSON.stringify(candidate)).byteLength > MAX_BATCH_BYTES) {
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
- let sending = false;
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
- function emit(pendingEvent) {
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
- ...baseEvent(now, session.id),
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
- if (!initialized || consentState !== "granted" || sending)
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
- if (queue.events.length === 0 || !PUBLIC_KEY_PATTERN.test(config.key)) {
294
- scheduleFlush(FLUSH_INTERVAL_MS);
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(queue.events);
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
- sending = true;
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
- const delivered = new Set(batch.events.map((event) => event.eventId));
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
- const rejected = new Set(batch.events.map((event) => event.eventId));
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
- sending = false;
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.0.0",
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.0.0"
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",