tracklane 0.6.0 → 0.8.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.
@@ -0,0 +1,822 @@
1
+ import { t as VendorResponseError } from "./src-DLRGPWMj.js";
2
+ import { i as resolveEvent, n as makeReporter, r as messageOf, t as assertUniqueProviders } from "./dispatch-B32jGhlh.js";
3
+ //#region src/providers/ga4.server.ts
4
+ const ENDPOINT = "https://www.google-analytics.com/mp/collect";
5
+ /**
6
+ * The visitor cookie holds `GA1.<depth>.<client_id>`, and the client id
7
+ * itself contains a dot, so the identifier is the last two segments, which
8
+ * is what survives Google's varying prefix depth.
9
+ *
10
+ * The value must be the cookie as the browser wrote it. A host passing a
11
+ * parsed map passes cookie values, not values it took apart itself.
12
+ */
13
+ function visitorId(cookies) {
14
+ const raw = cookies._ga;
15
+ if (!raw) return void 0;
16
+ const parts = raw.split(".");
17
+ if (parts.length < 4) return void 0;
18
+ return parts.slice(-2).join(".");
19
+ }
20
+ /**
21
+ * The per-container cookie carries the session, in one of two layouts.
22
+ *
23
+ * Verified against Google's live tag on 2026-07-31 rather than assumed:
24
+ *
25
+ * - `GS2.1.s1785511401$o1$g0$t1785511401$j60$l0$h0`, the current one. The
26
+ * third dot-segment packs several fields joined by `$`, and the session is
27
+ * the first of them, prefixed with `s`.
28
+ * - `GS1.1.1785511401.1.1.…`, the older one, where the third dot-segment is
29
+ * the session on its own.
30
+ *
31
+ * Reading the third segment blindly works for the old layout and returns the
32
+ * whole `s…$o1$g0$…` blob for the new one, which the vendor would accept and
33
+ * silently fail to match. Hence the two shapes, and hence `undefined` rather
34
+ * than a guess when neither fits.
35
+ */
36
+ function sessionId(cookies, measurementId) {
37
+ const segment = cookies[`_ga_${measurementId.replace(/^G-/, "")}`]?.split(".")[2];
38
+ if (!segment) return void 0;
39
+ const packed = /^s(\d+)/.exec(segment);
40
+ if (packed) return packed[1];
41
+ return /^\d+$/.test(segment) ? segment : void 0;
42
+ }
43
+ /**
44
+ * The Measurement Protocol documents exactly two consent fields, and spells
45
+ * their values in upper case where the browser spells them in lower, one of
46
+ * the small asymmetries an adapter absorbs so nobody else has to.
47
+ */
48
+ function consentBody(state) {
49
+ if (!state) return void 0;
50
+ const body = {};
51
+ if (state.ad_user_data) body.ad_user_data = state.ad_user_data.toUpperCase();
52
+ if (state.ad_personalization) body.ad_personalization = state.ad_personalization.toUpperCase();
53
+ return Object.keys(body).length > 0 ? body : void 0;
54
+ }
55
+ /**
56
+ * The Measurement Protocol's documented limits: "User property names must be
57
+ * 24 characters or fewer", "User property values must be 36 characters or
58
+ * fewer", and at most 25 user properties.
59
+ */
60
+ const MAX_PROPERTY_NAME = 24;
61
+ const MAX_PROPERTY_VALUE = 36;
62
+ const MAX_PROPERTIES = 25;
63
+ /**
64
+ * Traits as GA4 user properties. What GA4 cannot hold is left out and
65
+ * reported by name, never replaced: a clear or a truncation would be a value
66
+ * the host did not send. `null` is left out too, because clearing through
67
+ * this protocol is not verified yet (ADR-0014).
68
+ */
69
+ function userProperties(traits, traitsAt, report) {
70
+ if (!traits) return void 0;
71
+ const built = {};
72
+ const unclearable = [];
73
+ const tooLong = [];
74
+ for (const [name, value] of Object.entries(traits)) if (value === null) unclearable.push(name);
75
+ else if (name.length > MAX_PROPERTY_NAME || typeof value === "string" && value.length > MAX_PROPERTY_VALUE) tooLong.push(name);
76
+ else built[name] = {
77
+ value,
78
+ ...traitsAt === void 0 ? {} : { timestamp_micros: traitsAt * 1e3 }
79
+ };
80
+ if (unclearable.length > 0) report(`user properties not cleared, because clearing one through the Measurement Protocol is not verified: ${unclearable.join(", ")}`);
81
+ if (tooLong.length > 0) report(`user properties left out, over GA4's ${MAX_PROPERTY_NAME}-character name or ${MAX_PROPERTY_VALUE}-character value limit: ${tooLong.join(", ")}`);
82
+ if (Object.keys(built).length > MAX_PROPERTIES) report(`more than ${MAX_PROPERTIES} user properties, GA4's limit: all were sent, and Google decides which it keeps`);
83
+ return Object.keys(built).length > 0 ? built : void 0;
84
+ }
85
+ async function sha256$2(value) {
86
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
87
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
88
+ }
89
+ /**
90
+ * Google's documented normalisation for user-provided data, which differs
91
+ * from Meta's and LinkedIn's: Gmail addresses lose the dots before `@`, and
92
+ * phones keep a leading `+`.
93
+ */
94
+ function normaliseEmail$1(value) {
95
+ const email = value.replace(/\s/g, "").toLowerCase();
96
+ const [local, domain] = email.split("@");
97
+ if (local === void 0 || domain === void 0) return email;
98
+ return domain === "gmail.com" || domain === "googlemail.com" ? `${local.replace(/\./g, "")}@${domain}` : email;
99
+ }
100
+ function normalisePhone$1(value) {
101
+ const digits = value.replace(/\D/g, "");
102
+ return digits ? `+${digits}` : "";
103
+ }
104
+ function normaliseName$1(value) {
105
+ return value.replace(/[^\p{L}\s]/gu, "").toLowerCase().trim();
106
+ }
107
+ async function userData$2(user) {
108
+ if (!user) return void 0;
109
+ const built = {};
110
+ const hashed = async (raw, normalise) => {
111
+ const normalised = raw === void 0 ? "" : normalise(raw);
112
+ return normalised ? sha256$2(normalised) : void 0;
113
+ };
114
+ const email = await hashed(user.email, normaliseEmail$1);
115
+ if (email) built.sha256_email_address = [email];
116
+ const phone = await hashed(user.phone, normalisePhone$1);
117
+ if (phone) built.sha256_phone_number = [phone];
118
+ const firstName = await hashed(user.firstName, normaliseName$1);
119
+ const lastName = await hashed(user.lastName, normaliseName$1);
120
+ if (firstName || lastName) built.address = [{
121
+ ...firstName ? { sha256_first_name: firstName } : {},
122
+ ...lastName ? { sha256_last_name: lastName } : {}
123
+ }];
124
+ return Object.keys(built).length > 0 ? built : void 0;
125
+ }
126
+ /**
127
+ * Google Analytics 4 on the server, through the Measurement Protocol.
128
+ *
129
+ * **`identify` sends user properties with no event**, which Google
130
+ * documents. With no user property to send it sends nothing, because each
131
+ * `track` already carries `user_id`.
132
+ *
133
+ * @param credentials - The measurement id and API secret.
134
+ * @param config - Rarely needed event name overrides, and the opt-in for
135
+ * user-provided data.
136
+ * @returns A provider for `createTracking` from `tracklane/server`.
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * import { createTracking, ga4 } from 'tracklane/server';
141
+ *
142
+ * const { track } = createTracking({
143
+ * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret: process.env.GA4_SECRET! })],
144
+ * });
145
+ *
146
+ * await track('purchase', order, { cookies: request.headers.get('cookie') });
147
+ * ```
148
+ */
149
+ function ga4(credentials, config = {}) {
150
+ const { measurementId, apiSecret } = credentials;
151
+ const clientIdOf = (context) => {
152
+ const clientId = visitorId(context.cookies);
153
+ if (!clientId) throw new Error("ga4: cannot build client_id, no _ga cookie among the cookies passed in context. Ensure GA4's browser tag has set it first.");
154
+ return clientId;
155
+ };
156
+ const person = async (context, report) => {
157
+ const body = {
158
+ client_id: clientIdOf(context),
159
+ timestamp_micros: context.timestamp * 1e3
160
+ };
161
+ if (context.user?.userId !== void 0) body.user_id = context.user.userId;
162
+ const properties = userProperties(context.traits, context.traitsAt, report);
163
+ if (properties) body.user_properties = properties;
164
+ if (config.userProvidedData) {
165
+ const data = await userData$2(context.user);
166
+ if (data) body.user_data = data;
167
+ }
168
+ const consent = consentBody(context.consent);
169
+ if (consent) body.consent = consent;
170
+ return body;
171
+ };
172
+ const send = async (body, signal) => {
173
+ const url = `${ENDPOINT}?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(apiSecret)}`;
174
+ const response = await fetch(url, {
175
+ method: "POST",
176
+ headers: { "content-type": "application/json" },
177
+ body: JSON.stringify(body),
178
+ ...signal ? { signal } : {}
179
+ });
180
+ if (!response.ok) {
181
+ const text = await response.text().catch(() => "");
182
+ throw new VendorResponseError(`ga4: the Measurement Protocol answered ${response.status}`, {
183
+ provider: "ga4",
184
+ status: response.status,
185
+ body: text
186
+ });
187
+ }
188
+ };
189
+ return {
190
+ name: "ga4",
191
+ default: "passthrough",
192
+ ...config.events ? { events: config.events } : {},
193
+ async track(name, data, context, report) {
194
+ const body = await person(context, report);
195
+ const session = sessionId(context.cookies, measurementId);
196
+ if (!session) report("no session cookie (_ga_<container>): Google will accept this event and it will not appear in any report");
197
+ const params = { ...data };
198
+ if (session) {
199
+ params.session_id = session;
200
+ params.engagement_time_msec ??= 1;
201
+ }
202
+ await send({
203
+ ...body,
204
+ events: [{
205
+ name,
206
+ params
207
+ }]
208
+ }, context.signal);
209
+ },
210
+ async identify(context, report) {
211
+ if (context.user?.userId === void 0) throw new Error("ga4: identify needs user.userId");
212
+ const body = await person(context, report);
213
+ if (!body.user_properties) return;
214
+ await send(body, context.signal);
215
+ }
216
+ };
217
+ }
218
+ //#endregion
219
+ //#region src/providers/linkedin.server.ts
220
+ async function sha256$1(value) {
221
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
222
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
223
+ }
224
+ function ipv4(value) {
225
+ const parts = value.split(".");
226
+ return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
227
+ }
228
+ async function userData$1(context) {
229
+ const user = context.user ?? {};
230
+ const userIds = [];
231
+ const built = { userIds };
232
+ const email = user.email?.replace(/\s/g, "").toLowerCase();
233
+ if (email) userIds.push({
234
+ idType: "SHA256_EMAIL",
235
+ idValue: await sha256$1(email)
236
+ });
237
+ const clickId = context.cookies.li_fat_id;
238
+ if (clickId) try {
239
+ userIds.push({
240
+ idType: "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID",
241
+ idValue: decodeURIComponent(clickId)
242
+ });
243
+ } catch {}
244
+ if (context.ip && ipv4(context.ip)) userIds.push({
245
+ idType: "PLAINTEXT_IP_ADDRESS",
246
+ idValue: context.ip
247
+ });
248
+ if (user.firstName && user.lastName) built.userInfo = {
249
+ firstName: user.firstName,
250
+ lastName: user.lastName
251
+ };
252
+ if (user.userId) built.externalIds = [user.userId];
253
+ if (userIds.length === 0 && built.userInfo === void 0 && built.externalIds === void 0) throw new Error("linkedin: the Conversions API requires a user identifier, full name, or external ID");
254
+ return built;
255
+ }
256
+ /**
257
+ * Sends event-specific conversions to LinkedIn's Conversions API.
258
+ * Value and currency become conversionValue; items, transaction_id, custom
259
+ * properties, traits and consent have no documented field on this endpoint.
260
+ * The host's deduplication ID becomes eventId, without generating one.
261
+ *
262
+ * @param credentials - The ad account's Conversions API access token.
263
+ * @param config - Canonical event to conversion ID map and API version.
264
+ * @returns A server provider for `createTracking`.
265
+ * @example
266
+ * ```ts
267
+ * import { createTracking, linkedin } from 'tracklane/server';
268
+ * const { track } = createTracking({
269
+ * providers: [linkedin({ accessToken }, { events: { purchase: '12345' } })],
270
+ * });
271
+ * await track('purchase', { value: 15, currency: 'BRL' }, {
272
+ * user: { email: 'buyer@example.com' }, dedupId: 'order-1',
273
+ * });
274
+ * ```
275
+ */
276
+ function linkedin(credentials, config) {
277
+ const { apiVersion = "202609" } = config;
278
+ return {
279
+ name: "linkedin",
280
+ default: "ignore",
281
+ events: config.events,
282
+ async track(name, data, context, report) {
283
+ if (!/^\d+$/.test(name) || BigInt(name) <= 0n) throw new Error("linkedin: conversion must identify a positive conversion ID from Campaign Manager");
284
+ const event = {
285
+ conversion: `urn:lla:llaPartnerConversion:${name}`,
286
+ conversionHappenedAt: context.timestamp,
287
+ user: await userData$1(context)
288
+ };
289
+ if (data.value !== void 0 && data.currency !== void 0) event.conversionValue = {
290
+ currencyCode: data.currency,
291
+ amount: String(data.value)
292
+ };
293
+ if (context.dedupId !== void 0) event.eventId = context.dedupId;
294
+ const response = await fetch("https://api.linkedin.com/rest/conversionEvents", {
295
+ method: "POST",
296
+ headers: {
297
+ Authorization: `Bearer ${credentials.accessToken}`,
298
+ "Content-Type": "application/json",
299
+ "Linkedin-Version": apiVersion,
300
+ "X-Restli-Protocol-Version": "2.0.0"
301
+ },
302
+ body: JSON.stringify(event),
303
+ ...context.signal ? { signal: context.signal } : {}
304
+ });
305
+ if (!response.ok) {
306
+ const body = await response.text().catch(() => "");
307
+ throw new VendorResponseError(`linkedin: the Conversions API answered ${response.status}`, {
308
+ provider: "linkedin",
309
+ status: response.status,
310
+ body
311
+ });
312
+ }
313
+ if (data.value !== void 0 && data.currency === void 0) report("conversionValue omitted: a monetary value requires currency; the conversion rule value applies");
314
+ }
315
+ };
316
+ }
317
+ //#endregion
318
+ //#region src/providers/meta.server.ts
319
+ const GRAPH = "https://graph.facebook.com";
320
+ /**
321
+ * Pinned on purpose. A Graph URL with no version resolves to the *oldest*
322
+ * version still available, which silently changes what the endpoint accepts
323
+ * the day Meta drops it.
324
+ */
325
+ const DEFAULT_API_VERSION = "v25.0";
326
+ /**
327
+ * How Meta spells the canonical vocabulary, which is GA4's. Kept in step with
328
+ * `meta.browser.ts` by hand: the two halves are two libraries, and neither
329
+ * imports the other.
330
+ */
331
+ const CANONICAL_EVENTS = {
332
+ page_view: "PageView",
333
+ view_item: "ViewContent",
334
+ search: "Search",
335
+ add_to_cart: "AddToCart",
336
+ add_to_wishlist: "AddToWishlist",
337
+ begin_checkout: "InitiateCheckout",
338
+ add_payment_info: "AddPaymentInfo",
339
+ purchase: "Purchase",
340
+ sign_up: "CompleteRegistration",
341
+ generate_lead: "Lead"
342
+ };
343
+ /**
344
+ * Web Crypto rather than `node:crypto`, so the built server bundle stays
345
+ * platform-neutral: this adapter is called from Cloudflare Workers, Deno and
346
+ * Vercel's edge runtime as often as from a Node process, and a `node:` import
347
+ * would rule those out for no gain.
348
+ */
349
+ async function sha256(value) {
350
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
351
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
352
+ }
353
+ /**
354
+ * **Why the adapter hashes and does not ask the host to.**
355
+ *
356
+ * This looks like the library transforming data, and the cutting rule forbids
357
+ * that. It is not. Meta does not define a field called "email" that happens
358
+ * to want a hash: it defines `em` *as* the SHA-256 of the trimmed, lowercased
359
+ * address, the same way it defines `event_time` as seconds rather than
360
+ * milliseconds. Writing this line by hand you write
361
+ * `sha256(email.trim().toLowerCase())`, and the adapter replicates exactly
362
+ * that. Normalisation lives with the field it belongs to.
363
+ *
364
+ * The alternative is worse and is where integrations demonstrably go wrong:
365
+ * three vendors want SHA-256 under three different normalisation rules, so a
366
+ * host hashing on its own side has to hash the same address three ways and
367
+ * discovers a mistake only as a quietly poor match rate.
368
+ */
369
+ const normaliseEmail = (value) => value.trim().toLowerCase();
370
+ /**
371
+ * "Remove symbols, letters, and any leading zeros." The country code is not
372
+ * added here: Meta wants one and the library does not know it, and guessing a
373
+ * country onto a phone number invents an identifier for a different person.
374
+ */
375
+ const normalisePhone = (value) => value.replace(/\D/g, "").replace(/^0+/, "");
376
+ /** "Lowercase only with no punctuation." Spaces and accents survive; Meta reads UTF-8. */
377
+ const normaliseName = (value) => value.trim().toLowerCase().replace(/[\p{P}\p{S}]/gu, "");
378
+ /**
379
+ * GA4's `items` become Meta's `contents`, a closed shape: `id`, `quantity`,
380
+ * `item_price`, `delivery_category`. The rest of a GA4 item is dropped rather
381
+ * than forwarded, because a key Meta does not read inside `contents` looks
382
+ * mapped and arrives nowhere.
383
+ */
384
+ function contents(items) {
385
+ if (items.some((item) => typeof item.item_id !== "string" || !item.item_id.trim())) return void 0;
386
+ return items.map((item) => {
387
+ const content = {};
388
+ if (item.item_id !== void 0) content.id = item.item_id;
389
+ if (item.quantity !== void 0) content.quantity = item.quantity;
390
+ if (item.price !== void 0) content.item_price = item.price;
391
+ return content;
392
+ });
393
+ }
394
+ /**
395
+ * `value` and `currency` are spelled identically by both vendors. Everything
396
+ * the host named itself passes through, because a business measures things
397
+ * this vocabulary does not name.
398
+ *
399
+ * `transaction_id` becomes `order_id`, which this surface documents and the
400
+ * pixel does not. It is one of the asymmetries between Meta's two halves an
401
+ * adapter absorbs so nobody else has to.
402
+ */
403
+ function customData(data) {
404
+ const { items, transaction_id: transactionId, ...rest } = data;
405
+ const custom = { ...rest };
406
+ if (transactionId !== void 0) custom.order_id = transactionId;
407
+ if (items) {
408
+ const mapped = contents(items);
409
+ if (mapped !== void 0) custom.contents = mapped;
410
+ else delete custom.contents;
411
+ }
412
+ return custom;
413
+ }
414
+ /**
415
+ * The heaviest surface of the five vendors: four hashed identifiers, four raw
416
+ * ones, and two cookies forwarded exactly as the pixel wrote them.
417
+ *
418
+ * Built in a fixed order, and hashed one after another rather than in
419
+ * parallel, so that the same input produces the same JSON on every run. Two
420
+ * identical events differing only in key order would be the library inventing
421
+ * something, which is precisely what the conformance suite looks for.
422
+ */
423
+ async function userData(context) {
424
+ const user = context.user ?? {};
425
+ const built = {};
426
+ const hashed = [
427
+ [
428
+ "em",
429
+ user.email,
430
+ normaliseEmail
431
+ ],
432
+ [
433
+ "ph",
434
+ user.phone,
435
+ normalisePhone
436
+ ],
437
+ [
438
+ "fn",
439
+ user.firstName,
440
+ normaliseName
441
+ ],
442
+ [
443
+ "ln",
444
+ user.lastName,
445
+ normaliseName
446
+ ]
447
+ ];
448
+ for (const [field, raw, normalise] of hashed) {
449
+ if (raw === void 0) continue;
450
+ const normalised = normalise(raw);
451
+ if (!normalised) continue;
452
+ built[field] = await sha256(normalised);
453
+ }
454
+ if (user.userId !== void 0) built.external_id = user.userId;
455
+ if (context.ip !== void 0) built.client_ip_address = context.ip;
456
+ if (context.userAgent !== void 0) built.client_user_agent = context.userAgent;
457
+ const fbp = context.cookies._fbp;
458
+ if (fbp) built.fbp = fbp;
459
+ const fbc = context.cookies._fbc;
460
+ if (fbc) built.fbc = fbc;
461
+ return built;
462
+ }
463
+ /**
464
+ * Meta's Conversions API.
465
+ *
466
+ * If any item lacks a nonempty `item_id`, the complete `contents` list is
467
+ * omitted while totals and order metadata remain unchanged. This avoids
468
+ * presenting a partial order or inventing product identifiers. The error
469
+ * channel receives one payload-free warning after HTTP acceptance.
470
+ *
471
+ * @param credentials - The pixel id and an access token.
472
+ * @param config - Event overrides, the default action source, and Meta's own
473
+ * levers: the API version, a test event code, and Limited Data Use.
474
+ * @returns A provider for `createTracking` from `tracklane/server`.
475
+ *
476
+ * @example
477
+ * ```ts
478
+ * import { createTracking, meta } from 'tracklane/server';
479
+ *
480
+ * const { track } = createTracking({
481
+ * providers: [meta({ pixelId: '1234567890', accessToken: process.env.META_TOKEN! })],
482
+ * });
483
+ *
484
+ * await track('purchase', order, {
485
+ * user: { email: order.email },
486
+ * cookies: request.headers.get('cookie'),
487
+ * dedupId: order.id,
488
+ * ip,
489
+ * userAgent,
490
+ * });
491
+ * ```
492
+ */
493
+ function meta(credentials, config = {}) {
494
+ const { pixelId, accessToken } = credentials;
495
+ const { actionSource = "website", apiVersion = DEFAULT_API_VERSION, testEventCode } = config;
496
+ return {
497
+ name: "meta",
498
+ default: "passthrough",
499
+ events: {
500
+ ...CANONICAL_EVENTS,
501
+ ...config.events
502
+ },
503
+ async track(name, data, context, report) {
504
+ const user = await userData(context);
505
+ if (Object.keys(user).length === 0) throw new Error("meta: cannot build user_data, the Conversions API requires at least one identifier. Pass context.user, context.ip with context.userAgent, or the _fbp/_fbc cookies.");
506
+ const event = {
507
+ event_name: name,
508
+ event_time: Math.floor(context.timestamp / 1e3),
509
+ action_source: context.source ?? actionSource,
510
+ user_data: user
511
+ };
512
+ if (context.dedupId !== void 0) event.event_id = context.dedupId;
513
+ if (context.url !== void 0) event.event_source_url = context.url;
514
+ const custom = customData(data);
515
+ if (Object.keys(custom).length > 0) event.custom_data = custom;
516
+ if (config.dataProcessingOptions) {
517
+ event.data_processing_options = config.dataProcessingOptions;
518
+ if (config.dataProcessingOptionsCountry !== void 0) event.data_processing_options_country = config.dataProcessingOptionsCountry;
519
+ if (config.dataProcessingOptionsState !== void 0) event.data_processing_options_state = config.dataProcessingOptionsState;
520
+ }
521
+ const body = new URLSearchParams({
522
+ data: JSON.stringify([event]),
523
+ access_token: accessToken
524
+ });
525
+ if (testEventCode) body.set("test_event_code", testEventCode);
526
+ const response = await fetch(`${GRAPH}/${apiVersion}/${encodeURIComponent(pixelId)}/events`, {
527
+ method: "POST",
528
+ headers: { "content-type": "application/x-www-form-urlencoded" },
529
+ body,
530
+ ...context.signal ? { signal: context.signal } : {}
531
+ });
532
+ if (!response.ok) {
533
+ const body = await response.text().catch(() => "");
534
+ throw new VendorResponseError(`meta: the Conversions API answered ${response.status}`, {
535
+ provider: "meta",
536
+ status: response.status,
537
+ body
538
+ });
539
+ }
540
+ if (data.items && custom.contents === void 0) report("contents omitted: every item needs a nonempty item_id; totals were preserved");
541
+ const result = await response.json().catch(() => null);
542
+ const messages = result?.messages;
543
+ if (Array.isArray(messages) && messages.length > 0) report(`accepted with ${messages.length} warning(s) from the Conversions API; read them under fbtrace_id ${result?.fbtrace_id ?? "unknown"} in Events Manager`);
544
+ }
545
+ };
546
+ }
547
+ //#endregion
548
+ //#region src/providers/posthog.server.ts
549
+ const DEFAULT_HOST = "https://us.i.posthog.com";
550
+ /**
551
+ * The distinct_id lives inside a cookie posthog-js writes by default, named
552
+ * after this project's own api key: `ph_<api_key>_posthog`. Its value is
553
+ * `encodeURIComponent(JSON.stringify({ distinct_id, ... }))`, so it has to be
554
+ * decoded before it parses as JSON.
555
+ *
556
+ * Verified against a live project rather than read off the SDK's source: the
557
+ * cookie was percent-encoded, held `distinct_id` alongside `$device_id` and
558
+ * `$user_state`, and the id parsed out of it matched what the page's own
559
+ * `posthog.get_distinct_id()` returned. A server event sent with it landed on
560
+ * the same person the browser had created, which is the whole point.
561
+ *
562
+ * This is only the anonymous fallback. It is not read when the caller already
563
+ * knows who the visitor is; see the comment in `track` for why.
564
+ */
565
+ function anonymousId(cookies, apiKey) {
566
+ const raw = cookies[`ph_${apiKey}_posthog`];
567
+ if (!raw) return void 0;
568
+ try {
569
+ const parsed = JSON.parse(decodeURIComponent(raw));
570
+ return typeof parsed.distinct_id === "string" ? parsed.distinct_id : void 0;
571
+ } catch {
572
+ return;
573
+ }
574
+ }
575
+ /**
576
+ * PostHog deduplicates on `uuid`, and the field holds a UUID rather than any
577
+ * string: an invalid one is documented as dropped on ingestion, and has been
578
+ * reported answering 400 instead. Since `dedupId` is whatever the host chose
579
+ * — Meta's `event_id` takes anything, and this library's own example is an
580
+ * order id — forwarding it unchecked would turn a duplicated purchase into no
581
+ * purchase at all.
582
+ *
583
+ * Accepts any RFC 4122 layout rather than v4 alone, because v7 is what a
584
+ * system minting ids today tends to produce.
585
+ */
586
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
587
+ /**
588
+ * PostHog documents that it "shortens any `distinct_id` longer than 200
589
+ * characters" and ingests the event under the shortened id. Sent as given,
590
+ * because refusing would drop an event the vendor accepts; reported, because
591
+ * two ids sharing a prefix become one person and only the host can fix that.
592
+ */
593
+ const MAX_DISTINCT_ID = 200;
594
+ function checkLength(id, report) {
595
+ if (id.length > MAX_DISTINCT_ID) report(`distinct_id is longer than ${MAX_DISTINCT_ID} characters: PostHog truncates it, so two ids sharing that prefix become one person`);
596
+ }
597
+ /**
598
+ * Traits with a value go to `$set`; a trait set to `null` means "no longer
599
+ * known", which PostHog spells as a name in `$unset`.
600
+ */
601
+ function personProperties(traits) {
602
+ if (!traits) return {};
603
+ const set = {};
604
+ const unset = [];
605
+ for (const [name, value] of Object.entries(traits)) if (value === null) unset.push(name);
606
+ else set[name] = value;
607
+ return {
608
+ ...Object.keys(set).length > 0 ? { $set: set } : {},
609
+ ...unset.length > 0 ? { $unset: unset } : {}
610
+ };
611
+ }
612
+ /**
613
+ * PostHog on the server, through the Capture API.
614
+ *
615
+ * **Deduplicating a retried webhook takes two things, not one.** `dedupId`
616
+ * becomes PostHog's `uuid`, and PostHog collapses events sharing `uuid`, event
617
+ * name, timestamp and `distinct_id` — so a `timestamp` left to default to the
618
+ * clock differs on the retry and nothing is deduplicated. Pin it to a value
619
+ * derived from the order, as below. The `dedupId` itself must be a UUID:
620
+ * anything else is reported through `onError` and sent without the field,
621
+ * because PostHog rejects other shapes rather than ignoring them.
622
+ *
623
+ * Deduplication is also eventual, happening during a background merge, so both
624
+ * rows are visible for a while and a test that counts immediately will fail.
625
+ *
626
+ * **`identify` sends one `$identify`**, with `$set` and `$unset` from the
627
+ * traits. It merges a visitor into the user only when the context carries
628
+ * `anonymousId`, never from the cookie: a merge is irreversible, and PostHog
629
+ * reports a refused one only as an ingestion warning in its own interface.
630
+ * Identified events are billed with person processing.
631
+ *
632
+ * @param credentials - The project API key, and the ingestion host for
633
+ * anything other than PostHog's default US Cloud instance.
634
+ * @param config - Rarely needed event name overrides.
635
+ * @returns A provider for `createTracking` from `tracklane/server`.
636
+ *
637
+ * @example
638
+ * ```ts
639
+ * import { createTracking, posthog } from 'tracklane/server';
640
+ *
641
+ * const { track } = createTracking({
642
+ * providers: [posthog({ apiKey: process.env.POSTHOG_API_KEY! })],
643
+ * });
644
+ *
645
+ * // Both fields come from the order, so the payment provider's second
646
+ * // delivery of the same webhook produces the same event.
647
+ * await track('purchase', order, {
648
+ * user: { userId: order.userId },
649
+ * cookies: request.headers.get('cookie'),
650
+ * dedupId: order.eventId, // a UUID your checkout minted with the order
651
+ * timestamp: order.paidAt,
652
+ * });
653
+ * ```
654
+ */
655
+ function posthog(credentials, config = {}) {
656
+ const { apiKey, host = DEFAULT_HOST } = credentials;
657
+ const send = async (body, signal) => {
658
+ const response = await fetch(`${host}/i/v0/e/`, {
659
+ method: "POST",
660
+ headers: { "content-type": "application/json" },
661
+ body: JSON.stringify({
662
+ api_key: apiKey,
663
+ ...body
664
+ }),
665
+ ...signal ? { signal } : {}
666
+ });
667
+ if (!response.ok) {
668
+ const text = await response.text().catch(() => "");
669
+ throw new VendorResponseError(`posthog: the capture endpoint answered ${response.status}`, {
670
+ provider: "posthog",
671
+ status: response.status,
672
+ body: text
673
+ });
674
+ }
675
+ };
676
+ return {
677
+ name: "posthog",
678
+ default: "passthrough",
679
+ ...config.events ? { events: config.events } : {},
680
+ async track(name, data, context, report) {
681
+ const id = context.user?.userId ?? context.anonymousId ?? anonymousId(context.cookies, apiKey);
682
+ if (!id) throw new Error("posthog: cannot resolve distinct_id — no user.userId or anonymousId in context and no ph_<api_key>_posthog cookie among the cookies passed in context. Pass user.userId once the visitor is known, or ensure PostHog's browser tag has set its cookie first.");
683
+ checkLength(id, report);
684
+ const properties = {
685
+ ...data,
686
+ ...personProperties(context.traits)
687
+ };
688
+ const dedup = {};
689
+ if (context.dedupId !== void 0) if (UUID.test(context.dedupId)) dedup.uuid = context.dedupId;
690
+ else report("dedupId is not a UUID, so it was not forwarded as uuid: PostHog deduplicates on that field and rejects other shapes, so this event can duplicate on a retry");
691
+ const body = {
692
+ event: name,
693
+ distinct_id: id,
694
+ ...dedup,
695
+ properties,
696
+ timestamp: new Date(context.timestamp).toISOString()
697
+ };
698
+ await send(body, context.signal);
699
+ },
700
+ async identify(context, report) {
701
+ const id = context.user?.userId;
702
+ if (!id) throw new Error("posthog: identify needs user.userId");
703
+ checkLength(id, report);
704
+ await send({
705
+ event: "$identify",
706
+ distinct_id: id,
707
+ properties: {
708
+ ...personProperties(context.traits),
709
+ ...context.anonymousId === void 0 ? {} : { $anon_distinct_id: context.anonymousId }
710
+ },
711
+ timestamp: new Date(context.timestamp).toISOString()
712
+ }, context.signal);
713
+ }
714
+ };
715
+ }
716
+ //#endregion
717
+ //#region src/server.ts
718
+ /** `name=value; name=value`, the shape of a `Cookie` header. */
719
+ function parseCookies(cookies) {
720
+ if (!cookies) return {};
721
+ if (typeof cookies !== "string") return cookies;
722
+ const parsed = {};
723
+ for (const pair of cookies.split(";")) {
724
+ const separator = pair.indexOf("=");
725
+ if (separator < 1) continue;
726
+ const name = pair.slice(0, separator).trim();
727
+ if (name) parsed[name] = pair.slice(separator + 1).trim();
728
+ }
729
+ return parsed;
730
+ }
731
+ function toEpoch(value) {
732
+ return value instanceof Date ? value.getTime() : value;
733
+ }
734
+ function resolveContext(context) {
735
+ const { cookies, timestamp, traitsAt, ...rest } = context;
736
+ return {
737
+ ...rest,
738
+ cookies: parseCookies(cookies),
739
+ timestamp: timestamp === void 0 ? Date.now() : toEpoch(timestamp),
740
+ ...traitsAt === void 0 ? {} : { traitsAt: toEpoch(traitsAt) }
741
+ };
742
+ }
743
+ /**
744
+ * Creates the server tracking surface.
745
+ *
746
+ * Nothing is held between calls: a server instance is shared by every
747
+ * request, so a retained identity would attach one visitor to another
748
+ * visitor's conversion. Identity travels with each call, `identify`
749
+ * included, which is also what every conversion API expects.
750
+ *
751
+ * There is no `consent` call either. Consent commands are properties of a
752
+ * vendor's on-page tag, and a server request has no tag. The one vendor that
753
+ * accepts consent server-side reads it from the context.
754
+ *
755
+ * @param options - The providers to send to, and an optional error channel.
756
+ * @returns The `track` and `identify` calls.
757
+ *
758
+ * @example
759
+ * ```ts
760
+ * import { createTracking, ga4 } from 'tracklane/server';
761
+ *
762
+ * const { track } = createTracking({
763
+ * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret })],
764
+ * });
765
+ *
766
+ * await track('purchase', order, { cookies: request.headers.get('cookie') });
767
+ * ```
768
+ */
769
+ function createTracking(options) {
770
+ const { providers } = options;
771
+ assertUniqueProviders(providers);
772
+ const report = makeReporter(options.onError);
773
+ const settle = async (provider, event, send) => {
774
+ try {
775
+ await send((message) => {
776
+ report({
777
+ provider: provider.name,
778
+ event,
779
+ severity: "warning",
780
+ message
781
+ });
782
+ });
783
+ } catch (cause) {
784
+ report({
785
+ provider: provider.name,
786
+ event,
787
+ severity: "error",
788
+ message: messageOf(cause),
789
+ cause
790
+ });
791
+ }
792
+ };
793
+ return {
794
+ async track(name, data = {}, context = {}) {
795
+ const resolved = resolveContext(context);
796
+ await Promise.all(providers.map(async (provider) => {
797
+ const target = resolveEvent(provider, name, (message) => {
798
+ report({
799
+ provider: provider.name,
800
+ event: name,
801
+ severity: "warning",
802
+ message
803
+ });
804
+ });
805
+ if (target === void 0) return;
806
+ await settle(provider, name, (warn) => provider.track(target, data, resolved, warn));
807
+ }));
808
+ },
809
+ async identify(context) {
810
+ const resolved = resolveContext(context);
811
+ await Promise.all(providers.map((provider) => {
812
+ const identify = provider.identify?.bind(provider);
813
+ if (!identify) return void 0;
814
+ return settle(provider, "identify", (warn) => identify(resolved, warn));
815
+ }));
816
+ }
817
+ };
818
+ }
819
+ //#endregion
820
+ export { ga4 as a, linkedin as i, posthog as n, meta as r, createTracking as t };
821
+
822
+ //# sourceMappingURL=server-bgUN2MOi.js.map