tracklane 0.5.0 → 0.7.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/dist/browser.js CHANGED
@@ -1,397 +1,2 @@
1
- import { i as resolveEvent, n as makeReporter, r as messageOf, t as assertUniqueProviders } from "./dispatch-BLBoCn-b.js";
2
- //#region src/providers/ga4.browser.ts
3
- function gtagWindow() {
4
- return typeof window === "undefined" ? void 0 : window;
5
- }
6
- /**
7
- * Sends a command, or fails the way a hand-written call would.
8
- *
9
- * Writing `gtag(…)` on a page without Google's snippet throws, and this
10
- * library replicates that rather than swallowing it: a silent no-op is how a
11
- * host ends up believing it is measuring something.
12
- */
13
- function push$1(...args) {
14
- const gtag = gtagWindow()?.gtag;
15
- if (!gtag) throw new Error("ga4: window.gtag is not defined. Add Google's tag to the page before creating tracking.");
16
- gtag(...args);
17
- }
18
- /**
19
- * Google Analytics 4 in the browser, through `gtag.js`.
20
- *
21
- * **This provider does not load Google's tag.** The tag belongs on the page,
22
- * installed the way Google documents it: a snippet in the HTML, or a tag
23
- * manager. This library talks to the tag that is already there; it never
24
- * injects a third-party script into your page, and it never issues the
25
- * property configuration that would emit a second page view.
26
- *
27
- * Which also means the initial consent declaration is not ours to make:
28
- * Google requires it before the property is configured, and the snippet that
29
- * configures the property is yours. Use {@link ConsentState} through
30
- * `consent()` for changes after that.
31
- *
32
- * @param measurementId - The `G-` id of the data stream, matching the tag on
33
- * the page.
34
- * @param config - Rarely needed event name overrides.
35
- * @returns A provider for `createTracking` from `tracklane/browser`.
36
- *
37
- * @example
38
- * ```ts
39
- * import { createTracking, ga4 } from 'tracklane/browser';
40
- *
41
- * // Google's own snippet is already in the page.
42
- * const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });
43
- *
44
- * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });
45
- * ```
46
- */
47
- function ga4(measurementId, config = {}) {
48
- const { events } = config;
49
- return {
50
- name: "ga4",
51
- default: "passthrough",
52
- ...events ? { events } : {},
53
- track(name, data, _options) {
54
- push$1("event", name, {
55
- ...data,
56
- send_to: measurementId
57
- });
58
- },
59
- identify(user, traits) {
60
- if (user.userId !== void 0) push$1("set", { user_id: user.userId });
61
- if (traits) push$1("set", "user_properties", traits);
62
- },
63
- consent(command, state) {
64
- push$1("consent", command, state);
65
- }
66
- };
67
- }
68
- //#endregion
69
- //#region src/providers/meta.browser.ts
70
- /**
71
- * The names Meta accepts through `fbq('track')`. Anything else is a custom
72
- * event and goes through `trackCustom`, which is a different command rather
73
- * than a different argument.
74
- *
75
- * Seventeen standard events plus `PageView`, which Meta documents separately
76
- * from the seventeen and which the base snippet fires by itself.
77
- */
78
- const STANDARD_EVENTS = [
79
- "AddPaymentInfo",
80
- "AddToCart",
81
- "AddToWishlist",
82
- "CompleteRegistration",
83
- "Contact",
84
- "CustomizeProduct",
85
- "Donate",
86
- "FindLocation",
87
- "InitiateCheckout",
88
- "Lead",
89
- "PageView",
90
- "Purchase",
91
- "Schedule",
92
- "Search",
93
- "StartTrial",
94
- "SubmitApplication",
95
- "Subscribe",
96
- "ViewContent"
97
- ];
98
- /**
99
- * How Meta spells the canonical vocabulary, which is GA4's.
100
- *
101
- * Only canonical names appear here. Meta has eight more standard events with
102
- * no GA4 counterpart (`Subscribe`, `StartTrial`, `Schedule`, `Contact`,
103
- * `Donate`, `FindLocation`, `CustomizeProduct`, `SubmitApplication`), and
104
- * inventing canonical names for them would make this library the author of a
105
- * vocabulary it deliberately borrows. A host that fires those maps its own
106
- * event name to them through `events`.
107
- *
108
- * A canonical event with no standard counterpart is **not** dropped: it goes
109
- * to `trackCustom` under its own name, because by hand that event would have
110
- * been sent.
111
- */
112
- const CANONICAL_EVENTS = {
113
- page_view: "PageView",
114
- view_item: "ViewContent",
115
- search: "Search",
116
- add_to_cart: "AddToCart",
117
- add_to_wishlist: "AddToWishlist",
118
- begin_checkout: "InitiateCheckout",
119
- add_payment_info: "AddPaymentInfo",
120
- purchase: "Purchase",
121
- sign_up: "CompleteRegistration",
122
- generate_lead: "Lead"
123
- };
124
- function fbqWindow() {
125
- return typeof window === "undefined" ? void 0 : window;
126
- }
127
- /**
128
- * Sends a command, or fails the way a hand-written call would.
129
- *
130
- * Writing `fbq(…)` on a page without Meta's snippet throws, and this library
131
- * replicates that rather than swallowing it: a silent no-op is how a host
132
- * ends up believing it is measuring something.
133
- */
134
- function push(...args) {
135
- const fbq = fbqWindow()?.fbq;
136
- if (!fbq) throw new Error("meta: window.fbq is not defined. Add Meta's pixel to the page before creating tracking.");
137
- fbq(...args);
138
- }
139
- /**
140
- * GA4's `items` become Meta's `contents`, which is a closed shape: `id`,
141
- * `quantity`, `item_price` and `delivery_category`, and nothing else.
142
- *
143
- * The rest of a GA4 item, `item_name` and `item_brand`, is dropped rather than
144
- * forwarded, because a key Meta does not read inside `contents` looks mapped
145
- * and arrives nowhere. That is the failure mode this project keeps finding.
146
- */
147
- function contents(items) {
148
- return items.map((item) => {
149
- const content = {};
150
- if (item.item_id !== void 0) content.id = item.item_id;
151
- if (item.quantity !== void 0) content.quantity = item.quantity;
152
- if (item.price !== void 0) content.item_price = item.price;
153
- return content;
154
- });
155
- }
156
- /**
157
- * `value` and `currency` are spelled identically by both vendors, so they
158
- * pass through untouched. Everything the host named itself passes through
159
- * too: Meta keeps unknown keys as custom properties, which is exactly what a
160
- * hand-written call would have produced.
161
- *
162
- * `transaction_id` stays under its own name here. Meta's pixel documents no
163
- * order id among its object properties. `order_id` exists on the Conversions
164
- * API and not on this surface, so translating it would be inventing a slot.
165
- */
166
- function pixelParams(data) {
167
- const { items, ...rest } = data;
168
- const params = { ...rest };
169
- if (items) params.contents = contents(items);
170
- return params;
171
- }
172
- /**
173
- * Meta's pixel has one consent switch where this library carries Google's
174
- * four signals, so the four are collapsed into it.
175
- *
176
- * The collapse is the strictest declared answer: any `denied` produces
177
- * `revoke`, and `grant` needs every declared signal to be `granted`. There is
178
- * no field to partition, so this is the only reduction that cannot widen a
179
- * denial, and widening is the one thing a projection must never do.
180
- *
181
- * A host that wants Meta to follow only its advertising category, and not its
182
- * analytics one, decides that before `createTracking` by leaving this
183
- * provider out, which is what `@tracklane/consent` exists to do.
184
- */
185
- function collapse(state) {
186
- const signals = [
187
- state.ad_storage,
188
- state.analytics_storage,
189
- state.ad_user_data,
190
- state.ad_personalization
191
- ].filter((signal) => signal !== void 0);
192
- if (signals.length === 0) return void 0;
193
- return signals.every((signal) => signal === "granted") ? "grant" : "revoke";
194
- }
195
- /**
196
- * The Meta pixel in the browser, through `fbq`.
197
- *
198
- * **This provider does not load Meta's pixel.** The base code belongs on the
199
- * page, installed the way
200
- * [Meta documents it](https://developers.facebook.com/docs/meta-pixel/get-started):
201
- * a snippet in the HTML, or a tag manager. This library talks to the pixel
202
- * that is already there; it never injects a third-party script and never
203
- * issues `fbq('init')`.
204
- *
205
- * Three consequences follow from that, and all three are Meta's rules rather
206
- * than ours:
207
- *
208
- * - **There is no `identify` here.** Meta accepts advanced matching only in
209
- * the base code ("be sure to place advanced matching parameters in the
210
- * pixel base code or the values will not be treated as manual advanced
211
- * matching values"), and the base code is yours. Put `em`, `ph`, `fn`, `ln`
212
- * in your own `fbq('init')`, where the pixel normalises and hashes them for
213
- * you. On the server, where there is no snippet, the same identity travels
214
- * in `context.user` and is hashed by the adapter.
215
- * - **The initial consent declaration is not ours.** Meta wants
216
- * `fbq('consent', 'revoke')` before `init`, and by the time you hold what
217
- * `createTracking` returns that moment has passed. Ours is every
218
- * declaration after it, through `consent()`.
219
- * - **Limited Data Use is not ours either.** `fbq('dataProcessingOptions', …)`
220
- * is documented as running before `init`. It belongs in your snippet. The
221
- * server half, which has no snippet, carries it on its factory.
222
- *
223
- * **There is no pixel id here**, and that is deliberate rather than an
224
- * omission. `fbq('track')` reaches every pixel the page initialised, so an id
225
- * passed in would address nothing and the parameter would promise something
226
- * this adapter cannot deliver. Which pixels exist is declared by your snippet,
227
- * in `fbq('init')`.
228
- *
229
- * @param config - Event name overrides, merged over the built-in map.
230
- * @returns A provider for `createTracking` from `tracklane/browser`.
231
- *
232
- * @example
233
- * ```ts
234
- * import { createTracking, meta } from 'tracklane/browser';
235
- *
236
- * // Meta's own base code is already in the page, and it named the pixel.
237
- * const { track } = createTracking({ providers: [meta()] });
238
- *
239
- * // fbq('track', 'Purchase', { value: 49.9, currency: 'BRL' }, { eventID: 'T-1' })
240
- * track('purchase', { value: 49.9, currency: 'BRL' }, { dedupId: 'T-1' });
241
- * ```
242
- */
243
- function meta(config = {}) {
244
- return {
245
- name: "meta",
246
- default: "passthrough",
247
- events: {
248
- ...CANONICAL_EVENTS,
249
- ...config.events
250
- },
251
- track(name, data, options) {
252
- const args = [
253
- STANDARD_EVENTS.includes(name) ? "track" : "trackCustom",
254
- name,
255
- pixelParams(data)
256
- ];
257
- if (options.dedupId !== void 0) args.push({ eventID: options.dedupId });
258
- push(...args);
259
- },
260
- consent(_command, state) {
261
- const answer = collapse(state);
262
- if (answer) push("consent", answer);
263
- }
264
- };
265
- }
266
- //#endregion
267
- //#region src/providers/posthog.browser.ts
268
- function posthogWindow() {
269
- return typeof window === "undefined" ? void 0 : window;
270
- }
271
- /**
272
- * Returns the vendor SDK, or fails the way a hand-written call would.
273
- *
274
- * Calling `posthog.capture(...)` on a page without PostHog's snippet throws,
275
- * and this library replicates that rather than swallowing it: a silent no-op
276
- * is how a host ends up believing it is measuring something.
277
- */
278
- function sdk() {
279
- const instance = posthogWindow()?.posthog;
280
- if (!instance) throw new Error("posthog: window.posthog is not defined. Add PostHog's snippet to the page before creating tracking.");
281
- return instance;
282
- }
283
- /**
284
- * PostHog in the browser, through `posthog-js`.
285
- *
286
- * **This provider does not load PostHog's snippet.** It belongs on the page,
287
- * installed the way PostHog documents it: a snippet in the HTML, or a tag
288
- * manager. This library talks to the instance that is already there; it
289
- * never injects a third-party script into your page.
290
- *
291
- * @param config - Rarely needed event name overrides.
292
- * @returns A provider for `createTracking` from `tracklane/browser`.
293
- *
294
- * @example
295
- * ```ts
296
- * import { createTracking, posthog } from 'tracklane/browser';
297
- *
298
- * // PostHog's own snippet is already in the page.
299
- * const { track } = createTracking({ providers: [posthog()] });
300
- *
301
- * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });
302
- * ```
303
- */
304
- function posthog(config = {}) {
305
- const { events } = config;
306
- return {
307
- name: "posthog",
308
- default: "passthrough",
309
- ...events ? { events } : {},
310
- track(name, data, _options) {
311
- sdk().capture(name, data);
312
- },
313
- identify(user, traits) {
314
- if (user.userId !== void 0) sdk().identify(user.userId);
315
- if (traits) sdk().setPersonProperties(traits);
316
- },
317
- consent(_command, state) {
318
- const { analytics_storage } = state;
319
- if (analytics_storage === "denied") sdk().opt_out_capturing();
320
- else if (analytics_storage === "granted") sdk().opt_in_capturing();
321
- }
322
- };
323
- }
324
- //#endregion
325
- //#region src/browser.ts
326
- /**
327
- * Creates the browser tracking surface.
328
- *
329
- * Providers install eagerly, which is what pasting a vendor snippet does and
330
- * what lets each vendor record its own initial page view. Every vendor
331
- * shipped here queues its own commands until its script lands, so nothing is
332
- * lost to loading and this library needs no queue of its own.
333
- *
334
- * @param options - The providers to send to, and an optional error channel.
335
- * @returns The `track`, `identify` and `consent` calls.
336
- *
337
- * @example
338
- * ```ts
339
- * import { createTracking, ga4 } from 'tracklane/browser';
340
- *
341
- * const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });
342
- * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });
343
- * ```
344
- */
345
- function createTracking(options) {
346
- const { providers } = options;
347
- assertUniqueProviders(providers);
348
- const report = makeReporter(options.onError);
349
- const fail = (provider, event, cause) => {
350
- report({
351
- provider,
352
- event,
353
- severity: "error",
354
- message: messageOf(cause),
355
- cause
356
- });
357
- };
358
- for (const provider of providers) try {
359
- const installing = provider.install?.();
360
- Promise.resolve(installing).catch((cause) => {
361
- fail(provider.name, "install", cause);
362
- });
363
- } catch (cause) {
364
- fail(provider.name, "install", cause);
365
- }
366
- return {
367
- track(name, data = {}, trackOptions = {}) {
368
- for (const provider of providers) {
369
- const target = resolveEvent(provider, name);
370
- if (target === void 0) continue;
371
- try {
372
- provider.track(target, data, trackOptions);
373
- } catch (cause) {
374
- fail(provider.name, name, cause);
375
- }
376
- }
377
- },
378
- identify(user, traits) {
379
- for (const provider of providers) try {
380
- provider.identify?.(user, traits);
381
- } catch (cause) {
382
- fail(provider.name, "identify", cause);
383
- }
384
- },
385
- consent(command, state) {
386
- for (const provider of providers) try {
387
- provider.consent?.(command, state);
388
- } catch (cause) {
389
- fail(provider.name, "consent", cause);
390
- }
391
- }
392
- };
393
- }
394
- //#endregion
395
- export { createTracking, ga4, meta, posthog };
396
-
397
- //# sourceMappingURL=browser.js.map
1
+ import { a as ga4, i as linkedin, n as posthog, r as meta, t as createTracking } from "./browser-DhzO5qPS.js";
2
+ export { createTracking, ga4, linkedin, meta, posthog };
@@ -1,4 +1,4 @@
1
- import { m as ServerProvider, p as ResolvedContext, t as BrowserProvider } from "./browser-CddkJdvX.js";
1
+ import { g as ServerProvider, h as ResolvedContext, t as BrowserProvider } from "./browser-34-CHcxK.js";
2
2
  //#region src/conformance.d.ts
3
3
  /**
4
4
  * Runs the shared conformance suite against a browser provider.
@@ -8,6 +8,7 @@ import { m as ServerProvider, p as ResolvedContext, t as BrowserProvider } from
8
8
  * @param captured - Everything the provider handed the vendor since the last
9
9
  * reset, in order.
10
10
  * @param reset - Clears what `captured` returns.
11
+ * @param target - An already-resolved vendor target. Defaults to `purchase`.
11
12
  *
12
13
  * @example
13
14
  * ```ts
@@ -24,6 +25,8 @@ declare function conformsAsBrowserProvider(options: {
24
25
  create: () => BrowserProvider;
25
26
  captured: () => unknown[];
26
27
  reset: () => void;
28
+ /** Already-resolved vendor target; conversion-ID providers use their configured ID. */
29
+ target?: string;
27
30
  }): void;
28
31
  /**
29
32
  * Runs the shared conformance suite against a server provider.
@@ -35,6 +38,7 @@ declare function conformsAsBrowserProvider(options: {
35
38
  * @param fail - Makes the next send fail at the transport, so the suite can
36
39
  * check that the provider is loud about it.
37
40
  * @param context - A resolved context with a fixed timestamp.
41
+ * @param target - An already-resolved vendor target. Defaults to `purchase`.
38
42
  *
39
43
  * @example
40
44
  * ```ts
@@ -55,6 +59,8 @@ declare function conformsAsServerProvider(options: {
55
59
  reset: () => void;
56
60
  fail: () => void;
57
61
  context: () => ResolvedContext;
62
+ /** Already-resolved vendor target; conversion-ID providers use their configured ID. */
63
+ target?: string;
58
64
  }): void;
59
65
  //#endregion
60
66
  export { conformsAsBrowserProvider, conformsAsServerProvider };
@@ -1,3 +1,6 @@
1
+ import { n as isVendorResponseError } from "./src-DLRGPWMj.js";
2
+ import { t as createTracking } from "./browser-DhzO5qPS.js";
3
+ import { t as createTracking$1 } from "./server-DnP7RtEn.js";
1
4
  import { expect, it } from "vitest";
2
5
  //#region src/conformance.ts
3
6
  /**
@@ -50,6 +53,7 @@ function assertIdentity(provider) {
50
53
  * @param captured - Everything the provider handed the vendor since the last
51
54
  * reset, in order.
52
55
  * @param reset - Clears what `captured` returns.
56
+ * @param target - An already-resolved vendor target. Defaults to `purchase`.
53
57
  *
54
58
  * @example
55
59
  * ```ts
@@ -64,22 +68,43 @@ function assertIdentity(provider) {
64
68
  */
65
69
  function conformsAsBrowserProvider(options) {
66
70
  const { create, captured, reset } = options;
71
+ const target = options.target ?? "purchase";
67
72
  assertIdentity(create());
73
+ it("reports absent mappings on ignore providers and keeps explicit exclusions silent", () => {
74
+ const { install: _install, ...provider } = create();
75
+ if (provider.default !== "ignore") return;
76
+ reset();
77
+ const errors = [];
78
+ const tracking = createTracking({
79
+ providers: [{
80
+ ...provider,
81
+ events: { explicit: null }
82
+ }],
83
+ onError: (error) => errors.push(error)
84
+ });
85
+ tracking.track("missing");
86
+ tracking.track("explicit");
87
+ expect(errors).toMatchObject([{
88
+ severity: "warning",
89
+ event: "missing"
90
+ }]);
91
+ expect(captured()).toEqual([]);
92
+ });
68
93
  it("sends the same thing twice for the same event", () => {
69
94
  reset();
70
- create().track("purchase", STABLE_DATA, {});
95
+ create().track(target, STABLE_DATA, {});
71
96
  const first = JSON.stringify(captured());
72
97
  reset();
73
- create().track("purchase", STABLE_DATA, {});
98
+ create().track(target, STABLE_DATA, {});
74
99
  const second = JSON.stringify(captured());
75
100
  expect(second).toBe(first);
76
101
  });
77
102
  it("forwards the deduplication id only when it was given one", () => {
78
103
  reset();
79
- create().track("purchase", STABLE_DATA, {});
104
+ create().track(target, STABLE_DATA, {});
80
105
  const without = JSON.stringify(captured());
81
106
  reset();
82
- create().track("purchase", STABLE_DATA, { dedupId: "DEDUP-XYZ" });
107
+ create().track(target, STABLE_DATA, { dedupId: "DEDUP-XYZ" });
83
108
  const with_ = JSON.stringify(captured());
84
109
  if (with_ !== without) expect(with_).toContain("DEDUP-XYZ");
85
110
  expect(without).not.toContain("DEDUP-XYZ");
@@ -102,6 +127,7 @@ function conformsAsBrowserProvider(options) {
102
127
  * @param fail - Makes the next send fail at the transport, so the suite can
103
128
  * check that the provider is loud about it.
104
129
  * @param context - A resolved context with a fixed timestamp.
130
+ * @param target - An already-resolved vendor target. Defaults to `purchase`.
105
131
  *
106
132
  * @example
107
133
  * ```ts
@@ -118,22 +144,43 @@ function conformsAsBrowserProvider(options) {
118
144
  */
119
145
  function conformsAsServerProvider(options) {
120
146
  const { create, captured, reset, fail, context } = options;
147
+ const target = options.target ?? "purchase";
121
148
  const noop = () => {};
122
149
  assertIdentity(create());
150
+ it("reports absent mappings on ignore providers and keeps explicit exclusions silent", async () => {
151
+ const provider = create();
152
+ if (provider.default !== "ignore") return;
153
+ reset();
154
+ const errors = [];
155
+ const tracking = createTracking$1({
156
+ providers: [{
157
+ ...provider,
158
+ events: { explicit: null }
159
+ }],
160
+ onError: (error) => errors.push(error)
161
+ });
162
+ await tracking.track("missing", {}, context());
163
+ await tracking.track("explicit", {}, context());
164
+ expect(errors).toMatchObject([{
165
+ severity: "warning",
166
+ event: "missing"
167
+ }]);
168
+ expect(captured()).toEqual([]);
169
+ });
123
170
  it("sends the same thing twice for the same event", async () => {
124
171
  reset();
125
- await create().track("purchase", STABLE_DATA, context(), noop);
172
+ await create().track(target, STABLE_DATA, context(), noop);
126
173
  const first = JSON.stringify(captured());
127
174
  reset();
128
- await create().track("purchase", STABLE_DATA, context(), noop);
175
+ await create().track(target, STABLE_DATA, context(), noop);
129
176
  expect(JSON.stringify(captured())).toBe(first);
130
177
  });
131
178
  it("forwards the deduplication id only when it was given one", async () => {
132
179
  reset();
133
- await create().track("purchase", STABLE_DATA, context(), noop);
180
+ await create().track(target, STABLE_DATA, context(), noop);
134
181
  const without = JSON.stringify(captured());
135
182
  reset();
136
- await create().track("purchase", STABLE_DATA, {
183
+ await create().track(target, STABLE_DATA, {
137
184
  ...context(),
138
185
  dedupId: "DEDUP-XYZ"
139
186
  }, noop);
@@ -141,10 +188,20 @@ function conformsAsServerProvider(options) {
141
188
  if (with_ !== without) expect(with_).toContain("DEDUP-XYZ");
142
189
  expect(without).not.toContain("DEDUP-XYZ");
143
190
  });
144
- it("throws when the vendor did not accept the send", async () => {
191
+ it("throws a VendorResponseError when the vendor did not accept the send", async () => {
145
192
  reset();
146
193
  fail();
147
- await expect(create().track("purchase", STABLE_DATA, context(), noop)).rejects.toThrow();
194
+ let caught;
195
+ try {
196
+ await create().track(target, STABLE_DATA, context(), noop);
197
+ } catch (error) {
198
+ caught = error;
199
+ }
200
+ expect(isVendorResponseError(caught)).toBe(true);
201
+ if (isVendorResponseError(caught)) {
202
+ expect(typeof caught.status).toBe("number");
203
+ expect(typeof caught.body).toBe("string");
204
+ }
148
205
  });
149
206
  }
150
207
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"conformance.js","names":[],"sources":["../src/conformance.ts"],"sourcesContent":["import { expect, it } from 'vitest';\nimport type { BrowserProvider } from './browser.js';\nimport type { ResolvedContext, ServerProvider } from './server.js';\nimport type { EventData, ProviderDefault } from './vocabulary.js';\n\n/**\n * The behaviour every provider owes, which the types cannot express.\n *\n * ADR-0004 requires this suite because a provider can satisfy\n * {@link BrowserProvider} or {@link ServerProvider} perfectly and still break\n * a rule the whole library rests on. The interface cannot say \"never invent an\n * identifier\" or \"never expand a consent signal\"; a test can.\n *\n * These are invariants, not vendor behaviour. What GA4 sends and how it spells\n * it belongs in the GA4 tests. What follows must hold for a provider written\n * by anyone, for a vendor nobody here has heard of.\n *\n * **Published as `tracklane/conformance`**, because the claim that a provider\n * written elsewhere uses the same contract as the ones shipped here is only\n * true if it can run the same checks. `vitest` is an optional peer: import this\n * from a test file and call one of the two functions below inside a `describe`.\n *\n * **A wrapper owes the same invariants as a provider.** Wrapping a shipped\n * provider and delegating (see the provider documentation) produces an object\n * that the dispatcher cannot tell apart from any other, so run it through here\n * too. On the server half that catches the expensive mistake: a wrapper whose\n * `track` does not `await` the provider it delegates to resolves before the\n * request settles, which a serverless runtime turns into a conversion that\n * never left. {@link conformsAsServerProvider} fails on it, because a vendor\n * refusal can no longer reach the caller.\n */\n\nconst DEFAULTS: ProviderDefault[] = ['passthrough', 'ignore'];\n\n/** A payload with nothing in it that could excuse a difference between runs. */\nconst STABLE_DATA: EventData = { transaction_id: 'T-1', value: 49.9, currency: 'BRL' };\n\nfunction assertIdentity(provider: { name: string; default: ProviderDefault }): void {\n it('names itself, once and stably', () => {\n // The name is what a duplicate registration is detected by, and what an\n // error report is attributed to.\n expect(provider.name).toBeTruthy();\n expect(provider.name.trim()).toBe(provider.name);\n });\n\n it('declares what happens to an event it has no binding for', () => {\n expect(DEFAULTS).toContain(provider.default);\n });\n}\n\n/**\n * Runs the shared conformance suite against a browser provider.\n *\n * @param create - Builds the provider under test. Called per assertion, so a\n * provider that accumulates state between runs fails rather than hides.\n * @param captured - Everything the provider handed the vendor since the last\n * reset, in order.\n * @param reset - Clears what `captured` returns.\n *\n * @example\n * ```ts\n * describe('ga4 browser conformance', () => {\n * conformsAsBrowserProvider({\n * create: () => ga4('G-ABC'),\n * captured: () => calls,\n * reset: () => { calls = []; },\n * });\n * });\n * ```\n */\nexport function conformsAsBrowserProvider(options: {\n create: () => BrowserProvider;\n captured: () => unknown[];\n reset: () => void;\n}): void {\n const { create, captured, reset } = options;\n\n assertIdentity(create());\n\n it('sends the same thing twice for the same event', () => {\n // The library never invents an identifier, and this is how that rule is\n // caught rather than trusted: a generated id, a random value or a clock\n // read makes two identical calls differ.\n reset();\n create().track('purchase', STABLE_DATA, {});\n const first = JSON.stringify(captured());\n\n reset();\n create().track('purchase', STABLE_DATA, {});\n const second = JSON.stringify(captured());\n\n expect(second).toBe(first);\n });\n\n it('forwards the deduplication id only when it was given one', () => {\n reset();\n create().track('purchase', STABLE_DATA, {});\n const without = JSON.stringify(captured());\n\n reset();\n create().track('purchase', STABLE_DATA, { dedupId: 'DEDUP-XYZ' });\n const with_ = JSON.stringify(captured());\n\n // Either the vendor has the concept, and the value appears where it did\n // not before, or it has none and nothing changes. Inventing one to fill\n // the slot is the failure this catches.\n if (with_ !== without) expect(with_).toContain('DEDUP-XYZ');\n expect(without).not.toContain('DEDUP-XYZ');\n });\n\n it('never expands a consent signal it was not given', () => {\n const provider = create();\n if (!provider.consent) return;\n\n reset();\n provider.consent('update', { analytics_storage: 'denied' });\n\n // One denial in must never become a grant out. Collapsing Google's\n // vocabulary for a coarser vendor is translation; widening it invents a\n // permission the visitor never gave.\n expect(JSON.stringify(captured())).not.toContain('granted');\n });\n}\n\n/**\n * Runs the shared conformance suite against a server provider.\n *\n * @param create - Builds the provider under test, per assertion.\n * @param captured - Everything the provider sent since the last reset.\n * @param reset - Clears what `captured` returns, and restores a successful\n * transport.\n * @param fail - Makes the next send fail at the transport, so the suite can\n * check that the provider is loud about it.\n * @param context - A resolved context with a fixed timestamp.\n *\n * @example\n * ```ts\n * describe('ga4 server conformance', () => {\n * conformsAsServerProvider({\n * create: () => ga4(credentials),\n * captured: () => fetchMock.mock.calls,\n * reset: () => { fetchMock.mockClear(); },\n * fail: () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); },\n * context,\n * });\n * });\n * ```\n */\nexport function conformsAsServerProvider(options: {\n create: () => ServerProvider;\n captured: () => unknown[];\n reset: () => void;\n fail: () => void;\n context: () => ResolvedContext;\n}): void {\n const { create, captured, reset, fail, context } = options;\n const noop = (): void => {};\n\n assertIdentity(create());\n\n it('sends the same thing twice for the same event', async () => {\n // The context carries a fixed timestamp, so any difference between these\n // two is something the provider produced on its own.\n reset();\n await create().track('purchase', STABLE_DATA, context(), noop);\n const first = JSON.stringify(captured());\n\n reset();\n await create().track('purchase', STABLE_DATA, context(), noop);\n\n expect(JSON.stringify(captured())).toBe(first);\n });\n\n it('forwards the deduplication id only when it was given one', async () => {\n reset();\n await create().track('purchase', STABLE_DATA, context(), noop);\n const without = JSON.stringify(captured());\n\n reset();\n await create().track('purchase', STABLE_DATA, { ...context(), dedupId: 'DEDUP-XYZ' }, noop);\n const with_ = JSON.stringify(captured());\n\n if (with_ !== without) expect(with_).toContain('DEDUP-XYZ');\n expect(without).not.toContain('DEDUP-XYZ');\n });\n\n it('throws when the vendor did not accept the send', async () => {\n // Adapters stay honest and loud. Swallowing a non-2xx is how a host ends\n // up believing it is measuring something, and the dispatcher is already\n // there to isolate the throw and report it.\n reset();\n fail();\n\n await expect(create().track('purchase', STABLE_DATA, context(), noop)).rejects.toThrow();\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"conformance.js","names":["createBrowserTracking","createServerTracking"],"sources":["../src/conformance.ts"],"sourcesContent":["import { expect, it } from 'vitest';\nimport { type BrowserProvider, createTracking as createBrowserTracking } from './browser.js';\nimport { isVendorResponseError } from './errors.js';\nimport {\n createTracking as createServerTracking,\n type ResolvedContext,\n type ServerProvider,\n} from './server.js';\nimport type { EventData, ProviderDefault } from './vocabulary.js';\n\n/**\n * The behaviour every provider owes, which the types cannot express.\n *\n * ADR-0004 requires this suite because a provider can satisfy\n * {@link BrowserProvider} or {@link ServerProvider} perfectly and still break\n * a rule the whole library rests on. The interface cannot say \"never invent an\n * identifier\" or \"never expand a consent signal\"; a test can.\n *\n * These are invariants, not vendor behaviour. What GA4 sends and how it spells\n * it belongs in the GA4 tests. What follows must hold for a provider written\n * by anyone, for a vendor nobody here has heard of.\n *\n * **Published as `tracklane/conformance`**, because the claim that a provider\n * written elsewhere uses the same contract as the ones shipped here is only\n * true if it can run the same checks. `vitest` is an optional peer: import this\n * from a test file and call one of the two functions below inside a `describe`.\n *\n * **A wrapper owes the same invariants as a provider.** Wrapping a shipped\n * provider and delegating (see the provider documentation) produces an object\n * that the dispatcher cannot tell apart from any other, so run it through here\n * too. On the server half that catches the expensive mistake: a wrapper whose\n * `track` does not `await` the provider it delegates to resolves before the\n * request settles, which a serverless runtime turns into a conversion that\n * never left. {@link conformsAsServerProvider} fails on it, because a vendor\n * refusal can no longer reach the caller.\n */\n\nconst DEFAULTS: ProviderDefault[] = ['passthrough', 'ignore'];\n\n/** A payload with nothing in it that could excuse a difference between runs. */\nconst STABLE_DATA: EventData = { transaction_id: 'T-1', value: 49.9, currency: 'BRL' };\n\nfunction assertIdentity(provider: { name: string; default: ProviderDefault }): void {\n it('names itself, once and stably', () => {\n // The name is what a duplicate registration is detected by, and what an\n // error report is attributed to.\n expect(provider.name).toBeTruthy();\n expect(provider.name.trim()).toBe(provider.name);\n });\n\n it('declares what happens to an event it has no binding for', () => {\n expect(DEFAULTS).toContain(provider.default);\n });\n}\n\n/**\n * Runs the shared conformance suite against a browser provider.\n *\n * @param create - Builds the provider under test. Called per assertion, so a\n * provider that accumulates state between runs fails rather than hides.\n * @param captured - Everything the provider handed the vendor since the last\n * reset, in order.\n * @param reset - Clears what `captured` returns.\n * @param target - An already-resolved vendor target. Defaults to `purchase`.\n *\n * @example\n * ```ts\n * describe('ga4 browser conformance', () => {\n * conformsAsBrowserProvider({\n * create: () => ga4('G-ABC'),\n * captured: () => calls,\n * reset: () => { calls = []; },\n * });\n * });\n * ```\n */\nexport function conformsAsBrowserProvider(options: {\n create: () => BrowserProvider;\n captured: () => unknown[];\n reset: () => void;\n /** Already-resolved vendor target; conversion-ID providers use their configured ID. */\n target?: string;\n}): void {\n const { create, captured, reset } = options;\n const target = options.target ?? 'purchase';\n\n assertIdentity(create());\n\n it('reports absent mappings on ignore providers and keeps explicit exclusions silent', () => {\n // Mapping assertions do not exercise the host's installation hook.\n const { install: _install, ...provider } = create();\n if (provider.default !== 'ignore') return;\n reset();\n const errors: { severity: string; event: string }[] = [];\n const tracking = createBrowserTracking({\n providers: [{ ...provider, events: { explicit: null } }],\n onError: (error) => errors.push(error),\n });\n tracking.track('missing');\n tracking.track('explicit');\n expect(errors).toMatchObject([{ severity: 'warning', event: 'missing' }]);\n expect(captured()).toEqual([]);\n });\n\n it('sends the same thing twice for the same event', () => {\n // The library never invents an identifier, and this is how that rule is\n // caught rather than trusted: a generated id, a random value or a clock\n // read makes two identical calls differ.\n reset();\n create().track(target, STABLE_DATA, {});\n const first = JSON.stringify(captured());\n\n reset();\n create().track(target, STABLE_DATA, {});\n const second = JSON.stringify(captured());\n\n expect(second).toBe(first);\n });\n\n it('forwards the deduplication id only when it was given one', () => {\n reset();\n create().track(target, STABLE_DATA, {});\n const without = JSON.stringify(captured());\n\n reset();\n create().track(target, STABLE_DATA, { dedupId: 'DEDUP-XYZ' });\n const with_ = JSON.stringify(captured());\n\n // Either the vendor has the concept, and the value appears where it did\n // not before, or it has none and nothing changes. Inventing one to fill\n // the slot is the failure this catches.\n if (with_ !== without) expect(with_).toContain('DEDUP-XYZ');\n expect(without).not.toContain('DEDUP-XYZ');\n });\n\n it('never expands a consent signal it was not given', () => {\n const provider = create();\n if (!provider.consent) return;\n\n reset();\n provider.consent('update', { analytics_storage: 'denied' });\n\n // One denial in must never become a grant out. Collapsing Google's\n // vocabulary for a coarser vendor is translation; widening it invents a\n // permission the visitor never gave.\n expect(JSON.stringify(captured())).not.toContain('granted');\n });\n}\n\n/**\n * Runs the shared conformance suite against a server provider.\n *\n * @param create - Builds the provider under test, per assertion.\n * @param captured - Everything the provider sent since the last reset.\n * @param reset - Clears what `captured` returns, and restores a successful\n * transport.\n * @param fail - Makes the next send fail at the transport, so the suite can\n * check that the provider is loud about it.\n * @param context - A resolved context with a fixed timestamp.\n * @param target - An already-resolved vendor target. Defaults to `purchase`.\n *\n * @example\n * ```ts\n * describe('ga4 server conformance', () => {\n * conformsAsServerProvider({\n * create: () => ga4(credentials),\n * captured: () => fetchMock.mock.calls,\n * reset: () => { fetchMock.mockClear(); },\n * fail: () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); },\n * context,\n * });\n * });\n * ```\n */\nexport function conformsAsServerProvider(options: {\n create: () => ServerProvider;\n captured: () => unknown[];\n reset: () => void;\n fail: () => void;\n context: () => ResolvedContext;\n /** Already-resolved vendor target; conversion-ID providers use their configured ID. */\n target?: string;\n}): void {\n const { create, captured, reset, fail, context } = options;\n const target = options.target ?? 'purchase';\n const noop = (): void => {};\n\n assertIdentity(create());\n\n it('reports absent mappings on ignore providers and keeps explicit exclusions silent', async () => {\n const provider = create();\n if (provider.default !== 'ignore') return;\n reset();\n const errors: { severity: string; event: string }[] = [];\n const tracking = createServerTracking({\n providers: [{ ...provider, events: { explicit: null } }],\n onError: (error) => errors.push(error),\n });\n await tracking.track('missing', {}, context());\n await tracking.track('explicit', {}, context());\n expect(errors).toMatchObject([{ severity: 'warning', event: 'missing' }]);\n expect(captured()).toEqual([]);\n });\n\n it('sends the same thing twice for the same event', async () => {\n // The context carries a fixed timestamp, so any difference between these\n // two is something the provider produced on its own.\n reset();\n await create().track(target, STABLE_DATA, context(), noop);\n const first = JSON.stringify(captured());\n\n reset();\n await create().track(target, STABLE_DATA, context(), noop);\n\n expect(JSON.stringify(captured())).toBe(first);\n });\n\n it('forwards the deduplication id only when it was given one', async () => {\n reset();\n await create().track(target, STABLE_DATA, context(), noop);\n const without = JSON.stringify(captured());\n\n reset();\n await create().track(target, STABLE_DATA, { ...context(), dedupId: 'DEDUP-XYZ' }, noop);\n const with_ = JSON.stringify(captured());\n\n if (with_ !== without) expect(with_).toContain('DEDUP-XYZ');\n expect(without).not.toContain('DEDUP-XYZ');\n });\n\n it('throws a VendorResponseError when the vendor did not accept the send', async () => {\n // Adapters stay honest and loud. Swallowing a non-2xx is how a host ends\n // up believing it is measuring something, and the dispatcher is already\n // there to isolate the throw and report it. ADR-0013 adds the shape: what\n // reaches the host is recognised by `isVendorResponseError`, carrying the\n // status and the body, not just any throw. The body itself is\n // the caller's `fail()` hook to produce; a bodiless response is valid\n // (the documented example does exactly that), so it is not asserted here.\n reset();\n fail();\n\n let caught: unknown;\n try {\n await create().track(target, STABLE_DATA, context(), noop);\n } catch (error) {\n caught = error;\n }\n\n expect(isVendorResponseError(caught)).toBe(true);\n if (isVendorResponseError(caught)) {\n expect(typeof caught.status).toBe('number');\n expect(typeof caught.body).toBe('string');\n }\n });\n}\n"],"mappings":"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"}
@@ -21,12 +21,15 @@ function assertUniqueProviders(providers) {
21
21
  * event identity is minted per account in its own dashboard, so there is no
22
22
  * name that could possibly be guessed.
23
23
  */
24
- function resolveEvent(provider, name) {
24
+ function resolveEvent(provider, name, report) {
25
25
  const events = provider.events;
26
26
  const binding = events && Object.hasOwn(events, name) ? events[name] : void 0;
27
27
  if (binding === null) return void 0;
28
28
  if (binding !== void 0) return binding;
29
- if (provider.default === "ignore") return void 0;
29
+ if (provider.default === "ignore") {
30
+ report?.("event omitted: no conversion mapping; add a binding or null for an explicit exclusion");
31
+ return;
32
+ }
30
33
  return name;
31
34
  }
32
35
  /**
@@ -48,4 +51,4 @@ function messageOf(cause) {
48
51
  //#endregion
49
52
  export { resolveEvent as i, makeReporter as n, messageOf as r, assertUniqueProviders as t };
50
53
 
51
- //# sourceMappingURL=dispatch-BLBoCn-b.js.map
54
+ //# sourceMappingURL=dispatch-B32jGhlh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dispatch-B32jGhlh.js","names":[],"sources":["../src/dispatch.ts"],"sourcesContent":["import type { EventBinding, ProviderDefault, TrackingError } from './vocabulary.js';\n\n/** The part of a provider both libraries resolve event names against. */\ninterface Resolvable<Target> {\n name: string;\n default: ProviderDefault;\n events?: Record<string, EventBinding<Target>>;\n}\n\n/**\n * Registering one vendor twice is a hard error rather than a merge: there is\n * nothing a second registration could say that the first cannot, and\n * accepting both would double every event to that vendor, a defect that\n * surfaces weeks later as inflated conversions.\n */\nexport function assertUniqueProviders(providers: readonly { name: string }[]): void {\n const seen = new Set<string>();\n\n for (const provider of providers) {\n if (seen.has(provider.name)) {\n throw new Error(\n `tracklane: provider \"${provider.name}\" is registered twice. One configuration per vendor.`,\n );\n }\n seen.add(provider.name);\n }\n}\n\n/**\n * Resolves how a vendor spells a canonical event, or `undefined` when\n * nothing should be sent.\n *\n * Not sending is a legitimate outcome rather than a failure: `null` is the\n * host saying \"never send this event here\", and `ignore` is a vendor whose\n * event identity is minted per account in its own dashboard, so there is no\n * name that could possibly be guessed.\n */\nexport function resolveEvent<Target>(\n provider: Resolvable<Target>,\n name: string,\n report?: (message: string) => void,\n): Target | undefined {\n const events = provider.events;\n // `hasOwn`, not a plain lookup: every object inherits `constructor` and\n // `toString`, so an event named either would otherwise resolve to a\n // function off the prototype chain.\n const binding = events && Object.hasOwn(events, name) ? events[name] : undefined;\n\n if (binding === null) return undefined;\n if (binding !== undefined) return binding;\n if (provider.default === 'ignore') {\n report?.(\n 'event omitted: no conversion mapping; add a binding or null for an explicit exclusion',\n );\n return undefined;\n }\n\n return name as Target;\n}\n\n/**\n * Builds the reporting channel. A host that passes no `onError` gets\n * silence, which is what a hand-written vendor call gives it.\n */\nexport function makeReporter(\n onError: ((error: TrackingError) => void) | undefined,\n): (error: TrackingError) => void {\n if (!onError) return () => {};\n\n return (error) => {\n // A throwing reporter must not take the dispatch down with it, or a\n // host's broken logger becomes a broken checkout.\n try {\n onError(error);\n } catch {\n /* the reporter is the last line; there is nowhere left to report to */\n }\n };\n}\n\n/** Normalises whatever a provider threw into a reportable message. */\nexport function messageOf(cause: unknown): string {\n return cause instanceof Error ? cause.message : String(cause);\n}\n"],"mappings":";;;;;;;AAeA,SAAgB,sBAAsB,WAA8C;CAClF,MAAM,uBAAO,IAAI,IAAY;CAE7B,KAAK,MAAM,YAAY,WAAW;EAChC,IAAI,KAAK,IAAI,SAAS,IAAI,GACxB,MAAM,IAAI,MACR,wBAAwB,SAAS,KAAK,qDACxC;EAEF,KAAK,IAAI,SAAS,IAAI;CACxB;AACF;;;;;;;;;;AAWA,SAAgB,aACd,UACA,MACA,QACoB;CACpB,MAAM,SAAS,SAAS;CAIxB,MAAM,UAAU,UAAU,OAAO,OAAO,QAAQ,IAAI,IAAI,OAAO,QAAQ,KAAA;CAEvE,IAAI,YAAY,MAAM,OAAO,KAAA;CAC7B,IAAI,YAAY,KAAA,GAAW,OAAO;CAClC,IAAI,SAAS,YAAY,UAAU;EACjC,SACE,uFACF;EACA;CACF;CAEA,OAAO;AACT;;;;;AAMA,SAAgB,aACd,SACgC;CAChC,IAAI,CAAC,SAAS,aAAa,CAAC;CAE5B,QAAQ,UAAU;EAGhB,IAAI;GACF,QAAQ,KAAK;EACf,QAAQ,CAER;CACF;AACF;;AAGA,SAAgB,UAAU,OAAwB;CAChD,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D"}