@nekuda/webmcp-sdk 0.6.0-dev.17.1 → 0.7.0-dev.18.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
@@ -1,6 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleasedtelemetry beacons without a preflight
3
+ ## 0.7.02026-09-21
4
+
5
+ One change, made twice: a page load costs the ingest edge one request instead of
6
+ four. The wire schema stays at `2` and every event still arrives — the two savings
7
+ are a header and an envelope, not a sample. **A minor, not a patch**, because the
8
+ second of them puts a request shape on the wire that no earlier backend parses: a
9
+ `batch` is quarantined as `unknown_event` by any transform older than the one that
10
+ ships with it. The events an install emits, and the fields on them, are byte for
11
+ byte 0.6.0's.
12
+
13
+ ### Telemetry beacons without a preflight
4
14
 
5
15
  - The telemetry beacon is sent as `text/plain` instead of `application/json`. The
6
16
  body is unchanged; the header makes an anonymous beacon a CORS simple request, so
@@ -8,6 +18,19 @@
8
18
  still carry `x-api-key` and still preflight. The edge has mapped `text/plain` to
9
19
  the same template since ADR-0014, so no server change is needed.
10
20
 
21
+ ### One beacon per page load
22
+
23
+ - `sdk_init` and `tool_registration` are held for 50 ms per destination (endpoint
24
+ plus key) and, when two or more are waiting, sent as one `batch` envelope
25
+ (`{"schema":2,"event":"batch","events":[…]}`); a lone event is sent as it was.
26
+ `tool_call` never waits, and drains the queue ahead of itself. `pagehide` and a
27
+ hidden tab flush at once, through the same `keepalive` fetch. The backend explodes
28
+ a batch into its events, so nothing downstream changes shape.
29
+ - Three bounds, each a fallback rather than a drop: at most 16 events per batch (the
30
+ count the backend accepts — the queue flushes at the cap and opens a new window),
31
+ at most 60 KB per request (an over-large batch goes as the per-event requests it
32
+ replaced), and a runtime that cannot schedule a timer sends immediately.
33
+
11
34
  ## 0.6.0 — 2026-09-18
12
35
 
13
36
  Five additive changes, no breaking change. The wire schema stays at `2`. Four of them
package/dist/index.d.ts CHANGED
@@ -53,7 +53,8 @@
53
53
  * `tool_registration` once per `registerTools` call, carrying each tool's
54
54
  * outcome; and `tool_call` once per settled invocation, as derived signals only —
55
55
  * no raw `input`, `response`, or error message, unlike point 5. All three go to a
56
- * separate `/v1/telemetry` endpoint, fire-and-forget, no batching; arrival order
56
+ * separate `/v1/telemetry` endpoint, fire-and-forget; the two page-level ones may
57
+ * share one request as a `batch` envelope, `tool_call` never does. Arrival order
57
58
  * is NOT guaranteed, so consumers join on `sessionId` and never on ordering. A
58
59
  * page that configures point 5's `apiKey` has it sent here as `x-api-key` too,
59
60
  * which only adds tenant attribution — an unkeyed page sends anonymously to the
package/dist/index.js CHANGED
@@ -1105,7 +1105,7 @@ function shapeMetrics(inputSchema) {
1105
1105
 
1106
1106
  // src/telemetry.ts
1107
1107
  var SDK_NAME = "@nekuda/webmcp-sdk";
1108
- var SDK_VERSION = "0.6.0-dev.17.1";
1108
+ var SDK_VERSION = "0.7.0-dev.18.1";
1109
1109
  var INSTALL_MODES = ["npm", "cdn_snippet"];
1110
1110
  var SDK_INSTALL_MODE = INSTALL_MODES.find((mode) => mode === (typeof __WEBMCP_INSTALL_MODE__ === "string" ? __WEBMCP_INSTALL_MODE__ : "")) ?? "npm";
1111
1111
  function parseSampleRate(raw) {
@@ -1113,7 +1113,7 @@ function parseSampleRate(raw) {
1113
1113
  return Number.isFinite(rate) && rate > 0 && rate < 1 ? rate : 1;
1114
1114
  }
1115
1115
  var SDK_TELEMETRY_SAMPLE_RATE = parseSampleRate(typeof __WEBMCP_TELEMETRY_SAMPLE_RATE__ === "string" ? __WEBMCP_TELEMETRY_SAMPLE_RATE__ : undefined);
1116
- var SAMPLED_EVENTS = new Set(["sdk_init", "tool_registration"]);
1116
+ var PAGE_LEVEL_EVENTS = new Set(["sdk_init", "tool_registration"]);
1117
1117
  function pageSampled(sessionId, rate = SDK_TELEMETRY_SAMPLE_RATE) {
1118
1118
  if (rate >= 1)
1119
1119
  return true;
@@ -1461,13 +1461,90 @@ function pruneByAllowlist(event, fields = TELEMETRY_FIELDS, toolFields = TELEMET
1461
1461
  return pruned;
1462
1462
  }
1463
1463
  var defaultSinks2 = {
1464
- sendTelemetry: (event) => sendTelemetry(event, globalThis, telemetryEndpoint(), telemetryApiKey())
1464
+ sendTelemetry: (event) => queueOrSend(event, telemetryEndpoint(), telemetryApiKey())
1465
1465
  };
1466
+ var PAGE_BATCH_WINDOW_MS = 50;
1467
+ var PAGE_BATCH_MAX_BYTES = 60000;
1468
+ var PAGE_BATCH_EVENT = "batch";
1469
+ var PAGE_BATCH_EVENTS_KEY = "events";
1470
+ var PAGE_BATCH_MAX_EVENTS = 16;
1471
+ var pendingPageEvents = new Map;
1472
+ function queueOrSend(event, endpoint, apiKey) {
1473
+ const name = String(event.event);
1474
+ if (!PAGE_LEVEL_EVENTS.has(name)) {
1475
+ flushPageEventQueue();
1476
+ sendTelemetry(event, globalThis, endpoint, apiKey);
1477
+ return;
1478
+ }
1479
+ const key = `${endpoint ?? ""}
1480
+ ${typeof apiKey === "string" ? apiKey : ""}`;
1481
+ const pending = pendingPageEvents.get(key);
1482
+ if (pending) {
1483
+ pending.events.push(event);
1484
+ if (pending.events.length >= PAGE_BATCH_MAX_EVENTS)
1485
+ flushPendingBatch(key);
1486
+ return;
1487
+ }
1488
+ const entry = { events: [event], endpoint, apiKey, cancel: () => {} };
1489
+ pendingPageEvents.set(key, entry);
1490
+ const cancel = tryAfterDelay(() => flushPendingBatch(key), PAGE_BATCH_WINDOW_MS);
1491
+ if (!cancel) {
1492
+ flushPendingBatch(key);
1493
+ return;
1494
+ }
1495
+ entry.cancel = cancel;
1496
+ }
1497
+ function flushPendingBatch(key) {
1498
+ const entry = pendingPageEvents.get(key);
1499
+ if (!entry)
1500
+ return;
1501
+ pendingPageEvents.delete(key);
1502
+ entry.cancel();
1503
+ const { events, endpoint, apiKey } = entry;
1504
+ const one = events[0];
1505
+ if (events.length === 1 && one) {
1506
+ sendTelemetry(one, globalThis, endpoint, apiKey);
1507
+ return;
1508
+ }
1509
+ const batch = {
1510
+ schema: TELEMETRY_SCHEMA_VERSION,
1511
+ event: PAGE_BATCH_EVENT,
1512
+ ts: new Date().toISOString(),
1513
+ sessionId: sessionId(),
1514
+ [PAGE_BATCH_EVENTS_KEY]: events
1515
+ };
1516
+ const json = safe(() => JSON.stringify(batch));
1517
+ const bytes = json === undefined ? Number.POSITIVE_INFINITY : utf8Length(json);
1518
+ if (bytes <= PAGE_BATCH_MAX_BYTES) {
1519
+ sendTelemetry(batch, globalThis, endpoint, apiKey);
1520
+ return;
1521
+ }
1522
+ for (const event of events)
1523
+ sendTelemetry(event, globalThis, endpoint, apiKey);
1524
+ }
1525
+ function utf8Length(text) {
1526
+ const encoded = safe(() => new TextEncoder().encode(text).length);
1527
+ return typeof encoded === "number" ? encoded : text.length * 3;
1528
+ }
1529
+ function flushPageEventQueue() {
1530
+ for (const key of Array.from(pendingPageEvents.keys()))
1531
+ flushPendingBatch(key);
1532
+ }
1533
+ safe(() => {
1534
+ const g = globalThis;
1535
+ if (typeof g.addEventListener !== "function")
1536
+ return;
1537
+ g.addEventListener("pagehide", flushPageEventQueue);
1538
+ g.addEventListener("visibilitychange", () => {
1539
+ if (g.document?.visibilityState === "hidden")
1540
+ flushPageEventQueue();
1541
+ });
1542
+ });
1466
1543
  function batchTelemetrySinks(apiKey, endpoint) {
1467
1544
  const own = batchApiKey(apiKey);
1468
1545
  const ownEndpoint = telemetryEndpointFrom(endpoint);
1469
1546
  return {
1470
- sendTelemetry: (event) => sendTelemetry(event, globalThis, ownEndpoint ?? telemetryEndpoint(), own ?? telemetryApiKey())
1547
+ sendTelemetry: (event) => queueOrSend(event, ownEndpoint ?? telemetryEndpoint(), own ?? telemetryApiKey())
1471
1548
  };
1472
1549
  }
1473
1550
  function batchApiKey(apiKey) {
@@ -1485,7 +1562,7 @@ function emitTelemetry(build, sinks = defaultSinks2, fields = TELEMETRY_FIELDS,
1485
1562
  if (!telemetryEnabled())
1486
1563
  return;
1487
1564
  const event = { ...build() };
1488
- if (SAMPLED_EVENTS.has(String(event.event)) && sampleRate < 1) {
1565
+ if (PAGE_LEVEL_EVENTS.has(String(event.event)) && sampleRate < 1) {
1489
1566
  if (!pageSampled(String(event.sessionId), sampleRate))
1490
1567
  return;
1491
1568
  event.sampleRate = sampleRate;
@@ -1543,11 +1620,18 @@ function deferInitEventForBatch(sinks) {
1543
1620
  return;
1544
1621
  afterDelay(() => flushInitEvent(sinks), 0);
1545
1622
  }
1546
- function afterDelay(run, ms) {
1623
+ function tryAfterDelay(run, ms) {
1547
1624
  const schedule = safe(() => globalThis.setTimeout);
1548
1625
  if (typeof schedule !== "function")
1549
- return () => {};
1550
- const handle = safe(() => schedule.call(globalThis, () => run(), ms));
1626
+ return;
1627
+ let armed = false;
1628
+ const handle = safe(() => {
1629
+ const id = schedule.call(globalThis, () => run(), ms);
1630
+ armed = true;
1631
+ return id;
1632
+ });
1633
+ if (!armed)
1634
+ return;
1551
1635
  return () => {
1552
1636
  const clear = safe(() => globalThis.clearTimeout);
1553
1637
  if (typeof clear !== "function")
@@ -1555,6 +1639,9 @@ function afterDelay(run, ms) {
1555
1639
  safe(() => clear.call(globalThis, handle));
1556
1640
  };
1557
1641
  }
1642
+ function afterDelay(run, ms) {
1643
+ return tryAfterDelay(run, ms) ?? (() => {});
1644
+ }
1558
1645
  var INIT_FALLBACK_MS = 1000;
1559
1646
  cancelInitFallback = afterDelay(() => flushInitEvent(), INIT_FALLBACK_MS);
1560
1647
 
@@ -7,11 +7,12 @@
7
7
  * on `registerTools`, with `globalThis.__WEBMCP_TELEMETRY__ = false`, or via
8
8
  * Global Privacy Control.
9
9
  *
10
- * Event schemas, joined on the in-memory `sessionId`, no batching,
11
- * fire-and-forget: `sdk_init` (once per page load, from the one-shot flush this
12
- * module schedules at import time), `tool_registration` (once per `registerTools`
13
- * call), and `tool_call` (once per settled invocation). Events go to the separate
14
- * `/v1/telemetry` endpoint through `sendTelemetry` in `src/transport.ts`.
10
+ * Event schemas, joined on the in-memory `sessionId`, fire-and-forget: `sdk_init`
11
+ * (once per page load, from the one-shot flush this module schedules at import
12
+ * time), `tool_registration` (once per `registerTools` call), and `tool_call`
13
+ * (once per settled invocation). Events go to the separate `/v1/telemetry`
14
+ * endpoint through `sendTelemetry` in `src/transport.ts`; the two page-level ones
15
+ * may share a request as a `batch` envelope (see {@link PAGE_BATCH_WINDOW_MS}).
15
16
  *
16
17
  * No browser storage is touched anywhere in this channel — no `visitorId`, no
17
18
  * stored `sessionId` (the ePrivacy Art. 5(3) concern). Cross-visit stitching is
@@ -370,6 +371,55 @@ export declare function pruneByAllowlist(event: Record<string, unknown>, fields?
370
371
  export interface TelemetrySinks {
371
372
  sendTelemetry: (event: object) => void;
372
373
  }
374
+ /**
375
+ * One beacon per page load, where the page allows it.
376
+ *
377
+ * `sdk_init` flushes one task after import and the first `tool_registration` settles a
378
+ * few milliseconds after `registerTools`, so on most pages the two page-level events
379
+ * leave within the same few milliseconds — as two requests. Each request is billed at
380
+ * the edge, and in 2026-09 page-level beacons were the whole of a seven-million-a-day
381
+ * request bill. Both events are therefore held for {@link PAGE_BATCH_WINDOW_MS} and
382
+ * sent as one `batch` envelope when two or more are waiting; a lone event is sent as
383
+ * itself, byte for byte what it was before. `tool_call` never waits: it is one per
384
+ * invocation, carries the call's own timing, and batching it would hold a signal a
385
+ * navigation could then lose.
386
+ *
387
+ * The queue is keyed on the destination — endpoint plus key — because a batch is one
388
+ * request with one `x-api-key`, and two tenants sharing an origin must never travel
389
+ * under each other's key (see {@link batchTelemetrySinks}). The init flush a batch
390
+ * defers (`deferInitEventForBatch`) uses that batch's sinks, so the common case lands
391
+ * in one queue. A `pagehide` or a hidden tab flushes everything at once, through the
392
+ * same `keepalive` fetch. A runtime that cannot schedule — no `setTimeout`, or one
393
+ * that throws — sends immediately: a queue that could never drain would be a queue
394
+ * that loses, and the events would sit until `pagehide` or forever.
395
+ *
396
+ * The transform explodes a `batch` into its events (internal/telemetrytransform); the
397
+ * server-written attribution is applied to every one, so a batch cannot say anything
398
+ * its events could not have said alone.
399
+ */
400
+ export declare const PAGE_BATCH_WINDOW_MS = 50;
401
+ /** Under `fetch(keepalive)`'s 64 KB in-flight ceiling, with room for the wrapper. */
402
+ export declare const PAGE_BATCH_MAX_BYTES = 60000;
403
+ /**
404
+ * The batch envelope's `event` name and member-list key, and the member cap — the
405
+ * three spellings this file shares with the Go transform that explodes a batch
406
+ * (`internal/telemetrytransform`: `eventBatch`, `wireBatchEvents`, `MaxBatchEvents`).
407
+ * Nothing compiles the two together, so `analytics/telemetry_batch_contract_test.go`
408
+ * reads these three declarations out of this source and asserts they equal the Go
409
+ * constants. Exported for that gate, and used below rather than restated: a pin
410
+ * against a constant the envelope does not actually use is a tautology.
411
+ *
412
+ * The cap is the one the SERVER enforces. A seventeenth member does not make a
413
+ * larger batch, it makes an `unparseable_body` — one request in which every event is
414
+ * lost — so the queue flushes at the cap and starts a new batch instead. A page can
415
+ * reach it: `registerTools` may be called many times on an SPA entry, and each call
416
+ * emits its own `tool_registration`.
417
+ */
418
+ export declare const PAGE_BATCH_EVENT = "batch";
419
+ export declare const PAGE_BATCH_EVENTS_KEY = "events";
420
+ export declare const PAGE_BATCH_MAX_EVENTS = 16;
421
+ /** Send every waiting page-level batch now — on `pagehide`, a hidden tab, or a test. */
422
+ export declare function flushPageEventQueue(): void;
373
423
  /**
374
424
  * The sink for the two events a *batch* owns. They carry that batch's tool identity —
375
425
  * `stableKey`, `routeTemplate`, `errorSignature` — so they authenticate with that
@@ -506,10 +556,22 @@ export declare function flushInitEvent(sinks?: TelemetrySinks): void;
506
556
  */
507
557
  export declare function deferInitEventForBatch(sinks: TelemetrySinks): void;
508
558
  /**
509
- * Run `run` after `ms`, returning a canceller. Guarded end to end: a runtime with no
510
- * timers (SSR) and a hostile `setTimeout` (an extension, a fake-timer harness) both
511
- * mean the callback never runs, never a throw out of the import or out of
512
- * `registerTools`. The canceller is safe to call when nothing was scheduled.
559
+ * Run `run` after `ms`, returning a canceller or `undefined` when nothing was
560
+ * scheduled. Guarded end to end: a runtime with no timers (SSR) and a hostile
561
+ * `setTimeout` (an extension, a fake-timer harness) both mean the callback never
562
+ * runs, never a throw out of the import or out of `registerTools`.
563
+ *
564
+ * The `undefined` is the part {@link afterDelay} cannot express, and it matters to
565
+ * exactly one caller: the page-level batch holds events until its timer fires, so a
566
+ * `setTimeout` that exists and THROWS would leave them queued until `pagehide` — a
567
+ * silent loss on any page the user closes from a different tab. A caller that only
568
+ * defers work is happy with a no-op; a caller that defers DATA has to know.
569
+ */
570
+ export declare function tryAfterDelay(run: () => void, ms: number): (() => void) | undefined;
571
+ /**
572
+ * {@link tryAfterDelay} for the callers that have nothing to do when the runtime
573
+ * cannot schedule: the canceller is safe to call when nothing was scheduled, and an
574
+ * unscheduled flush is simply one that never happens.
513
575
  */
514
576
  export declare function afterDelay(run: () => void, ms: number): () => void;
515
577
  /** How long a page that never registers gets to expose a real batch configuration. */
@@ -56,6 +56,8 @@ export interface CollectConfig {
56
56
  * a no-`fetch` environment is a silent no-op.
57
57
  */
58
58
  export declare function sendToCollect(event: TrackingEvent, config: CollectConfig, scope?: object): void;
59
+ /** CORS-safelisted, so an anonymous beacon needs no preflight — see sendTelemetry. */
60
+ export declare const TELEMETRY_CONTENT_TYPE = "text/plain";
59
61
  /**
60
62
  * POST a usage-telemetry event to the telemetry endpoint via `fetch(keepalive)`.
61
63
  * The path is the same whether or not `apiKey` is given: the header only *adds*
@@ -90,8 +92,6 @@ export declare function sendToCollect(event: TrackingEvent, config: CollectConfi
90
92
  * The event stays a bare `object` here so this module never has to import the
91
93
  * assembled shape from `telemetry.ts` (which imports this function).
92
94
  */
93
- /** CORS-safelisted, so an anonymous beacon needs no preflight — see sendTelemetry. */
94
- export declare const TELEMETRY_CONTENT_TYPE = "text/plain";
95
95
  export declare function sendTelemetry(event: object, scope?: object, endpoint?: string, apiKey?: unknown): void;
96
96
  /** The `Logger.emit` slice we use — structural, no hard OTEL type coupling. */
97
97
  interface LoggerLike {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nekuda/webmcp-sdk",
3
- "version": "0.6.0-dev.17.1",
3
+ "version": "0.7.0-dev.18.1",
4
4
  "type": "module",
5
5
  "description": "Phase-1 WebMCP SDK: a thin wrapper over document.modelContext that plugin-generated code targets — defineTool + register/unregister lifecycle. This package pins the plugin↔SDK seam; anonymous tool-call tracking (backend transport via apiKey, OTEL LogRecords via otel) is opt-in through registerTools and default-silent, while anonymous usage telemetry is a separate unauthenticated channel that is on by default (opt out with telemetry: false).",
6
6
  "main": "./dist/index.js",