@nekuda/webmcp-sdk 0.4.0-dev.7.3

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.
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Default-on usage telemetry for `@nekuda/webmcp-sdk`. Independent of the opt-in
3
+ * `tracking` channel (`src/tracking.ts`): it needs no `apiKey`, so it reports SDK
4
+ * adoption, browser mix, WebMCP availability, and tool-call reliability from every
5
+ * site the SDK runs on. A page that *does* configure one has it sent as
6
+ * `x-api-key`, which only adds tenant attribution. Opt out with `telemetry: false`
7
+ * on `registerTools`, with `globalThis.__WEBMCP_TELEMETRY__ = false`, or via
8
+ * Global Privacy Control.
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`.
15
+ *
16
+ * No browser storage is touched anywhere in this channel — no `visitorId`, no
17
+ * stored `sessionId` (the ePrivacy Art. 5(3) concern). Cross-visit stitching is
18
+ * deliberately impossible here; the authenticated `tracking` channel keeps its own
19
+ * storage-backed identity, and this one never reads it.
20
+ *
21
+ * Every field an event may carry is gated by the baked-in allowlists in
22
+ * `src/telemetry-fields.ts` — one keyed by event path, one by per-tool field.
23
+ * Pruning happens on the way out, so flipping a field to `false` there is the
24
+ * single change needed to stop collecting it.
25
+ */
26
+ import { type ToolIntent, type ToolSource } from "./define.js";
27
+ import type { ToolAnnotations } from "./spec.js";
28
+ import { type InstallMode, type RegisteredToolEntry, type RegistrationTrigger, type SdkInitEvent, type TelemetryEvent, type ToolCallEvent, type ToolCallOutcome, type ToolRegistrationEvent, type ToolRegistrationOutcome } from "./telemetry-events.js";
29
+ import { type TelemetryFieldMap, type TelemetryToolFieldMap } from "./telemetry-fields.js";
30
+ import { type TrackingOptions } from "./tracking.js";
31
+ /** Reported as `sdk.name` — the npm package identity. */
32
+ export declare const SDK_NAME = "@nekuda/webmcp-sdk";
33
+ /**
34
+ * Reported as `sdk.version`. The fallback is duplicated from `package.json`
35
+ * because a browser bundle cannot read it at runtime; a test pins the two
36
+ * together so a release that bumps one and not the other fails rather than
37
+ * mislabeling every event.
38
+ */
39
+ export declare const SDK_VERSION: string;
40
+ /**
41
+ * Reported as `sdk.installMode`. Defaults to `"npm"` — that is how every published
42
+ * build reaches a page today, and a mistyped define must degrade to the truth for
43
+ * the overwhelming majority rather than emit a value no consumer can read.
44
+ */
45
+ export declare const SDK_INSTALL_MODE: InstallMode;
46
+ /**
47
+ * Read a value that may not exist, collapsing both an absent property and a
48
+ * throwing getter to `undefined`. Every browser global this module touches is
49
+ * optional *and* attacker/extension-reachable, so reaching a property is as
50
+ * unsafe as calling a method — hence the thunk rather than a property lookup.
51
+ *
52
+ * Exported because the same hazard applies to the caller-supplied options object
53
+ * `register.ts` reads its channel configuration from.
54
+ */
55
+ export declare function safe<T>(read: () => T): T | undefined;
56
+ /**
57
+ * Global Privacy Control: `navigator.globalPrivacyControl === true` is a legally
58
+ * recognized opt-out signal, so the default-on channel goes fully silent for it.
59
+ * Strictly `true` — a truthy `"1"`/`1` is not the spec'd signal and must not be
60
+ * read as consent *or* refusal. Absent or hostile `navigator` → not opted out.
61
+ */
62
+ export declare function gpcOptedOut(scope?: object): boolean;
63
+ /**
64
+ * Whether the default-on telemetry channel may emit. Default-on means only three
65
+ * things silence it on a page: the explicit `telemetry: false` opt-out,
66
+ * {@link globalOptOut}, and GPC. When this is `false` nothing at all happens
67
+ * downstream — no `sdk_init`, no `tool_call`.
68
+ *
69
+ * Called twice per event, deliberately. `registerTools` asks once per call, with the
70
+ * batch's `option`, to decide whether that batch observes at all — a silent batch
71
+ * does zero work, not wasted work. {@link emitTelemetry} asks again without the
72
+ * option, at the moment an event would leave the page, because the two page-level
73
+ * levers can be flipped after that decision was made.
74
+ *
75
+ * A `document` is required on top of that, because this channel measures *browser*
76
+ * adoption and must never beacon from a non-browser runtime. The documented
77
+ * codegen shape calls `registerTools` at module scope, which also evaluates during
78
+ * SSR/prerender (Next, Remix), where an unauthenticated POST from the customer's
79
+ * own server is both unexpected egress and junk data: a server `fetch` sends no
80
+ * CORS `Origin`, the only site-attribution signal this channel has, and the event
81
+ * carries no client context, no page context, and `surface.available: false` for a
82
+ * runtime that was never going to have one. Every real browser has a `document`; an
83
+ * unsupported one is missing only `document.modelContext`, so the
84
+ * unsupported-browser adoption signal still reports.
85
+ */
86
+ export declare function telemetryEnabled(option?: boolean, scope?: object): boolean;
87
+ /**
88
+ * `client.browser` / `client.browserMajor` — the browser's own identity, the only
89
+ * environment fact these events carry. Both absent when UA-Client-Hints is.
90
+ */
91
+ export interface BrowserBrand {
92
+ browser?: string;
93
+ browserMajor?: number;
94
+ }
95
+ /**
96
+ * A clustering key for the thrown value's message: the message with its
97
+ * high-cardinality substrings templated to `*`, so `no product for SKU-12345` and
98
+ * `no product for SKU-98765` land in one bucket. Complements
99
+ * {@link errorClassOf}, which is class-only and so cannot separate two failure
100
+ * modes that share a class — nearly every merchant failure is a bare `Error`.
101
+ *
102
+ * `undefined`, never an empty string, when the message is unreadable or empty, so
103
+ * the field is omitted rather than adding a meaningless key to every event. A
104
+ * nullish throw is one of those cases: `String(undefined)` would otherwise cluster
105
+ * as the literal signature `"undefined"`, describing an error nobody observed and
106
+ * mixing `throw undefined` in with the outcome that was recorded without one at all
107
+ * (`register.ts` carries no `error` for a rejection with `undefined`). Absent here
108
+ * matches {@link errorClassOf}, which is already `null` for the same value.
109
+ *
110
+ * Templating runs before the byte cap but after {@link MAX_INPUT_CHARS}. The prefix
111
+ * bound is what makes the cost of the scans safe: each rule scans from its own set of
112
+ * starting positions, and a thrown message is page-controlled, so an unbounded input
113
+ * turned a multi-megabyte one into a main-thread stall of hundreds of milliseconds on
114
+ * the error path. It cannot be left
115
+ * to {@link LONG_RUN} to collapse the run first: a run with no digits and few capitals
116
+ * reads as prose to `isOpaque` and survives whole. What the bound costs is the tail of
117
+ * a message whose middle collapses to nothing — two megabytes of digits between
118
+ * `order ` and ` not found` clusters as `order *` — the accepted trade, since the
119
+ * opening words are what make the key useful; see {@link MAX_INPUT_CHARS}.
120
+ *
121
+ * What the prefix bound does *not* do is license bounding a rule's own candidate. A
122
+ * rule that matches part of an oversized candidate — or, for {@link QUOTED_LITERAL},
123
+ * none of it — is a leak of exactly the value it exists to remove, so
124
+ * {@link QUOTED_LITERAL}, {@link EMAIL}, {@link URL_RUN}, and {@link PATH_RUN} each run
125
+ * to a delimiter instead — see each for why that costs nothing. Where the *cut* is what
126
+ * removes a rule's closing delimiter, {@link CUT_CANDIDATE} stands in as that
127
+ * delimiter, so the bound cannot leak by the back door what the rules no longer leak
128
+ * by the front.
129
+ *
130
+ * Templating before the *byte* cap is what makes a huge message cluster on its
131
+ * opening words instead of on whatever the byte cut happened to leave behind.
132
+ *
133
+ * The rules then run in a fixed order, and four adjacencies are load-bearing.
134
+ * {@link CUT_CANDIDATE} precedes {@link QUOTED_LITERAL} so the quote the cut left open
135
+ * cannot be read as an earlier literal's closing delimiter. {@link URL_RUN} precedes
136
+ * both {@link LONG_RUN} and {@link PATH_RUN} so an absolute URL is taken whole rather
137
+ * than shredded — {@link LONG_RUN}'s class includes `/`, so it would otherwise collapse
138
+ * an opaque `//host` run first and destroy the `://` anchor {@link URL_RUN} needs,
139
+ * leaving the path and query on the wire
140
+ * (`POST failed https:*.example.com/reset?user=johnsmith`); {@link PATH_RUN} would eat
141
+ * the `//` and leave `https:*`. {@link DIGIT_RUN} runs last so a partly-numeric token
142
+ * collapses to a single `*` rather than being shredded into a digit-free remnant —
143
+ * which is both a worse cluster key and a partial leak of the token.
144
+ */
145
+ export declare function errorSignature(error: unknown): string | undefined;
146
+ /** What `sdk_init` needs from its caller: nothing but a scope to read. */
147
+ export interface InitEventParams {
148
+ /**
149
+ * Injectable global scope for every derived read (tests) — except `timeToInitMs`,
150
+ * which was read at module load and is not a read of this scope at all.
151
+ */
152
+ scope?: object;
153
+ }
154
+ /**
155
+ * Assemble the `sdk_init` event: one per page load, reporting which build reached
156
+ * the page, what WebMCP surface it found, and what kind of page and client it
157
+ * landed in. It carries nothing about any `registerTools` call — that is
158
+ * `tool_registration`'s job, and this event fires even when no batch ever runs,
159
+ * which is the only way a broken integration is visible at all.
160
+ *
161
+ * The truncation ladder `emitTelemetry` runs on the way out never fires for this
162
+ * event: every string on it is capped at the read (`browser` at
163
+ * {@link MAX_BRAND_BYTES}, the route template, the spec version, a two-or-three-letter
164
+ * language subtag, a bounded `sessionId`), so the assembled event cannot approach the
165
+ * 64 KB transport bound in the first place.
166
+ */
167
+ export declare function buildInitEvent(params?: InitEventParams): SdkInitEvent;
168
+ /**
169
+ * What `tool_registration` reads off a tool. Structurally satisfied by a defined
170
+ * tool; everything past the identity is optional because this builder must survive
171
+ * a caller that skipped `defineTool`.
172
+ */
173
+ export interface RegisteredTelemetryTool {
174
+ name: string;
175
+ stableKey: string;
176
+ version?: string;
177
+ description?: string;
178
+ inputSchema?: unknown;
179
+ annotations?: ToolAnnotations;
180
+ source?: ToolSource;
181
+ intent?: ToolIntent;
182
+ }
183
+ /** One tool's registration, as the caller watched it settle. */
184
+ export interface RegisteredToolParams {
185
+ tool: RegisteredTelemetryTool;
186
+ outcome: ToolRegistrationOutcome;
187
+ /** What the surface rejected with; templated into `failureSignature` when `failed`. */
188
+ error?: unknown;
189
+ }
190
+ /** Where a batch sits in the page load, and what caused it. */
191
+ export interface RegistrationSequence {
192
+ /** 1-based position among the batches this page load reports. */
193
+ registrationIndex: number;
194
+ trigger: RegistrationTrigger;
195
+ }
196
+ /**
197
+ * Claim the next batch's position and trigger, advancing module state as it goes.
198
+ *
199
+ * Called at `registerTools` *call* time, not at settle time: the index is the order
200
+ * the calls were made in, and two overlapping batches would otherwise be numbered by
201
+ * whichever surface answered first. It follows that the route read here is the route
202
+ * the call happened on, which is what a `spa_navigation` trigger is about — the event
203
+ * itself derives `routeTemplate` again at emit time, and an SPA that navigates mid-
204
+ * registration is the one case where the two can differ.
205
+ *
206
+ * Only a batch that will actually emit may claim, so the reported sequence has no
207
+ * gaps: an index missing from the stream means a lost beacon, not an opt-out.
208
+ */
209
+ export declare function nextRegistration(scope?: object): RegistrationSequence;
210
+ /** What `tool_registration` needs from `registerTools`; the rest is derived. */
211
+ export interface ToolRegistrationEventParams {
212
+ /** 1-based position of this batch within the page load. */
213
+ registrationIndex: number;
214
+ trigger: RegistrationTrigger;
215
+ /** Milliseconds from the call to the batch settling (or to the timeout). */
216
+ settleMs: number;
217
+ /** Every tool in the batch, in the order it was passed to `registerTools`. */
218
+ tools: readonly RegisteredToolParams[];
219
+ /** The batch's authenticated-channel options — reported as `config.*`. */
220
+ tracking?: TrackingOptions;
221
+ /** Injectable global scope for every derived read (tests). */
222
+ scope?: object;
223
+ }
224
+ /**
225
+ * Assemble the `tool_registration` event: one per `registerTools` call, carrying
226
+ * the per-tool outcomes v1 computed and threw away. A site where 3 of 8 tools fail
227
+ * on a duplicate name is the failure mode this event exists to make visible, and
228
+ * `tools[]` is the only place it is visible at all.
229
+ *
230
+ * `config.*` lives here rather than on `sdk_init` because it is per-batch: at
231
+ * `sdk_init` flush time no batch may have run, so there is nothing to report.
232
+ *
233
+ * The event is unbounded in size by design at this layer — a 500-tool batch is a real
234
+ * shape, and this is the one event of the three that can exceed the transport's 64 KB
235
+ * limit. Keeping it inside that limit is the `tools` rung of the truncation ladder in
236
+ * `src/tracking.ts`, which `emitTelemetry` runs after pruning: entries lose their shape
237
+ * detail first, and the array degrades to a bare count only if that was not enough.
238
+ */
239
+ export declare function buildToolRegistrationEvent(params: ToolRegistrationEventParams): ToolRegistrationEvent & {
240
+ tools: RegisteredToolEntry[];
241
+ };
242
+ /** Where one invocation sits in the page load's sequence of tool calls. */
243
+ export interface ToolCallSequence {
244
+ /** Per-call random ID — deduplicates a retried beacon, joins nothing else. */
245
+ callId: string;
246
+ /** 1-based position among all tool calls in this page load. */
247
+ callIndex: number;
248
+ /** 1-based position among calls to *this* tool. */
249
+ toolCallIndex: number;
250
+ /** The previously called tool's `stableKey`; absent on the page's first call. */
251
+ precededBy?: string;
252
+ }
253
+ /**
254
+ * Claim this invocation's position in the page load, advancing module state as it
255
+ * goes. The counters are what turn isolated events into a *journey*: which tool an
256
+ * agent reached for first, how many times it retried the same one, and what it had
257
+ * just called when this one failed.
258
+ *
259
+ * Claimed before the handler runs, not at settle time, so the sequence is the order
260
+ * the agent made the calls in rather than the order they happened to finish in —
261
+ * the same reason {@link nextRegistration} claims at call time. `precededBy` may
262
+ * therefore be this tool's own key: a retry is a real sequence, not a gap.
263
+ *
264
+ * The `callId` is this channel's own; the authenticated channel mints a separate one
265
+ * per invocation, and the two are deliberately not joinable.
266
+ *
267
+ * `precededBy` is the one field here scoped to `tenantScope` rather than to the page
268
+ * load, because it is the only one carrying a *tenant-owned string*. Two tenants
269
+ * sharing an origin is the topology {@link batchTelemetrySinks} exists for, and module
270
+ * state that spans them would put tenant A's `stableKey` in an event authenticated with
271
+ * tenant B's key — A's tool names attributed to B's org, the same misattribution the
272
+ * per-batch sinks prevent one field over. Omitted rather than remembered per tenant: a
273
+ * `precededBy` that would have to name another tenant's call is a gap, and the field is
274
+ * already documented as absent on a page's first call. The counters stay page-scoped —
275
+ * `callIndex` is defined as a position in the page load and carries no tenant's string.
276
+ */
277
+ export declare function nextCall(stableKey: string, tenantScope?: string): ToolCallSequence;
278
+ /** What `tool_call` reads off a tool — the two fields worth joining calls on. */
279
+ export interface CalledTelemetryTool {
280
+ stableKey: string;
281
+ /** Reported as a hash only; the schema itself never leaves the page. */
282
+ inputSchema?: unknown;
283
+ intent?: ToolIntent;
284
+ }
285
+ /** What `tool_call` reports about one settled invocation. */
286
+ export interface ToolCallEventParams extends ToolCallSequence {
287
+ tool: CalledTelemetryTool;
288
+ outcome: ToolCallOutcome;
289
+ /** Client-measured elapsed milliseconds. */
290
+ durationMs: number;
291
+ /** The normalized handler result (success path) — measured, never carried. */
292
+ response?: unknown;
293
+ /** The thrown value (error path) — reported as `errorClass` + `errorSignature`. */
294
+ error?: unknown;
295
+ /** Injectable global scope for every derived read (tests). */
296
+ scope?: object;
297
+ }
298
+ /**
299
+ * Assemble the `tool_call` event: one per settled invocation, derived signals only.
300
+ * The reliability question — did this tool work, how fast, and how did it fail — is
301
+ * answered by `outcome`, `durationMs`, the error *class* and *signature*, and the
302
+ * response *measurements*. The raw `input`, `response`, and error message v1 sent
303
+ * are gone: they carry merchant and visitor content, and none of them was needed to
304
+ * answer it.
305
+ *
306
+ * `outcome` alone decides the conditional halves, so neither a stray `error` on a
307
+ * successful call nor a stray `response` on a failed one can contradict it. No stack
308
+ * trace — unbounded, and it leaks code structure.
309
+ *
310
+ * The ladder never fires here either, for the same reason `buildInitEvent` never
311
+ * reaches it: every string is bounded at its source — `stableKey` at 1024 chars by `defineTool`,
312
+ * `errorClass` at {@link MAX_ERROR_CLASS_BYTES}, `errorSignature` at
313
+ * {@link MAX_ERROR_SIGNATURE_BYTES}, the route template in `telemetry-context.ts`,
314
+ * `intent` to one of its declared values by {@link toolEnum}, and
315
+ * `schemaHash`/`callId`/`sessionId` at fixed widths — so the assembled event
316
+ * cannot approach the 64 KB transport bound. That matters most inside `tool`, which
317
+ * no ladder rung can reach without discarding the identity this event joins on.
318
+ */
319
+ export declare function buildToolCallEvent(params: ToolCallEventParams): ToolCallEvent;
320
+ /**
321
+ * Drop every field the allowlists map to `false`, keyed by the path the field
322
+ * occupies in the emitted event: a flat key (`sessionId`) is deleted from the
323
+ * event, a dotted key (`sdk.version`, `page.routeTemplate`) from its parent object,
324
+ * and a per-tool key from every `tools[]` entry and from `tool_call.tool`. A parent
325
+ * left empty by pruning is dropped too, so a disabled group leaves no
326
+ * `"response": {}` husk behind. Keys absent from the allowlists pass through
327
+ * untouched — they gate what they know about, they are not a whitelist of the
328
+ * event's shape.
329
+ *
330
+ * Copy-on-write: the input event and any nested object or tool entry it owns are
331
+ * never mutated, and untouched nested values are shared by reference.
332
+ *
333
+ * Event-level paths are at most two levels deep (that is the full extent of the
334
+ * event schemas), which is why this splits on the first dot rather than walking an
335
+ * arbitrary path; the third level, inside `tools[]`, is what `toolFields` is for.
336
+ */
337
+ export declare function pruneByAllowlist(event: Record<string, unknown>, fields?: TelemetryFieldMap, toolFields?: TelemetryToolFieldMap): Record<string, unknown>;
338
+ /**
339
+ * The channel's single output. Injectable so orchestration can be tested without
340
+ * touching the network (the `TrackingSinks` pattern in `src/tracking.ts`).
341
+ */
342
+ export interface TelemetrySinks {
343
+ sendTelemetry: (event: object) => void;
344
+ }
345
+ /**
346
+ * The sink for the two events a *batch* owns. They carry that batch's tool identity —
347
+ * `stableKey`, `routeTemplate`, `errorSignature` — so they authenticate with that
348
+ * batch's own key rather than with module state. Two tenants sharing an origin is a
349
+ * topology `TrackingOptions.apiKey` designs for, and reading the last-captured key at
350
+ * emit time would send tenant A's stableKeys under tenant B's key, attributing A's
351
+ * tools to B's org.
352
+ *
353
+ * A keyless batch falls back to whatever key the page supplied, resolved at emit time
354
+ * for the reason {@link defaultSinks} resolves late: the batch has no tenant of its
355
+ * own, and the page it registered on does. `sdk_init` keeps the module key because it
356
+ * carries page context only — no batch, and so nothing to misattribute.
357
+ *
358
+ * That late fallback is why a `tool_call`, whose `precededBy` can name a *different*
359
+ * tenant's tool, passes {@link resolveTelemetryKey}'s answer through here rather than
360
+ * the raw batch option: a key resolved to a value pins it, closing the window in which
361
+ * the module key changes between the claim and the emit.
362
+ */
363
+ export declare function batchTelemetrySinks(apiKey: unknown): TelemetrySinks;
364
+ /**
365
+ * The key a batch's beacons authenticate with, resolved now instead of at emit time:
366
+ * its own when it supplied one, otherwise the page's.
367
+ *
368
+ * Exists so a caller can resolve *once* and use the one answer for both halves of a
369
+ * beacon — the tenant it is claimed under ({@link telemetryTenantScope}) and the key it
370
+ * is sent with ({@link batchTelemetrySinks}). Both fall back to module state on a
371
+ * keyless batch, and `tool_call` consults them at two different moments: the scope
372
+ * before the handler runs, the key after it settles. A `registerTools` call landing in
373
+ * that window with a different `apiKey` made the two disagree, and the disagreement was
374
+ * exactly the misattribution the scope exists to prevent — tenant A's `stableKey`
375
+ * arriving via `precededBy` on a beacon authenticated as tenant C.
376
+ *
377
+ * Idempotent, so the resolved value can be handed straight back to either function: the
378
+ * result is `undefined` or an already-usable key, never a blank string.
379
+ */
380
+ export declare function resolveTelemetryKey(apiKey: unknown): string | undefined;
381
+ /**
382
+ * The tenant a batch's beacons belong to, as an opaque token — which tenant, never
383
+ * which key. Resolved exactly as {@link batchTelemetrySinks} resolves the key it
384
+ * authenticates with, so the scope a `tool_call` is claimed under is the scope its
385
+ * beacon is sent under. Hashed for the reason `storageNamespace` is: this is only ever
386
+ * compared, so there is no call for holding the key itself in a second place.
387
+ *
388
+ * An unkeyed page is one scope (`""`), which is the single-tenant case and the common
389
+ * one; see {@link nextCall} for what the scope gates.
390
+ */
391
+ export declare function telemetryTenantScope(apiKey: unknown): string;
392
+ /**
393
+ * Build one event, prune it by the allowlist, then hand it to the transport.
394
+ * Pruning happens **here**, on the way out, so it is the single choke point every
395
+ * event passes through — a field flipped to `false` in `telemetry-fields.ts`
396
+ * cannot leak through some builder that forgot to check.
397
+ *
398
+ * The 64 KB truncation ladder (`src/tracking.ts`) runs last, after pruning: a
399
+ * disabled field cannot be what pushes an event over the bound, and only
400
+ * `tool_registration` can approach it at all — every other string this channel emits
401
+ * is capped where it is read. An event still oversized after the ladder is dropped
402
+ * whole by `fetch(keepalive)`, which is why the ladder's `tools` rung degrades to a
403
+ * count rather than giving up.
404
+ *
405
+ * The page-level opt-outs are re-read here too, for the same reason pruning lives
406
+ * here: this is the only place all three events pass through. `registerTools`
407
+ * decides its batch's channel once, but a page can reach for a lever *after* that
408
+ * call — `__WEBMCP_TELEMETRY__` set from a consent callback, a `navigator` patched
409
+ * with GPC after load — and both are documented as silencing every event, not every
410
+ * event registered from then on. Trusting the latched decision instead would keep
411
+ * beaconing a page that asked for silence, which for GPC is a legally recognized
412
+ * opt-out. The cost is that a `callIndex`/`registrationIndex` claimed before the
413
+ * lever flipped leaves a gap; `docs/telemetry-schema.md` names that as one of the
414
+ * causes a gap can have.
415
+ *
416
+ * Never throws, which is why the event arrives as a thunk rather than a value: an
417
+ * argument is evaluated at the call site, *outside* this guard, so passing
418
+ * `buildToolCallEvent(...)` directly would leave assembly unprotected — and on the
419
+ * error path a builder throw would replace the tool's own error with a telemetry
420
+ * one, the worst possible failure for a channel that must stay an observer. Every
421
+ * global the builders read is guarded individually too; this is the backstop that
422
+ * keeps the guarantee true of assembly as a whole, not of each read in turn.
423
+ */
424
+ export declare function emitTelemetry(build: () => TelemetryEvent, sinks?: TelemetrySinks, fields?: TelemetryFieldMap, toolFields?: TelemetryToolFieldMap): void;
425
+ /**
426
+ * Record the `apiKey` of a `registerTools` call so the deferred `sdk_init` flush —
427
+ * which runs with no batch in hand, and may run before any batch exists — can
428
+ * authenticate. Non-string, empty, and whitespace-only values are ignored, so a
429
+ * page that never keys the authenticated channel simply stays anonymous.
430
+ *
431
+ * A later keyless batch does **not** clear a key an earlier one supplied: the page
432
+ * belongs to that tenant either way, and dropping the attribution because the
433
+ * second `registerTools` call omitted it would lose the more specific fact. A later
434
+ * *differing* key does overwrite, and on a two-tenant page one of them will be wrong
435
+ * — which is why only `sdk_init` reads this. The batch-owned events, the ones
436
+ * carrying tool identity, go through {@link batchTelemetrySinks} instead.
437
+ */
438
+ export declare function captureTelemetryApiKey(apiKey: unknown): void;
439
+ /** The key {@link captureTelemetryApiKey} last recorded, if any. */
440
+ export declare function telemetryApiKey(): string | undefined;
441
+ /**
442
+ * Cancel the deferred `sdk_init` below. Called by `registerTools({ telemetry: false })`,
443
+ * whose opt-out is page-level and not merely batch-level: the flush describes the
444
+ * *page load*, so one call asking for silence has to silence it even though the
445
+ * flush knows nothing about batches.
446
+ *
447
+ * Irreversible on purpose — a later batch that omits the option is not consent.
448
+ */
449
+ export declare function cancelInitEvent(): void;
450
+ /**
451
+ * Emit `sdk_init` — at most once per module instance, ever. The flag is set before
452
+ * the opt-out is consulted, so a cancelled flush cannot fire later and a second
453
+ * attempt (a re-entrant timer, a test) is a no-op rather than a duplicate page-load
454
+ * event.
455
+ *
456
+ * Only `telemetry: false` is checked here; the rest of the cancellation set — the
457
+ * `__WEBMCP_TELEMETRY__` kill switch, GPC, and a runtime with no `document`
458
+ * (SSR/prerender) — is {@link emitTelemetry}'s to read, at the same moment it reads
459
+ * it for the other two events. That is *now* rather than at import either way, so a
460
+ * page that sets the switch inside a consent callback still wins the race.
461
+ *
462
+ * Exported for tests; the scheduled timer below is the one that runs it on a real page.
463
+ */
464
+ export declare function flushInitEvent(sinks?: TelemetrySinks): void;
465
+ /**
466
+ * Run `run` after `ms`, returning a canceller. Guarded end to end: a runtime with no
467
+ * timers (SSR) and a hostile `setTimeout` (an extension, a fake-timer harness) both
468
+ * mean the callback never runs, never a throw out of the import or out of
469
+ * `registerTools`. The canceller is safe to call when nothing was scheduled.
470
+ */
471
+ export declare function afterDelay(run: () => void, ms: number): () => void;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Derived, non-personal metadata about a tool's `inputSchema`: a stable hash and
3
+ * a handful of shape metrics. Both feed the `tools[]` entries on the
4
+ * `tool_registration` telemetry event (and `tool.schemaHash` on `tool_call`), so
5
+ * schema drift and schema *shape* are measurable without the schema itself ever
6
+ * leaving the page — a schema carries merchant vocabulary and is not ours to send.
7
+ *
8
+ * `inputSchema` is caller-supplied and `defineTool` validates only that the top
9
+ * level is a plain object, so everything below is untrusted input: it can be
10
+ * cyclic, arbitrarily deep, arbitrarily wide, and studded with throwing getters.
11
+ * Every traversal here is therefore depth-capped, cycle-guarded, node-budgeted,
12
+ * and reads properties through a guard. Nothing in this module throws.
13
+ */
14
+ /**
15
+ * Deterministic, key-sorted JSON-ish encoding of any value. Not valid JSON for
16
+ * hostile input (trimmed branches become marker tokens) and never parsed — it
17
+ * exists only to be hashed. `depth` lets a caller start the depth budget partway
18
+ * down; it defaults to the top.
19
+ */
20
+ export declare function canonicalize(value: unknown, depth?: number): string;
21
+ /**
22
+ * Stable 8-hex-char fingerprint of a tool's input schema. An absent — or
23
+ * non-object, which `defineTool` already rejects — schema is *omitted* rather
24
+ * than hashed, so "no schema" and "empty schema" stay distinguishable downstream.
25
+ */
26
+ export declare function schemaHash(inputSchema?: unknown): string | undefined;
27
+ /**
28
+ * Shape of a tool's parameters — enough to correlate tool design with agent
29
+ * success without carrying any parameter names or text. All counts are top-level
30
+ * parameters; `maxDepth` measures nesting (a flat object schema is `1`).
31
+ */
32
+ export interface ToolShapeMetrics {
33
+ /** Keys in `properties`. */
34
+ paramCount: number;
35
+ /** String entries in `required`. */
36
+ requiredCount: number;
37
+ /** Params typed `string` with neither `enum` nor `format` — the ones an agent
38
+ * has to invent a value for, and the usual source of failed calls. */
39
+ freeTextParamCount: number;
40
+ /** Params constrained by an `enum`. */
41
+ enumParamCount: number;
42
+ /** Nesting depth of the parameter tree; `0` when there are no params. */
43
+ maxDepth: number;
44
+ /** Params carrying a non-empty `description`. */
45
+ describedParamCount: number;
46
+ }
47
+ /**
48
+ * Shape metrics for a tool's input schema, or `undefined` when there is no schema
49
+ * to measure — matching `schemaHash`, so an entry either carries both or neither.
50
+ * A present but unreadable schema reports zeros rather than disappearing.
51
+ */
52
+ export declare function shapeMetrics(inputSchema?: unknown): ToolShapeMetrics | undefined;