@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 +12 -0
- package/README.md +7 -4
- package/dist/tracelog.esm.js +154 -39
- package/dist/tracelog.iife.js +154 -39
- package/package.json +3 -3
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.
|
|
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.
|
|
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
|
|
95
|
-
|
|
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.
|
package/dist/tracelog.esm.js
CHANGED
|
@@ -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
|
-
|
|
657
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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
|
|
746
|
-
if (
|
|
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}$/;
|
package/dist/tracelog.iife.js
CHANGED
|
@@ -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
|
-
|
|
658
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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
|
|
747
|
-
if (
|
|
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.
|
|
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.
|
|
49
|
-
"@tracelog/event-contract": "1.
|
|
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",
|