@tracelog/capture-web 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -18,6 +18,18 @@ patch, and neither requires reading your code.
18
18
  Entries after 1.0.0 are drafted from the commits that touched this package and
19
19
  edited before release.
20
20
 
21
+ ## [1.3.1](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.3.0...capture-web@1.3.1) (2026-09-30)
22
+
23
+
24
+ ### Fixed
25
+
26
+ * **capture-core:** a send outlives the page that started it ([86faf5f](https://github.com/nacorga/tracelog-sdk/commit/86faf5f479a76d69f6413f9db82daae1482492df))
27
+
28
+ ## [1.3.0](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.2.0...capture-web@1.3.0) (2026-09-27)
29
+
30
+
31
+ * **capture-web:** Synchronize tracelog-sdk versions
32
+
21
33
  ## [1.2.0](https://github.com/nacorga/tracelog-sdk/compare/capture-web@1.1.0...capture-web@1.2.0) (2026-09-22)
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.2.0
25
+ npm install --save-exact @tracelog/capture-web@1.3.1
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.2.0/tracelog.js"></script>
31
+ <script src="https://cdn.tracelog.io/v/1.3.1/tracelog.js"></script>
32
32
  ```
33
33
 
34
34
  <!-- x-release-please-end -->
@@ -105,8 +105,10 @@ An IP address is read once when the event arrives to derive a two-letter country
105
105
  code, then discarded.
106
106
 
107
107
  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.
108
+ and circuit breaking. A step is sent when it is taken, and every send uses
109
+ `keepalive`, so a click that leaves the page — even for another site — does not
110
+ cancel it. Delivery failure surfaces as a diagnosable condition, never as
111
+ silent loss.
110
112
 
111
113
  ## Where it runs
112
114
 
@@ -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 = {
@@ -526,6 +571,8 @@ function createCaptureEngine(ports, acquisition) {
526
571
  pending = [];
527
572
  for (const pendingEvent of buffered)
528
573
  emit(pendingEvent, true);
574
+ if (buffered.length > 0)
575
+ sendTaken();
529
576
  scheduleFlush(FLUSH_INTERVAL_MS);
530
577
  },
531
578
  deny() {
@@ -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 = {
@@ -527,6 +572,8 @@ function createCaptureEngine(ports, acquisition) {
527
572
  pending = [];
528
573
  for (const pendingEvent of buffered)
529
574
  emit(pendingEvent, true);
575
+ if (buffered.length > 0)
576
+ sendTaken();
530
577
  scheduleFlush(FLUSH_INTERVAL_MS);
531
578
  },
532
579
  deny() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tracelog/capture-web",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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.2.0",
49
- "@tracelog/event-contract": "1.2.0"
48
+ "@tracelog/capture-core": "1.3.1",
49
+ "@tracelog/event-contract": "1.3.1"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "tsc -p tsconfig.build.json && node build.mjs",