@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 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@1.3.0
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/1.3.0/tracelog.js"></script>
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
- // Only once your consent surface says yes:
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. Delivery failure surfaces as a diagnosable condition,
109
- never as silent loss.
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
 
@@ -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
- function batchFrom(events) {
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
- if (serializedBytes(candidate) > MAX_BATCH_BYTES) {
185
- if (selected.length === 0) {
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 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.
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(keepalive = false) {
385
- release(keepalive);
432
+ async function flush(hidden = false) {
433
+ release(hidden);
386
434
  if (!initialized || consentState !== "granted")
387
435
  return;
388
- if (sendsInFlight > 0 && !keepalive)
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
- if (unsendable.length > 0) {
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, keepalive);
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, keepalive) {
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
- consentState =
510
- storedConsent === "granted" || storedConsent === "denied"
511
- ? storedConsent
512
- : "unknown";
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
- safeSet(ports.storage, CONSENT_KEY, "granted");
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
- return nonce !== null && nonce.length > 0 && window.opener !== null
905
- ? { nonce, opener: window.opener }
906
- : undefined;
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
  },
@@ -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
- function batchFrom(events) {
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
- if (serializedBytes(candidate) > MAX_BATCH_BYTES) {
186
- if (selected.length === 0) {
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 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.
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(keepalive = false) {
386
- release(keepalive);
433
+ async function flush(hidden = false) {
434
+ release(hidden);
387
435
  if (!initialized || consentState !== "granted")
388
436
  return;
389
- if (sendsInFlight > 0 && !keepalive)
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
- if (unsendable.length > 0) {
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, keepalive);
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, keepalive) {
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
- consentState =
511
- storedConsent === "granted" || storedConsent === "denied"
512
- ? storedConsent
513
- : "unknown";
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
- safeSet(ports.storage, CONSENT_KEY, "granted");
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
- return nonce !== null && nonce.length > 0 && window.opener !== null
906
- ? { nonce, opener: window.opener }
907
- : undefined;
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": "1.3.0",
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": "1.3.0",
49
- "@tracelog/event-contract": "1.3.0"
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",