@tracelog/capture-web 1.1.0 → 1.3.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,18 @@ 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.3.0](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.2.0...capture-web@1.3.0) (2026-09-27)
22
+
23
+
24
+ * **capture-web:** Synchronize tracelog-sdk versions
25
+
26
+ ## [1.2.0](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.1.0...capture-web@1.2.0) (2026-09-22)
27
+
28
+
29
+ ### Added
30
+
31
+ * **capture-web:** read Meta's form and report its beacon as unread ([bd78f98](https://github.com/nacorga/tracelog-sdk/commit/bd78f98a9c478b863399d9e86090b31a906743c5))
32
+
21
33
  ## [1.1.0](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.0.0...capture-web@1.1.0) (2026-09-21)
22
34
 
23
35
 
package/README.md CHANGED
@@ -22,13 +22,13 @@ as they are served.
22
22
  <!-- x-release-please-start-version -->
23
23
 
24
24
  ```bash
25
- npm install --save-exact @tracelog/capture-web@1.1.0
25
+ npm install --save-exact @tracelog/capture-web@1.3.0
26
26
  ```
27
27
 
28
28
  Without a build step, the same runtime as a script tag:
29
29
 
30
30
  ```html
31
- <script src="https://cdn.tracelog.io/v/1.1.0/tracelog.js"></script>
31
+ <script src="https://cdn.tracelog.io/v/1.3.0/tracelog.js"></script>
32
32
  ```
33
33
 
34
34
  <!-- x-release-please-end -->
@@ -91,8 +91,11 @@ inside the conversion path, attached to the step where they happened.
91
91
 
92
92
  Beside each conversion, which GA4, Meta and Google Ads tags the page requested
93
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
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
96
99
  be seen, sent at once when the page is hidden — its report then says whether it
97
100
  was cut short — and never held in verification mode. Context keys beginning
98
101
  with `__tl.` are TraceLog's and are removed.
@@ -7,6 +7,7 @@ const MAX_TAG_SIGHTINGS = 16;
7
7
  const TAG_SIGHTINGS_CONTEXT_KEY = "__tl.tags";
8
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
9
  const TAG_EVENT_PATTERN = "^[A-Za-z0-9_]{1,64}$";
10
+ const tagSightingKinds = ["ga4","meta","google_ads"];
10
11
  const CONSENT_KEY = "__tl.c";
11
12
  const SESSION_KEY = "__tl.s";
12
13
  const ACTIVITY_KEY = "__tl.a";
@@ -351,11 +352,13 @@ function createCaptureEngine(ports, acquisition) {
351
352
  : (ports.sightings?.around(new Date(hold.at)) ?? null);
352
353
  if (event === undefined || answer === null)
353
354
  continue;
355
+ const unread = ports.sightings?.unreadAround?.(new Date(hold.at)) ?? null;
354
356
  const report = {
355
357
  complete: hold.whole &&
356
358
  now - hold.at >= TAG_SIGHTING_AFTER_MS &&
357
359
  answer.length <= MAX_TAG_SIGHTINGS,
358
360
  sightings: answer.slice(0, MAX_TAG_SIGHTINGS),
361
+ ...(unread === null ? {} : { unread }),
359
362
  };
360
363
  const context = {
361
364
  ...event.context,
@@ -630,12 +633,31 @@ const metaIdPattern = new RegExp(TAG_ID_PATTERNS.meta);
630
633
  const adsIdPattern = new RegExp(TAG_ID_PATTERNS.google_ads);
631
634
  const tagEventPattern = new RegExp(TAG_EVENT_PATTERN);
632
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
+ }
633
653
  /**
634
654
  * The matching table ([spec/capture.md] § Tag sightings): GA4 and Google Ads
635
655
  * key on the path and the id, so a regional host and a first-party tagging
636
656
  * domain that keeps the path both match; Meta keys on its host as well. What
637
657
  * 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.
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.
639
661
  */
640
662
  function matchTagRequest(url) {
641
663
  let parsed;
@@ -650,58 +672,143 @@ function matchTagRequest(url) {
650
672
  if (path.endsWith("/g/collect")) {
651
673
  const id = parameters.get("tid");
652
674
  return id !== null && ga4IdPattern.test(id)
653
- ? { kind: "ga4", id, event: null }
675
+ ? { sighting: { kind: "ga4", id, event: null } }
654
676
  : null;
655
677
  }
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
- };
678
+ if (onMetaEndpoint(parsed)) {
679
+ return metaMatch(parameters.get("id"), parameters.get("ev"));
668
680
  }
669
681
  const ads = adsPathPattern.exec(path);
670
682
  if (ads !== null) {
671
683
  const id = `AW-${ads[1]}`;
672
684
  const labelled = `${id}/${parameters.get("label") ?? ""}`;
673
685
  return {
674
- kind: "google_ads",
675
- id: adsIdPattern.test(labelled) ? labelled : id,
676
- event: null,
686
+ sighting: {
687
+ kind: "google_ads",
688
+ id: adsIdPattern.test(labelled) ? labelled : id,
689
+ event: null,
690
+ },
677
691
  };
678
692
  }
679
693
  return null;
680
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
+ }
681
716
  /**
682
717
  * 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
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
684
721
  * wrapped, patched or replaced — no `fetch`, no beacon, no vendor's global —
685
722
  * because the runtime runs on somebody else's page and nothing it does may
686
723
  * change what another script sees.
687
724
  */
688
725
  function createTagSightingPort() {
689
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;
690
735
  /** Ordered by `at`, oldest first; at most `TAG_SIGHTING_MEMORY`. */
691
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
+ }
692
747
  function record(entries) {
693
748
  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();
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;
704
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);
705
812
  }
706
813
  return {
707
814
  start() {
@@ -719,32 +826,31 @@ function createTagSightingPort() {
719
826
  }
720
827
  catch {
721
828
  observer = undefined;
829
+ return;
722
830
  }
831
+ watchBody();
723
832
  },
724
833
  stop() {
725
834
  observer?.disconnect();
726
835
  observer = undefined;
836
+ bodyObserver?.disconnect();
837
+ bodyObserver = undefined;
838
+ watchedFrom = undefined;
727
839
  matches = [];
728
840
  },
729
841
  observing() {
730
842
  return observer !== undefined;
731
843
  },
732
844
  around(at) {
733
- if (observer === undefined)
845
+ const found = within(at);
846
+ if (found === null)
734
847
  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
848
  const seen = new Set();
744
849
  const sightings = [];
745
- for (const { sighting, at: started } of matches) {
746
- if (started < from || started > to)
850
+ for (const match of found) {
851
+ if (!("sighting" in match))
747
852
  continue;
853
+ const { sighting } = match;
748
854
  const key = `${sighting.kind} ${sighting.id} ${sighting.event ?? ""}`;
749
855
  if (seen.has(key))
750
856
  continue;
@@ -753,6 +859,15 @@ function createTagSightingPort() {
753
859
  }
754
860
  return sightings;
755
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
+ },
756
871
  };
757
872
  }
758
873
  const WEB_PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
@@ -8,6 +8,7 @@ const MAX_TAG_SIGHTINGS = 16;
8
8
  const TAG_SIGHTINGS_CONTEXT_KEY = "__tl.tags";
9
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
10
  const TAG_EVENT_PATTERN = "^[A-Za-z0-9_]{1,64}$";
11
+ const tagSightingKinds = ["ga4","meta","google_ads"];
11
12
  const CONSENT_KEY = "__tl.c";
12
13
  const SESSION_KEY = "__tl.s";
13
14
  const ACTIVITY_KEY = "__tl.a";
@@ -352,11 +353,13 @@ function createCaptureEngine(ports, acquisition) {
352
353
  : (ports.sightings?.around(new Date(hold.at)) ?? null);
353
354
  if (event === undefined || answer === null)
354
355
  continue;
356
+ const unread = ports.sightings?.unreadAround?.(new Date(hold.at)) ?? null;
355
357
  const report = {
356
358
  complete: hold.whole &&
357
359
  now - hold.at >= TAG_SIGHTING_AFTER_MS &&
358
360
  answer.length <= MAX_TAG_SIGHTINGS,
359
361
  sightings: answer.slice(0, MAX_TAG_SIGHTINGS),
362
+ ...(unread === null ? {} : { unread }),
360
363
  };
361
364
  const context = {
362
365
  ...event.context,
@@ -631,12 +634,31 @@ const metaIdPattern = new RegExp(TAG_ID_PATTERNS.meta);
631
634
  const adsIdPattern = new RegExp(TAG_ID_PATTERNS.google_ads);
632
635
  const tagEventPattern = new RegExp(TAG_EVENT_PATTERN);
633
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
+ }
634
654
  /**
635
655
  * The matching table ([spec/capture.md] § Tag sightings): GA4 and Google Ads
636
656
  * key on the path and the id, so a regional host and a first-party tagging
637
657
  * domain that keeps the path both match; Meta keys on its host as well. What
638
658
  * 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.
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.
640
662
  */
641
663
  function matchTagRequest(url) {
642
664
  let parsed;
@@ -651,58 +673,143 @@ function matchTagRequest(url) {
651
673
  if (path.endsWith("/g/collect")) {
652
674
  const id = parameters.get("tid");
653
675
  return id !== null && ga4IdPattern.test(id)
654
- ? { kind: "ga4", id, event: null }
676
+ ? { sighting: { kind: "ga4", id, event: null } }
655
677
  : null;
656
678
  }
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
- };
679
+ if (onMetaEndpoint(parsed)) {
680
+ return metaMatch(parameters.get("id"), parameters.get("ev"));
669
681
  }
670
682
  const ads = adsPathPattern.exec(path);
671
683
  if (ads !== null) {
672
684
  const id = `AW-${ads[1]}`;
673
685
  const labelled = `${id}/${parameters.get("label") ?? ""}`;
674
686
  return {
675
- kind: "google_ads",
676
- id: adsIdPattern.test(labelled) ? labelled : id,
677
- event: null,
687
+ sighting: {
688
+ kind: "google_ads",
689
+ id: adsIdPattern.test(labelled) ? labelled : id,
690
+ event: null,
691
+ },
678
692
  };
679
693
  }
680
694
  return null;
681
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
+ }
682
717
  /**
683
718
  * 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
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
685
722
  * wrapped, patched or replaced — no `fetch`, no beacon, no vendor's global —
686
723
  * because the runtime runs on somebody else's page and nothing it does may
687
724
  * change what another script sees.
688
725
  */
689
726
  function createTagSightingPort() {
690
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;
691
736
  /** Ordered by `at`, oldest first; at most `TAG_SIGHTING_MEMORY`. */
692
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
+ }
693
748
  function record(entries) {
694
749
  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();
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;
705
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);
706
813
  }
707
814
  return {
708
815
  start() {
@@ -720,32 +827,31 @@ function createTagSightingPort() {
720
827
  }
721
828
  catch {
722
829
  observer = undefined;
830
+ return;
723
831
  }
832
+ watchBody();
724
833
  },
725
834
  stop() {
726
835
  observer?.disconnect();
727
836
  observer = undefined;
837
+ bodyObserver?.disconnect();
838
+ bodyObserver = undefined;
839
+ watchedFrom = undefined;
728
840
  matches = [];
729
841
  },
730
842
  observing() {
731
843
  return observer !== undefined;
732
844
  },
733
845
  around(at) {
734
- if (observer === undefined)
846
+ const found = within(at);
847
+ if (found === null)
735
848
  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
849
  const seen = new Set();
745
850
  const sightings = [];
746
- for (const { sighting, at: started } of matches) {
747
- if (started < from || started > to)
851
+ for (const match of found) {
852
+ if (!("sighting" in match))
748
853
  continue;
854
+ const { sighting } = match;
749
855
  const key = `${sighting.kind} ${sighting.id} ${sighting.event ?? ""}`;
750
856
  if (seen.has(key))
751
857
  continue;
@@ -754,6 +860,15 @@ function createTagSightingPort() {
754
860
  }
755
861
  return sightings;
756
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
+ },
757
872
  };
758
873
  }
759
874
  const WEB_PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tracelog/capture-web",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "The TraceLog browser capture runtime: consent-first, pinned per version.",
5
5
  "keywords": [
6
6
  "conversion",
@@ -45,8 +45,8 @@
45
45
  "access": "public"
46
46
  },
47
47
  "devDependencies": {
48
- "@tracelog/capture-core": "1.1.0",
49
- "@tracelog/event-contract": "1.1.0"
48
+ "@tracelog/capture-core": "1.3.0",
49
+ "@tracelog/event-contract": "1.3.0"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "tsc -p tsconfig.build.json && node build.mjs",