@tracelog/capture-web 1.3.0 → 2.0.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 +24 -0
- package/README.md +22 -8
- package/dist/tracelog.esm.js +153 -43
- package/dist/tracelog.iife.js +153 -43
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,30 @@ 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
|
+
## [2.0.0](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.3.1...capture-web@2.0.0) (2026-10-01)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### ⚠ BREAKING CHANGES
|
|
25
|
+
|
|
26
|
+
* **capture-core:** call TraceLog.consent.grant() on every load while the visitor's consent stands, not only when they accept.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
* **capture-web:** calls before init are held ([b8d4fe5](https://github.com/nacorga/tracelog-sdk/commit/b8d4fe5e4bb104c2343aeceeb1b17f70d8cc697a))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
* **capture-core:** a grant is not remembered across loads ([75d4cac](https://github.com/nacorga/tracelog-sdk/commit/75d4cac28055007553e36b97d4eef0dbabf27103))
|
|
36
|
+
* **capture-web:** the verification mark follows its tab ([9653074](https://github.com/nacorga/tracelog-sdk/commit/9653074fb57ef5523a43a7fa6bc681f8b8a8814f))
|
|
37
|
+
|
|
38
|
+
## [1.3.1](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.3.0...capture-web@1.3.1) (2026-09-30)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
* **capture-core:** a send outlives the page that started it ([86faf5f](https://github.com/nacorga/tracelog-sdk/commit/86faf5f479a76d69f6413f9db82daae1482492df))
|
|
44
|
+
|
|
21
45
|
## [1.3.0](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.2.0...capture-web@1.3.0) (2026-09-27)
|
|
22
46
|
|
|
23
47
|
|
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@
|
|
25
|
+
npm install --save-exact @tracelog/capture-web@2.0.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/
|
|
31
|
+
<script src="https://cdn.tracelog.io/v/2.0.0/tracelog.js"></script>
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
<!-- x-release-please-end -->
|
|
@@ -42,7 +42,7 @@ the floor before a release.
|
|
|
42
42
|
Before consent is granted the runtime creates no identifiers, writes no storage,
|
|
43
43
|
and sends no network traffic — none, not a reduced set. Consent is a state
|
|
44
44
|
machine you drive; until it reaches `granted`, capture is inert. Denying keeps it
|
|
45
|
-
inert without breaking the page.
|
|
45
|
+
inert without breaking the page, and is remembered; a grant is not.
|
|
46
46
|
|
|
47
47
|
```js
|
|
48
48
|
import TraceLog from "@tracelog/capture-web";
|
|
@@ -52,10 +52,16 @@ TraceLog.init({
|
|
|
52
52
|
endpoint: "https://api.tracelog.io/v1/events",
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
//
|
|
55
|
+
// When your consent surface says yes, and on every later load while
|
|
56
|
+
// that consent stands:
|
|
56
57
|
TraceLog.consent.grant();
|
|
57
58
|
```
|
|
58
59
|
|
|
60
|
+
A grant lasts the page it was given on. Your consent surface keeps the
|
|
61
|
+
visitor's answer and its expiry, so call `grant()` on each load where that
|
|
62
|
+
answer is still yes; a grant that outlived it would capture a visitor whose
|
|
63
|
+
consent has lapsed.
|
|
64
|
+
|
|
59
65
|
A site that never calls `consent.grant()` captures nothing and costs its
|
|
60
66
|
visitors nothing.
|
|
61
67
|
|
|
@@ -74,11 +80,17 @@ That is all of it, and it is what the major version protects.
|
|
|
74
80
|
|
|
75
81
|
`init` also accepts `mode: "verification"`, which a distributed platform artifact
|
|
76
82
|
declares for its platform's own test order. A site's own snippet never sets it —
|
|
77
|
-
the runtime derives verification mode from the window that opened the page
|
|
83
|
+
the runtime derives verification mode from the window that opened the page, and
|
|
84
|
+
once consent is granted there the tab keeps it (`sessionStorage`, `__tl.v`) for
|
|
85
|
+
the site's next pages and the return from a payment taken elsewhere, to the same
|
|
86
|
+
origin. A denial forgets it.
|
|
78
87
|
|
|
79
88
|
Call `init` once per page load. The runtime is built on the first call and
|
|
80
89
|
kept; a later call re-reads the key and the endpoint and rebuilds nothing else,
|
|
81
|
-
so a mode or an acquisition the first call decided stands for the page.
|
|
90
|
+
so a mode or an acquisition the first call decided stands for the page. A
|
|
91
|
+
call made before it — a grant, a step, a conversion — is held, up to 100, and
|
|
92
|
+
made once it has run. The runtime can hold only what reaches it: a page that
|
|
93
|
+
imports it lazily holds what it calls before the import resolves.
|
|
82
94
|
|
|
83
95
|
The application generates the exact calls your declared plan needs, so you never
|
|
84
96
|
type a name TraceLog already knows.
|
|
@@ -105,8 +117,10 @@ An IP address is read once when the event arrives to derive a two-letter country
|
|
|
105
117
|
code, then discarded.
|
|
106
118
|
|
|
107
119
|
Events queue locally once consent allows, batch, and deliver with retry, backoff
|
|
108
|
-
and circuit breaking.
|
|
109
|
-
|
|
120
|
+
and circuit breaking. A step is sent when it is taken, and every send uses
|
|
121
|
+
`keepalive`, so a click that leaves the page — even for another site — does not
|
|
122
|
+
cancel it. Delivery failure surfaces as a diagnosable condition, never as
|
|
123
|
+
silent loss.
|
|
110
124
|
|
|
111
125
|
## Where it runs
|
|
112
126
|
|
package/dist/tracelog.esm.js
CHANGED
|
@@ -27,6 +27,14 @@ const RETRY_BASE_MS = 1000;
|
|
|
27
27
|
const RETRY_CAP_MS = 60 * 1000;
|
|
28
28
|
const CIRCUIT_FAILURE_THRESHOLD = 5;
|
|
29
29
|
const CIRCUIT_PROBE_MS = 60 * 1000;
|
|
30
|
+
/**
|
|
31
|
+
* What the browser lets `keepalive` bodies in flight add up to. Every send
|
|
32
|
+
* uses `keepalive`, so no navigation cancels one, and every send fits what is
|
|
33
|
+
* left of this beside the sends already in flight ([spec/capture.md]
|
|
34
|
+
* § Delivery). It is below the contract's `MAX_BATCH_BYTES`, so it is the
|
|
35
|
+
* batch budget too.
|
|
36
|
+
*/
|
|
37
|
+
const KEEPALIVE_BUDGET_BYTES = 64 * 1024;
|
|
30
38
|
const EVENT_NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
|
31
39
|
const PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
32
40
|
/**
|
|
@@ -171,7 +179,12 @@ function isConversionValid(options) {
|
|
|
171
179
|
function serializedBytes(value) {
|
|
172
180
|
return textEncoder.encode(JSON.stringify(value)).byteLength;
|
|
173
181
|
}
|
|
174
|
-
|
|
182
|
+
/**
|
|
183
|
+
* As many events as fit `room`, oldest first. An event that alone exceeds the
|
|
184
|
+
* whole keepalive budget can never be sent; one that only exceeds what the
|
|
185
|
+
* sends in flight leave waits for them.
|
|
186
|
+
*/
|
|
187
|
+
function batchFrom(events, room) {
|
|
175
188
|
const selected = [];
|
|
176
189
|
const unsendable = [];
|
|
177
190
|
for (const event of events) {
|
|
@@ -181,8 +194,9 @@ function batchFrom(events) {
|
|
|
181
194
|
v: EVENT_ENVELOPE_VERSION,
|
|
182
195
|
events: [...selected, event],
|
|
183
196
|
};
|
|
184
|
-
|
|
185
|
-
|
|
197
|
+
const bytes = serializedBytes(candidate);
|
|
198
|
+
if (bytes > room) {
|
|
199
|
+
if (selected.length === 0 && bytes > KEEPALIVE_BUDGET_BYTES) {
|
|
186
200
|
unsendable.push(event);
|
|
187
201
|
continue;
|
|
188
202
|
}
|
|
@@ -199,11 +213,13 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
199
213
|
let pending = [];
|
|
200
214
|
let lastDeclaredName;
|
|
201
215
|
/**
|
|
202
|
-
* Every send in flight, by count and by the union of their event
|
|
203
|
-
* page hide
|
|
204
|
-
*
|
|
216
|
+
* Every send in flight, by count, by bytes and by the union of their event
|
|
217
|
+
* ids: a step taken and a page hide send beside one rather than wait for
|
|
218
|
+
* it, so there can be several, each must leave the others' events alone,
|
|
219
|
+
* and together they stay within the keepalive budget.
|
|
205
220
|
*/
|
|
206
221
|
let sendsInFlight = 0;
|
|
222
|
+
let bytesInFlight = 0;
|
|
207
223
|
const inFlight = new Set();
|
|
208
224
|
/**
|
|
209
225
|
* The conversions this page emitted and holds for their tag sighting
|
|
@@ -329,6 +345,38 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
329
345
|
return;
|
|
330
346
|
}
|
|
331
347
|
emit(pendingEvent);
|
|
348
|
+
sendTaken();
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* A step, and a conversion that is not held, leaves when it is taken: a
|
|
352
|
+
* click that navigates to another site may fire no hide event at all, and a
|
|
353
|
+
* send started by the click itself is the one that arrives ([spec/capture.md]
|
|
354
|
+
* § Delivery). An open circuit keeps it queued for the probe.
|
|
355
|
+
*/
|
|
356
|
+
function sendTaken() {
|
|
357
|
+
if (circuit !== "closed" || !PUBLIC_KEY_PATTERN.test(config.key))
|
|
358
|
+
return;
|
|
359
|
+
void sendNow();
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Sends at once what is queued and in no send in flight, beside any send in
|
|
363
|
+
* flight, within what the keepalive budget has left.
|
|
364
|
+
*/
|
|
365
|
+
async function sendNow() {
|
|
366
|
+
const queue = readQueue(ports.storage);
|
|
367
|
+
const ready = queue.events.filter((event) => !held.has(event.eventId) && !inFlight.has(event.eventId));
|
|
368
|
+
const { batch, unsendable } = batchFrom(ready, KEEPALIVE_BUDGET_BYTES - bytesInFlight);
|
|
369
|
+
dropUnsendable(queue, unsendable);
|
|
370
|
+
if (batch.events.length > 0)
|
|
371
|
+
await deliver(batch);
|
|
372
|
+
}
|
|
373
|
+
function dropUnsendable(queue, unsendable) {
|
|
374
|
+
if (unsendable.length === 0)
|
|
375
|
+
return;
|
|
376
|
+
const dropped = new Set(unsendable.map((event) => event.eventId));
|
|
377
|
+
queue.events = queue.events.filter((event) => !dropped.has(event.eventId));
|
|
378
|
+
addDrop(queue, "invalid_event", unsendable.length);
|
|
379
|
+
writeQueue(ports.storage, queue);
|
|
332
380
|
}
|
|
333
381
|
/**
|
|
334
382
|
* Gives each held conversion whose window has closed — or every one, at
|
|
@@ -381,11 +429,21 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
381
429
|
}
|
|
382
430
|
return Math.max(0, earliest - now);
|
|
383
431
|
}
|
|
384
|
-
async function flush(
|
|
385
|
-
release(
|
|
432
|
+
async function flush(hidden = false) {
|
|
433
|
+
release(hidden);
|
|
386
434
|
if (!initialized || consentState !== "granted")
|
|
387
435
|
return;
|
|
388
|
-
|
|
436
|
+
/**
|
|
437
|
+
* A page being hidden gets no later chance, so it sends what no send in
|
|
438
|
+
* flight carries at once, whatever the circuit's state, and the answer is
|
|
439
|
+
* read as any other's.
|
|
440
|
+
*/
|
|
441
|
+
if (hidden) {
|
|
442
|
+
if (PUBLIC_KEY_PATTERN.test(config.key))
|
|
443
|
+
await sendNow();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (sendsInFlight > 0)
|
|
389
447
|
return;
|
|
390
448
|
const queue = readQueue(ports.storage);
|
|
391
449
|
const ready = queue.events.filter((event) => !held.has(event.eventId) && !inFlight.has(event.eventId));
|
|
@@ -397,17 +455,6 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
397
455
|
}
|
|
398
456
|
return;
|
|
399
457
|
}
|
|
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);
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
458
|
const now = ports.clock.now().getTime();
|
|
412
459
|
if (circuit === "open") {
|
|
413
460
|
if (now < nextProbeAt) {
|
|
@@ -416,35 +463,32 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
416
463
|
}
|
|
417
464
|
circuit = "half_open";
|
|
418
465
|
}
|
|
419
|
-
const { batch, unsendable } = batchFrom(ready);
|
|
420
|
-
|
|
421
|
-
const dropped = new Set(unsendable.map((event) => event.eventId));
|
|
422
|
-
queue.events = queue.events.filter((event) => !dropped.has(event.eventId));
|
|
423
|
-
addDrop(queue, "invalid_event", unsendable.length);
|
|
424
|
-
writeQueue(ports.storage, queue);
|
|
425
|
-
}
|
|
466
|
+
const { batch, unsendable } = batchFrom(ready, KEEPALIVE_BUDGET_BYTES);
|
|
467
|
+
dropUnsendable(queue, unsendable);
|
|
426
468
|
if (batch.events.length === 0) {
|
|
427
469
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
428
470
|
return;
|
|
429
471
|
}
|
|
430
|
-
await deliver(batch
|
|
472
|
+
await deliver(batch);
|
|
431
473
|
}
|
|
432
474
|
/**
|
|
433
475
|
* One send, and its answer. It removes from the stored queue exactly what
|
|
434
476
|
* it carried, by its own read, filter and write at the moment it settles,
|
|
435
477
|
* so a send beside it keeps what it delivered.
|
|
436
478
|
*/
|
|
437
|
-
async function deliver(batch
|
|
479
|
+
async function deliver(batch) {
|
|
438
480
|
const carried = new Set(batch.events.map((event) => event.eventId));
|
|
481
|
+
const bytes = serializedBytes(batch);
|
|
439
482
|
for (const eventId of carried)
|
|
440
483
|
inFlight.add(eventId);
|
|
441
484
|
sendsInFlight += 1;
|
|
485
|
+
bytesInFlight += bytes;
|
|
442
486
|
try {
|
|
443
487
|
const response = await ports.transport.send({
|
|
444
488
|
endpoint: config.endpoint,
|
|
445
489
|
key: config.key,
|
|
446
490
|
batch,
|
|
447
|
-
keepalive,
|
|
491
|
+
keepalive: true,
|
|
448
492
|
});
|
|
449
493
|
if (response.status >= 200 && response.status < 300) {
|
|
450
494
|
const currentQueue = readQueue(ports.storage);
|
|
@@ -496,6 +540,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
496
540
|
for (const eventId of carried)
|
|
497
541
|
inFlight.delete(eventId);
|
|
498
542
|
sendsInFlight -= 1;
|
|
543
|
+
bytesInFlight -= bytes;
|
|
499
544
|
}
|
|
500
545
|
}
|
|
501
546
|
const engine = {
|
|
@@ -505,27 +550,33 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
505
550
|
endpoint: options.endpoint ?? "/v1/events",
|
|
506
551
|
};
|
|
507
552
|
initialized = true;
|
|
553
|
+
/**
|
|
554
|
+
* Only a denial is remembered; a grant is the integrator's on every load
|
|
555
|
+
* while consent stands, so it never outlives the banner that gave it
|
|
556
|
+
* ([spec/capture.md] § Consent first). A grant an earlier runtime
|
|
557
|
+
* stored is deleted. A second init keeps the decision this page made.
|
|
558
|
+
*/
|
|
559
|
+
if (consentState !== "unknown")
|
|
560
|
+
return;
|
|
508
561
|
const storedConsent = safeGet(ports.storage, CONSENT_KEY);
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (consentState === "granted") {
|
|
514
|
-
ports.sightings?.start();
|
|
515
|
-
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
516
|
-
}
|
|
562
|
+
if (storedConsent === "denied")
|
|
563
|
+
consentState = "denied";
|
|
564
|
+
else if (storedConsent !== null)
|
|
565
|
+
safeRemove(ports.storage, CONSENT_KEY);
|
|
517
566
|
},
|
|
518
567
|
consent: {
|
|
519
568
|
grant() {
|
|
520
569
|
if (!initialized)
|
|
521
570
|
return;
|
|
522
571
|
consentState = "granted";
|
|
523
|
-
|
|
572
|
+
safeRemove(ports.storage, CONSENT_KEY);
|
|
524
573
|
ports.sightings?.start();
|
|
525
574
|
const buffered = pending;
|
|
526
575
|
pending = [];
|
|
527
576
|
for (const pendingEvent of buffered)
|
|
528
577
|
emit(pendingEvent, true);
|
|
578
|
+
if (buffered.length > 0)
|
|
579
|
+
sendTaken();
|
|
529
580
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
530
581
|
},
|
|
531
582
|
deny() {
|
|
@@ -871,6 +922,8 @@ function createTagSightingPort() {
|
|
|
871
922
|
};
|
|
872
923
|
}
|
|
873
924
|
const WEB_PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
925
|
+
/** The tab's verification nonce, in `sessionStorage`, written at the grant. */
|
|
926
|
+
const VERIFICATION_KEY = "__tl.v";
|
|
874
927
|
class MemoryStorage {
|
|
875
928
|
constructor() {
|
|
876
929
|
this.values = new Map();
|
|
@@ -891,6 +944,20 @@ let engine;
|
|
|
891
944
|
let verification;
|
|
892
945
|
let configValid = false;
|
|
893
946
|
let listenersInstalled = false;
|
|
947
|
+
/**
|
|
948
|
+
* Calls made before `init`, made in order once it has run, up to the same 100
|
|
949
|
+
* the engine holds before consent; past that they are dropped
|
|
950
|
+
* ([spec/capture.md] § Consent first).
|
|
951
|
+
*/
|
|
952
|
+
let early = [];
|
|
953
|
+
/** True when there is no engine yet, and the call was held, or dropped. */
|
|
954
|
+
function heldForInit(call) {
|
|
955
|
+
if (engine !== undefined)
|
|
956
|
+
return false;
|
|
957
|
+
if (early.length < PRE_CONSENT_CAP)
|
|
958
|
+
early.push(call);
|
|
959
|
+
return true;
|
|
960
|
+
}
|
|
894
961
|
function browserStorage() {
|
|
895
962
|
try {
|
|
896
963
|
return window.localStorage;
|
|
@@ -899,11 +966,39 @@ function browserStorage() {
|
|
|
899
966
|
return new MemoryStorage();
|
|
900
967
|
}
|
|
901
968
|
}
|
|
969
|
+
/**
|
|
970
|
+
* A page opened from a verification session carries the marker and has an
|
|
971
|
+
* opener. Once consent is granted there, the tab keeps the nonce, so the mark
|
|
972
|
+
* follows it to the site's next pages and back from a payment taken
|
|
973
|
+
* elsewhere, to the same origin ([spec/capture.md] § Verification mode).
|
|
974
|
+
*/
|
|
902
975
|
function verificationMode() {
|
|
903
976
|
const nonce = new URL(window.location.href).searchParams.get("__tl_verify");
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
977
|
+
if (nonce !== null && nonce.length > 0 && window.opener !== null) {
|
|
978
|
+
return { nonce, opener: window.opener };
|
|
979
|
+
}
|
|
980
|
+
let kept = null;
|
|
981
|
+
try {
|
|
982
|
+
kept = window.sessionStorage.getItem(VERIFICATION_KEY);
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
// An unreadable tab storage keeps no mark.
|
|
986
|
+
}
|
|
987
|
+
return kept === null || kept.length === 0
|
|
988
|
+
? undefined
|
|
989
|
+
: { nonce: kept, opener: window.opener };
|
|
990
|
+
}
|
|
991
|
+
function keepVerification(keep) {
|
|
992
|
+
try {
|
|
993
|
+
if (!keep)
|
|
994
|
+
window.sessionStorage.removeItem(VERIFICATION_KEY);
|
|
995
|
+
else if (verification !== undefined) {
|
|
996
|
+
window.sessionStorage.setItem(VERIFICATION_KEY, verification.nonce);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
catch {
|
|
1000
|
+
// A tab that cannot keep it marks this page only.
|
|
1001
|
+
}
|
|
907
1002
|
}
|
|
908
1003
|
function landingPage() {
|
|
909
1004
|
const url = new URL(window.location.href);
|
|
@@ -958,7 +1053,7 @@ function isEndpointValid(endpoint) {
|
|
|
958
1053
|
}
|
|
959
1054
|
}
|
|
960
1055
|
function reportDiagnostic() {
|
|
961
|
-
if (verification === undefined)
|
|
1056
|
+
if (verification === undefined || verification.opener === null)
|
|
962
1057
|
return;
|
|
963
1058
|
verification.opener.postMessage({
|
|
964
1059
|
type: "tracelog:diag",
|
|
@@ -1055,17 +1150,28 @@ function init(options) {
|
|
|
1055
1150
|
...(options.endpoint === undefined ? {} : { endpoint }),
|
|
1056
1151
|
});
|
|
1057
1152
|
installListeners();
|
|
1153
|
+
const held = early;
|
|
1154
|
+
early = [];
|
|
1155
|
+
for (const call of held)
|
|
1156
|
+
call();
|
|
1058
1157
|
reportDiagnostic();
|
|
1059
1158
|
}
|
|
1060
1159
|
const TraceLog = {
|
|
1061
1160
|
init,
|
|
1062
1161
|
consent: {
|
|
1063
1162
|
grant() {
|
|
1163
|
+
if (heldForInit(() => TraceLog.consent.grant()))
|
|
1164
|
+
return;
|
|
1064
1165
|
engine?.consent.grant();
|
|
1166
|
+
if (engine?.consent.state() === "granted")
|
|
1167
|
+
keepVerification(true);
|
|
1065
1168
|
reportDiagnostic();
|
|
1066
1169
|
},
|
|
1067
1170
|
deny() {
|
|
1171
|
+
if (heldForInit(() => TraceLog.consent.deny()))
|
|
1172
|
+
return;
|
|
1068
1173
|
engine?.consent.deny();
|
|
1174
|
+
keepVerification(false);
|
|
1069
1175
|
reportDiagnostic();
|
|
1070
1176
|
},
|
|
1071
1177
|
state() {
|
|
@@ -1073,10 +1179,14 @@ const TraceLog = {
|
|
|
1073
1179
|
},
|
|
1074
1180
|
},
|
|
1075
1181
|
step(name, context) {
|
|
1182
|
+
if (heldForInit(() => TraceLog.step(name, context)))
|
|
1183
|
+
return;
|
|
1076
1184
|
engine?.step(name, context);
|
|
1077
1185
|
reportDiagnostic();
|
|
1078
1186
|
},
|
|
1079
1187
|
conversion(name, options) {
|
|
1188
|
+
if (heldForInit(() => TraceLog.conversion(name, options)))
|
|
1189
|
+
return;
|
|
1080
1190
|
engine?.conversion(name, options);
|
|
1081
1191
|
reportDiagnostic();
|
|
1082
1192
|
},
|
package/dist/tracelog.iife.js
CHANGED
|
@@ -28,6 +28,14 @@ const RETRY_BASE_MS = 1000;
|
|
|
28
28
|
const RETRY_CAP_MS = 60 * 1000;
|
|
29
29
|
const CIRCUIT_FAILURE_THRESHOLD = 5;
|
|
30
30
|
const CIRCUIT_PROBE_MS = 60 * 1000;
|
|
31
|
+
/**
|
|
32
|
+
* What the browser lets `keepalive` bodies in flight add up to. Every send
|
|
33
|
+
* uses `keepalive`, so no navigation cancels one, and every send fits what is
|
|
34
|
+
* left of this beside the sends already in flight ([spec/capture.md]
|
|
35
|
+
* § Delivery). It is below the contract's `MAX_BATCH_BYTES`, so it is the
|
|
36
|
+
* batch budget too.
|
|
37
|
+
*/
|
|
38
|
+
const KEEPALIVE_BUDGET_BYTES = 64 * 1024;
|
|
31
39
|
const EVENT_NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
|
32
40
|
const PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
33
41
|
/**
|
|
@@ -172,7 +180,12 @@ function isConversionValid(options) {
|
|
|
172
180
|
function serializedBytes(value) {
|
|
173
181
|
return textEncoder.encode(JSON.stringify(value)).byteLength;
|
|
174
182
|
}
|
|
175
|
-
|
|
183
|
+
/**
|
|
184
|
+
* As many events as fit `room`, oldest first. An event that alone exceeds the
|
|
185
|
+
* whole keepalive budget can never be sent; one that only exceeds what the
|
|
186
|
+
* sends in flight leave waits for them.
|
|
187
|
+
*/
|
|
188
|
+
function batchFrom(events, room) {
|
|
176
189
|
const selected = [];
|
|
177
190
|
const unsendable = [];
|
|
178
191
|
for (const event of events) {
|
|
@@ -182,8 +195,9 @@ function batchFrom(events) {
|
|
|
182
195
|
v: EVENT_ENVELOPE_VERSION,
|
|
183
196
|
events: [...selected, event],
|
|
184
197
|
};
|
|
185
|
-
|
|
186
|
-
|
|
198
|
+
const bytes = serializedBytes(candidate);
|
|
199
|
+
if (bytes > room) {
|
|
200
|
+
if (selected.length === 0 && bytes > KEEPALIVE_BUDGET_BYTES) {
|
|
187
201
|
unsendable.push(event);
|
|
188
202
|
continue;
|
|
189
203
|
}
|
|
@@ -200,11 +214,13 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
200
214
|
let pending = [];
|
|
201
215
|
let lastDeclaredName;
|
|
202
216
|
/**
|
|
203
|
-
* Every send in flight, by count and by the union of their event
|
|
204
|
-
* page hide
|
|
205
|
-
*
|
|
217
|
+
* Every send in flight, by count, by bytes and by the union of their event
|
|
218
|
+
* ids: a step taken and a page hide send beside one rather than wait for
|
|
219
|
+
* it, so there can be several, each must leave the others' events alone,
|
|
220
|
+
* and together they stay within the keepalive budget.
|
|
206
221
|
*/
|
|
207
222
|
let sendsInFlight = 0;
|
|
223
|
+
let bytesInFlight = 0;
|
|
208
224
|
const inFlight = new Set();
|
|
209
225
|
/**
|
|
210
226
|
* The conversions this page emitted and holds for their tag sighting
|
|
@@ -330,6 +346,38 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
330
346
|
return;
|
|
331
347
|
}
|
|
332
348
|
emit(pendingEvent);
|
|
349
|
+
sendTaken();
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* A step, and a conversion that is not held, leaves when it is taken: a
|
|
353
|
+
* click that navigates to another site may fire no hide event at all, and a
|
|
354
|
+
* send started by the click itself is the one that arrives ([spec/capture.md]
|
|
355
|
+
* § Delivery). An open circuit keeps it queued for the probe.
|
|
356
|
+
*/
|
|
357
|
+
function sendTaken() {
|
|
358
|
+
if (circuit !== "closed" || !PUBLIC_KEY_PATTERN.test(config.key))
|
|
359
|
+
return;
|
|
360
|
+
void sendNow();
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Sends at once what is queued and in no send in flight, beside any send in
|
|
364
|
+
* flight, within what the keepalive budget has left.
|
|
365
|
+
*/
|
|
366
|
+
async function sendNow() {
|
|
367
|
+
const queue = readQueue(ports.storage);
|
|
368
|
+
const ready = queue.events.filter((event) => !held.has(event.eventId) && !inFlight.has(event.eventId));
|
|
369
|
+
const { batch, unsendable } = batchFrom(ready, KEEPALIVE_BUDGET_BYTES - bytesInFlight);
|
|
370
|
+
dropUnsendable(queue, unsendable);
|
|
371
|
+
if (batch.events.length > 0)
|
|
372
|
+
await deliver(batch);
|
|
373
|
+
}
|
|
374
|
+
function dropUnsendable(queue, unsendable) {
|
|
375
|
+
if (unsendable.length === 0)
|
|
376
|
+
return;
|
|
377
|
+
const dropped = new Set(unsendable.map((event) => event.eventId));
|
|
378
|
+
queue.events = queue.events.filter((event) => !dropped.has(event.eventId));
|
|
379
|
+
addDrop(queue, "invalid_event", unsendable.length);
|
|
380
|
+
writeQueue(ports.storage, queue);
|
|
333
381
|
}
|
|
334
382
|
/**
|
|
335
383
|
* Gives each held conversion whose window has closed — or every one, at
|
|
@@ -382,11 +430,21 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
382
430
|
}
|
|
383
431
|
return Math.max(0, earliest - now);
|
|
384
432
|
}
|
|
385
|
-
async function flush(
|
|
386
|
-
release(
|
|
433
|
+
async function flush(hidden = false) {
|
|
434
|
+
release(hidden);
|
|
387
435
|
if (!initialized || consentState !== "granted")
|
|
388
436
|
return;
|
|
389
|
-
|
|
437
|
+
/**
|
|
438
|
+
* A page being hidden gets no later chance, so it sends what no send in
|
|
439
|
+
* flight carries at once, whatever the circuit's state, and the answer is
|
|
440
|
+
* read as any other's.
|
|
441
|
+
*/
|
|
442
|
+
if (hidden) {
|
|
443
|
+
if (PUBLIC_KEY_PATTERN.test(config.key))
|
|
444
|
+
await sendNow();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (sendsInFlight > 0)
|
|
390
448
|
return;
|
|
391
449
|
const queue = readQueue(ports.storage);
|
|
392
450
|
const ready = queue.events.filter((event) => !held.has(event.eventId) && !inFlight.has(event.eventId));
|
|
@@ -398,17 +456,6 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
398
456
|
}
|
|
399
457
|
return;
|
|
400
458
|
}
|
|
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);
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
459
|
const now = ports.clock.now().getTime();
|
|
413
460
|
if (circuit === "open") {
|
|
414
461
|
if (now < nextProbeAt) {
|
|
@@ -417,35 +464,32 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
417
464
|
}
|
|
418
465
|
circuit = "half_open";
|
|
419
466
|
}
|
|
420
|
-
const { batch, unsendable } = batchFrom(ready);
|
|
421
|
-
|
|
422
|
-
const dropped = new Set(unsendable.map((event) => event.eventId));
|
|
423
|
-
queue.events = queue.events.filter((event) => !dropped.has(event.eventId));
|
|
424
|
-
addDrop(queue, "invalid_event", unsendable.length);
|
|
425
|
-
writeQueue(ports.storage, queue);
|
|
426
|
-
}
|
|
467
|
+
const { batch, unsendable } = batchFrom(ready, KEEPALIVE_BUDGET_BYTES);
|
|
468
|
+
dropUnsendable(queue, unsendable);
|
|
427
469
|
if (batch.events.length === 0) {
|
|
428
470
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
429
471
|
return;
|
|
430
472
|
}
|
|
431
|
-
await deliver(batch
|
|
473
|
+
await deliver(batch);
|
|
432
474
|
}
|
|
433
475
|
/**
|
|
434
476
|
* One send, and its answer. It removes from the stored queue exactly what
|
|
435
477
|
* it carried, by its own read, filter and write at the moment it settles,
|
|
436
478
|
* so a send beside it keeps what it delivered.
|
|
437
479
|
*/
|
|
438
|
-
async function deliver(batch
|
|
480
|
+
async function deliver(batch) {
|
|
439
481
|
const carried = new Set(batch.events.map((event) => event.eventId));
|
|
482
|
+
const bytes = serializedBytes(batch);
|
|
440
483
|
for (const eventId of carried)
|
|
441
484
|
inFlight.add(eventId);
|
|
442
485
|
sendsInFlight += 1;
|
|
486
|
+
bytesInFlight += bytes;
|
|
443
487
|
try {
|
|
444
488
|
const response = await ports.transport.send({
|
|
445
489
|
endpoint: config.endpoint,
|
|
446
490
|
key: config.key,
|
|
447
491
|
batch,
|
|
448
|
-
keepalive,
|
|
492
|
+
keepalive: true,
|
|
449
493
|
});
|
|
450
494
|
if (response.status >= 200 && response.status < 300) {
|
|
451
495
|
const currentQueue = readQueue(ports.storage);
|
|
@@ -497,6 +541,7 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
497
541
|
for (const eventId of carried)
|
|
498
542
|
inFlight.delete(eventId);
|
|
499
543
|
sendsInFlight -= 1;
|
|
544
|
+
bytesInFlight -= bytes;
|
|
500
545
|
}
|
|
501
546
|
}
|
|
502
547
|
const engine = {
|
|
@@ -506,27 +551,33 @@ function createCaptureEngine(ports, acquisition) {
|
|
|
506
551
|
endpoint: options.endpoint ?? "/v1/events",
|
|
507
552
|
};
|
|
508
553
|
initialized = true;
|
|
554
|
+
/**
|
|
555
|
+
* Only a denial is remembered; a grant is the integrator's on every load
|
|
556
|
+
* while consent stands, so it never outlives the banner that gave it
|
|
557
|
+
* ([spec/capture.md] § Consent first). A grant an earlier runtime
|
|
558
|
+
* stored is deleted. A second init keeps the decision this page made.
|
|
559
|
+
*/
|
|
560
|
+
if (consentState !== "unknown")
|
|
561
|
+
return;
|
|
509
562
|
const storedConsent = safeGet(ports.storage, CONSENT_KEY);
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
if (consentState === "granted") {
|
|
515
|
-
ports.sightings?.start();
|
|
516
|
-
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
517
|
-
}
|
|
563
|
+
if (storedConsent === "denied")
|
|
564
|
+
consentState = "denied";
|
|
565
|
+
else if (storedConsent !== null)
|
|
566
|
+
safeRemove(ports.storage, CONSENT_KEY);
|
|
518
567
|
},
|
|
519
568
|
consent: {
|
|
520
569
|
grant() {
|
|
521
570
|
if (!initialized)
|
|
522
571
|
return;
|
|
523
572
|
consentState = "granted";
|
|
524
|
-
|
|
573
|
+
safeRemove(ports.storage, CONSENT_KEY);
|
|
525
574
|
ports.sightings?.start();
|
|
526
575
|
const buffered = pending;
|
|
527
576
|
pending = [];
|
|
528
577
|
for (const pendingEvent of buffered)
|
|
529
578
|
emit(pendingEvent, true);
|
|
579
|
+
if (buffered.length > 0)
|
|
580
|
+
sendTaken();
|
|
530
581
|
scheduleFlush(FLUSH_INTERVAL_MS);
|
|
531
582
|
},
|
|
532
583
|
deny() {
|
|
@@ -872,6 +923,8 @@ function createTagSightingPort() {
|
|
|
872
923
|
};
|
|
873
924
|
}
|
|
874
925
|
const WEB_PUBLIC_KEY_PATTERN = /^tl_pk_[a-z2-7]{26}$/;
|
|
926
|
+
/** The tab's verification nonce, in `sessionStorage`, written at the grant. */
|
|
927
|
+
const VERIFICATION_KEY = "__tl.v";
|
|
875
928
|
class MemoryStorage {
|
|
876
929
|
constructor() {
|
|
877
930
|
this.values = new Map();
|
|
@@ -892,6 +945,20 @@ let engine;
|
|
|
892
945
|
let verification;
|
|
893
946
|
let configValid = false;
|
|
894
947
|
let listenersInstalled = false;
|
|
948
|
+
/**
|
|
949
|
+
* Calls made before `init`, made in order once it has run, up to the same 100
|
|
950
|
+
* the engine holds before consent; past that they are dropped
|
|
951
|
+
* ([spec/capture.md] § Consent first).
|
|
952
|
+
*/
|
|
953
|
+
let early = [];
|
|
954
|
+
/** True when there is no engine yet, and the call was held, or dropped. */
|
|
955
|
+
function heldForInit(call) {
|
|
956
|
+
if (engine !== undefined)
|
|
957
|
+
return false;
|
|
958
|
+
if (early.length < PRE_CONSENT_CAP)
|
|
959
|
+
early.push(call);
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
895
962
|
function browserStorage() {
|
|
896
963
|
try {
|
|
897
964
|
return window.localStorage;
|
|
@@ -900,11 +967,39 @@ function browserStorage() {
|
|
|
900
967
|
return new MemoryStorage();
|
|
901
968
|
}
|
|
902
969
|
}
|
|
970
|
+
/**
|
|
971
|
+
* A page opened from a verification session carries the marker and has an
|
|
972
|
+
* opener. Once consent is granted there, the tab keeps the nonce, so the mark
|
|
973
|
+
* follows it to the site's next pages and back from a payment taken
|
|
974
|
+
* elsewhere, to the same origin ([spec/capture.md] § Verification mode).
|
|
975
|
+
*/
|
|
903
976
|
function verificationMode() {
|
|
904
977
|
const nonce = new URL(window.location.href).searchParams.get("__tl_verify");
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
978
|
+
if (nonce !== null && nonce.length > 0 && window.opener !== null) {
|
|
979
|
+
return { nonce, opener: window.opener };
|
|
980
|
+
}
|
|
981
|
+
let kept = null;
|
|
982
|
+
try {
|
|
983
|
+
kept = window.sessionStorage.getItem(VERIFICATION_KEY);
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
// An unreadable tab storage keeps no mark.
|
|
987
|
+
}
|
|
988
|
+
return kept === null || kept.length === 0
|
|
989
|
+
? undefined
|
|
990
|
+
: { nonce: kept, opener: window.opener };
|
|
991
|
+
}
|
|
992
|
+
function keepVerification(keep) {
|
|
993
|
+
try {
|
|
994
|
+
if (!keep)
|
|
995
|
+
window.sessionStorage.removeItem(VERIFICATION_KEY);
|
|
996
|
+
else if (verification !== undefined) {
|
|
997
|
+
window.sessionStorage.setItem(VERIFICATION_KEY, verification.nonce);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
// A tab that cannot keep it marks this page only.
|
|
1002
|
+
}
|
|
908
1003
|
}
|
|
909
1004
|
function landingPage() {
|
|
910
1005
|
const url = new URL(window.location.href);
|
|
@@ -959,7 +1054,7 @@ function isEndpointValid(endpoint) {
|
|
|
959
1054
|
}
|
|
960
1055
|
}
|
|
961
1056
|
function reportDiagnostic() {
|
|
962
|
-
if (verification === undefined)
|
|
1057
|
+
if (verification === undefined || verification.opener === null)
|
|
963
1058
|
return;
|
|
964
1059
|
verification.opener.postMessage({
|
|
965
1060
|
type: "tracelog:diag",
|
|
@@ -1056,17 +1151,28 @@ function init(options) {
|
|
|
1056
1151
|
...(options.endpoint === undefined ? {} : { endpoint }),
|
|
1057
1152
|
});
|
|
1058
1153
|
installListeners();
|
|
1154
|
+
const held = early;
|
|
1155
|
+
early = [];
|
|
1156
|
+
for (const call of held)
|
|
1157
|
+
call();
|
|
1059
1158
|
reportDiagnostic();
|
|
1060
1159
|
}
|
|
1061
1160
|
const TraceLog = {
|
|
1062
1161
|
init,
|
|
1063
1162
|
consent: {
|
|
1064
1163
|
grant() {
|
|
1164
|
+
if (heldForInit(() => TraceLog.consent.grant()))
|
|
1165
|
+
return;
|
|
1065
1166
|
engine?.consent.grant();
|
|
1167
|
+
if (engine?.consent.state() === "granted")
|
|
1168
|
+
keepVerification(true);
|
|
1066
1169
|
reportDiagnostic();
|
|
1067
1170
|
},
|
|
1068
1171
|
deny() {
|
|
1172
|
+
if (heldForInit(() => TraceLog.consent.deny()))
|
|
1173
|
+
return;
|
|
1069
1174
|
engine?.consent.deny();
|
|
1175
|
+
keepVerification(false);
|
|
1070
1176
|
reportDiagnostic();
|
|
1071
1177
|
},
|
|
1072
1178
|
state() {
|
|
@@ -1074,10 +1180,14 @@ const TraceLog = {
|
|
|
1074
1180
|
},
|
|
1075
1181
|
},
|
|
1076
1182
|
step(name, context) {
|
|
1183
|
+
if (heldForInit(() => TraceLog.step(name, context)))
|
|
1184
|
+
return;
|
|
1077
1185
|
engine?.step(name, context);
|
|
1078
1186
|
reportDiagnostic();
|
|
1079
1187
|
},
|
|
1080
1188
|
conversion(name, options) {
|
|
1189
|
+
if (heldForInit(() => TraceLog.conversion(name, options)))
|
|
1190
|
+
return;
|
|
1081
1191
|
engine?.conversion(name, options);
|
|
1082
1192
|
reportDiagnostic();
|
|
1083
1193
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tracelog/capture-web",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.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": "
|
|
49
|
-
"@tracelog/event-contract": "
|
|
48
|
+
"@tracelog/capture-core": "2.0.0",
|
|
49
|
+
"@tracelog/event-contract": "2.0.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"build": "tsc -p tsconfig.build.json && node build.mjs",
|