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/README.md +1 -1
- package/dist/{browser-CddkJdvX.d.ts → browser-34-CHcxK.d.ts} +163 -10
- package/dist/browser-DhzO5qPS.js +432 -0
- package/dist/browser-DhzO5qPS.js.map +1 -0
- package/dist/browser.d.ts +2 -2
- package/dist/browser.js +2 -397
- package/dist/conformance.d.ts +7 -1
- package/dist/conformance.js +67 -10
- package/dist/conformance.js.map +1 -1
- package/dist/{dispatch-BLBoCn-b.js → dispatch-B32jGhlh.js} +6 -3
- package/dist/dispatch-B32jGhlh.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -0
- package/dist/server-DnP7RtEn.js +647 -0
- package/dist/server-DnP7RtEn.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -509
- package/dist/src-DLRGPWMj.js +75 -0
- package/dist/src-DLRGPWMj.js.map +1 -0
- package/package.json +2 -2
- package/dist/browser.js.map +0 -1
- package/dist/dispatch-BLBoCn-b.js.map +0 -1
- package/dist/server.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { C as
|
|
2
|
-
export type
|
|
1
|
+
import { B as VendorResponseError, C as EventData, D as UserData, E as TrackingError, S as EventBinding, T as ProviderDefault, V as isVendorResponseError, _ as ServerTracking, b as ActionSource, g as ServerProvider, h as ResolvedContext, i as TrackOptions, m as EventContext, n as BrowserTracking, r as BrowserTrackingOptions, t as BrowserProvider, v as ServerTrackingOptions, w as EventItem, x as ConsentState } from "./browser-34-CHcxK.js";
|
|
2
|
+
export { type ActionSource, type BrowserProvider, type BrowserTracking, type BrowserTrackingOptions, type ConsentState, type EventBinding, type EventContext, type EventData, type EventItem, type ProviderDefault, type ResolvedContext, type ServerProvider, type ServerTracking, type ServerTrackingOptions, type TrackOptions, type TrackingError, type UserData, VendorResponseError, isVendorResponseError };
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,647 @@
|
|
|
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
|
+
* Google Analytics 4 on the server, through the Measurement Protocol.
|
|
57
|
+
*
|
|
58
|
+
* @param credentials - The measurement id and API secret.
|
|
59
|
+
* @param config - Rarely needed event name overrides.
|
|
60
|
+
* @returns A provider for `createTracking` from `tracklane/server`.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* import { createTracking, ga4 } from 'tracklane/server';
|
|
65
|
+
*
|
|
66
|
+
* const { track } = createTracking({
|
|
67
|
+
* providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret: process.env.GA4_SECRET! })],
|
|
68
|
+
* });
|
|
69
|
+
*
|
|
70
|
+
* await track('purchase', order, { cookies: request.headers.get('cookie') });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
function ga4(credentials, config = {}) {
|
|
74
|
+
const { measurementId, apiSecret } = credentials;
|
|
75
|
+
return {
|
|
76
|
+
name: "ga4",
|
|
77
|
+
default: "passthrough",
|
|
78
|
+
...config.events ? { events: config.events } : {},
|
|
79
|
+
async track(name, data, context, report) {
|
|
80
|
+
const clientId = visitorId(context.cookies);
|
|
81
|
+
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.");
|
|
82
|
+
const session = sessionId(context.cookies, measurementId);
|
|
83
|
+
if (!session) report("no session cookie (_ga_<container>): Google will accept this event and it will not appear in any report");
|
|
84
|
+
const params = { ...data };
|
|
85
|
+
if (session) {
|
|
86
|
+
params.session_id = session;
|
|
87
|
+
params.engagement_time_msec = 1;
|
|
88
|
+
}
|
|
89
|
+
const body = {
|
|
90
|
+
client_id: clientId,
|
|
91
|
+
timestamp_micros: context.timestamp * 1e3,
|
|
92
|
+
events: [{
|
|
93
|
+
name,
|
|
94
|
+
params
|
|
95
|
+
}]
|
|
96
|
+
};
|
|
97
|
+
if (context.user?.userId !== void 0) body.user_id = context.user.userId;
|
|
98
|
+
if (context.traits) body.user_properties = Object.fromEntries(Object.entries(context.traits).map(([name, value]) => [name, { value }]));
|
|
99
|
+
const consent = consentBody(context.consent);
|
|
100
|
+
if (consent) body.consent = consent;
|
|
101
|
+
const url = `${ENDPOINT}?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(apiSecret)}`;
|
|
102
|
+
const response = await fetch(url, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "content-type": "application/json" },
|
|
105
|
+
body: JSON.stringify(body)
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const body = await response.text().catch(() => "");
|
|
109
|
+
throw new VendorResponseError(`ga4: the Measurement Protocol answered ${response.status}`, {
|
|
110
|
+
provider: "ga4",
|
|
111
|
+
status: response.status,
|
|
112
|
+
body
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/providers/linkedin.server.ts
|
|
120
|
+
async function sha256$1(value) {
|
|
121
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
122
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
123
|
+
}
|
|
124
|
+
function ipv4(value) {
|
|
125
|
+
const parts = value.split(".");
|
|
126
|
+
return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
127
|
+
}
|
|
128
|
+
async function userData$1(context) {
|
|
129
|
+
const user = context.user ?? {};
|
|
130
|
+
const userIds = [];
|
|
131
|
+
const built = { userIds };
|
|
132
|
+
const email = user.email?.replace(/\s/g, "").toLowerCase();
|
|
133
|
+
if (email) userIds.push({
|
|
134
|
+
idType: "SHA256_EMAIL",
|
|
135
|
+
idValue: await sha256$1(email)
|
|
136
|
+
});
|
|
137
|
+
const clickId = context.cookies.li_fat_id;
|
|
138
|
+
if (clickId) try {
|
|
139
|
+
userIds.push({
|
|
140
|
+
idType: "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID",
|
|
141
|
+
idValue: decodeURIComponent(clickId)
|
|
142
|
+
});
|
|
143
|
+
} catch {}
|
|
144
|
+
if (context.ip && ipv4(context.ip)) userIds.push({
|
|
145
|
+
idType: "PLAINTEXT_IP_ADDRESS",
|
|
146
|
+
idValue: context.ip
|
|
147
|
+
});
|
|
148
|
+
if (user.firstName && user.lastName) built.userInfo = {
|
|
149
|
+
firstName: user.firstName,
|
|
150
|
+
lastName: user.lastName
|
|
151
|
+
};
|
|
152
|
+
if (user.userId) built.externalIds = [user.userId];
|
|
153
|
+
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");
|
|
154
|
+
return built;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Sends event-specific conversions to LinkedIn's Conversions API.
|
|
158
|
+
* Value and currency become conversionValue; items, transaction_id, custom
|
|
159
|
+
* properties, traits and consent have no documented field on this endpoint.
|
|
160
|
+
* The host's deduplication ID becomes eventId, without generating one.
|
|
161
|
+
*
|
|
162
|
+
* @param credentials - The ad account's Conversions API access token.
|
|
163
|
+
* @param config - Canonical event to conversion ID map and API version.
|
|
164
|
+
* @returns A server provider for `createTracking`.
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* import { createTracking, linkedin } from 'tracklane/server';
|
|
168
|
+
* const { track } = createTracking({
|
|
169
|
+
* providers: [linkedin({ accessToken }, { events: { purchase: '12345' } })],
|
|
170
|
+
* });
|
|
171
|
+
* await track('purchase', { value: 15, currency: 'BRL' }, {
|
|
172
|
+
* user: { email: 'buyer@example.com' }, dedupId: 'order-1',
|
|
173
|
+
* });
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
function linkedin(credentials, config) {
|
|
177
|
+
const { apiVersion = "202609" } = config;
|
|
178
|
+
return {
|
|
179
|
+
name: "linkedin",
|
|
180
|
+
default: "ignore",
|
|
181
|
+
events: config.events,
|
|
182
|
+
async track(name, data, context, report) {
|
|
183
|
+
if (!/^\d+$/.test(name) || BigInt(name) <= 0n) throw new Error("linkedin: conversion must identify a positive conversion ID from Campaign Manager");
|
|
184
|
+
const event = {
|
|
185
|
+
conversion: `urn:lla:llaPartnerConversion:${name}`,
|
|
186
|
+
conversionHappenedAt: context.timestamp,
|
|
187
|
+
user: await userData$1(context)
|
|
188
|
+
};
|
|
189
|
+
if (data.value !== void 0 && data.currency !== void 0) event.conversionValue = {
|
|
190
|
+
currencyCode: data.currency,
|
|
191
|
+
amount: String(data.value)
|
|
192
|
+
};
|
|
193
|
+
if (context.dedupId !== void 0) event.eventId = context.dedupId;
|
|
194
|
+
const response = await fetch("https://api.linkedin.com/rest/conversionEvents", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: {
|
|
197
|
+
Authorization: `Bearer ${credentials.accessToken}`,
|
|
198
|
+
"Content-Type": "application/json",
|
|
199
|
+
"Linkedin-Version": apiVersion,
|
|
200
|
+
"X-Restli-Protocol-Version": "2.0.0"
|
|
201
|
+
},
|
|
202
|
+
body: JSON.stringify(event)
|
|
203
|
+
});
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
const body = await response.text().catch(() => "");
|
|
206
|
+
throw new VendorResponseError(`linkedin: the Conversions API answered ${response.status}`, {
|
|
207
|
+
provider: "linkedin",
|
|
208
|
+
status: response.status,
|
|
209
|
+
body
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (data.value !== void 0 && data.currency === void 0) report("conversionValue omitted: a monetary value requires currency; the conversion rule value applies");
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/providers/meta.server.ts
|
|
218
|
+
const GRAPH = "https://graph.facebook.com";
|
|
219
|
+
/**
|
|
220
|
+
* Pinned on purpose. A Graph URL with no version resolves to the *oldest*
|
|
221
|
+
* version still available, which silently changes what the endpoint accepts
|
|
222
|
+
* the day Meta drops it.
|
|
223
|
+
*/
|
|
224
|
+
const DEFAULT_API_VERSION = "v25.0";
|
|
225
|
+
/**
|
|
226
|
+
* How Meta spells the canonical vocabulary, which is GA4's. Kept in step with
|
|
227
|
+
* `meta.browser.ts` by hand: the two halves are two libraries, and neither
|
|
228
|
+
* imports the other.
|
|
229
|
+
*/
|
|
230
|
+
const CANONICAL_EVENTS = {
|
|
231
|
+
page_view: "PageView",
|
|
232
|
+
view_item: "ViewContent",
|
|
233
|
+
search: "Search",
|
|
234
|
+
add_to_cart: "AddToCart",
|
|
235
|
+
add_to_wishlist: "AddToWishlist",
|
|
236
|
+
begin_checkout: "InitiateCheckout",
|
|
237
|
+
add_payment_info: "AddPaymentInfo",
|
|
238
|
+
purchase: "Purchase",
|
|
239
|
+
sign_up: "CompleteRegistration",
|
|
240
|
+
generate_lead: "Lead"
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* Web Crypto rather than `node:crypto`, so the built server bundle stays
|
|
244
|
+
* platform-neutral: this adapter is called from Cloudflare Workers, Deno and
|
|
245
|
+
* Vercel's edge runtime as often as from a Node process, and a `node:` import
|
|
246
|
+
* would rule those out for no gain.
|
|
247
|
+
*/
|
|
248
|
+
async function sha256(value) {
|
|
249
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
250
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* **Why the adapter hashes and does not ask the host to.**
|
|
254
|
+
*
|
|
255
|
+
* This looks like the library transforming data, and the cutting rule forbids
|
|
256
|
+
* that. It is not. Meta does not define a field called "email" that happens
|
|
257
|
+
* to want a hash: it defines `em` *as* the SHA-256 of the trimmed, lowercased
|
|
258
|
+
* address, the same way it defines `event_time` as seconds rather than
|
|
259
|
+
* milliseconds. Writing this line by hand you write
|
|
260
|
+
* `sha256(email.trim().toLowerCase())`, and the adapter replicates exactly
|
|
261
|
+
* that. Normalisation lives with the field it belongs to.
|
|
262
|
+
*
|
|
263
|
+
* The alternative is worse and is where integrations demonstrably go wrong:
|
|
264
|
+
* three vendors want SHA-256 under three different normalisation rules, so a
|
|
265
|
+
* host hashing on its own side has to hash the same address three ways and
|
|
266
|
+
* discovers a mistake only as a quietly poor match rate.
|
|
267
|
+
*/
|
|
268
|
+
const normaliseEmail = (value) => value.trim().toLowerCase();
|
|
269
|
+
/**
|
|
270
|
+
* "Remove symbols, letters, and any leading zeros." The country code is not
|
|
271
|
+
* added here: Meta wants one and the library does not know it, and guessing a
|
|
272
|
+
* country onto a phone number invents an identifier for a different person.
|
|
273
|
+
*/
|
|
274
|
+
const normalisePhone = (value) => value.replace(/\D/g, "").replace(/^0+/, "");
|
|
275
|
+
/** "Lowercase only with no punctuation." Spaces and accents survive; Meta reads UTF-8. */
|
|
276
|
+
const normaliseName = (value) => value.trim().toLowerCase().replace(/[\p{P}\p{S}]/gu, "");
|
|
277
|
+
/**
|
|
278
|
+
* GA4's `items` become Meta's `contents`, a closed shape: `id`, `quantity`,
|
|
279
|
+
* `item_price`, `delivery_category`. The rest of a GA4 item is dropped rather
|
|
280
|
+
* than forwarded, because a key Meta does not read inside `contents` looks
|
|
281
|
+
* mapped and arrives nowhere.
|
|
282
|
+
*/
|
|
283
|
+
function contents(items) {
|
|
284
|
+
if (items.some((item) => typeof item.item_id !== "string" || !item.item_id.trim())) return void 0;
|
|
285
|
+
return items.map((item) => {
|
|
286
|
+
const content = {};
|
|
287
|
+
if (item.item_id !== void 0) content.id = item.item_id;
|
|
288
|
+
if (item.quantity !== void 0) content.quantity = item.quantity;
|
|
289
|
+
if (item.price !== void 0) content.item_price = item.price;
|
|
290
|
+
return content;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* `value` and `currency` are spelled identically by both vendors. Everything
|
|
295
|
+
* the host named itself passes through, because a business measures things
|
|
296
|
+
* this vocabulary does not name.
|
|
297
|
+
*
|
|
298
|
+
* `transaction_id` becomes `order_id`, which this surface documents and the
|
|
299
|
+
* pixel does not. It is one of the asymmetries between Meta's two halves an
|
|
300
|
+
* adapter absorbs so nobody else has to.
|
|
301
|
+
*/
|
|
302
|
+
function customData(data) {
|
|
303
|
+
const { items, transaction_id: transactionId, ...rest } = data;
|
|
304
|
+
const custom = { ...rest };
|
|
305
|
+
if (transactionId !== void 0) custom.order_id = transactionId;
|
|
306
|
+
if (items) {
|
|
307
|
+
const mapped = contents(items);
|
|
308
|
+
if (mapped !== void 0) custom.contents = mapped;
|
|
309
|
+
else delete custom.contents;
|
|
310
|
+
}
|
|
311
|
+
return custom;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The heaviest surface of the five vendors: four hashed identifiers, four raw
|
|
315
|
+
* ones, and two cookies forwarded exactly as the pixel wrote them.
|
|
316
|
+
*
|
|
317
|
+
* Built in a fixed order, and hashed one after another rather than in
|
|
318
|
+
* parallel, so that the same input produces the same JSON on every run. Two
|
|
319
|
+
* identical events differing only in key order would be the library inventing
|
|
320
|
+
* something, which is precisely what the conformance suite looks for.
|
|
321
|
+
*/
|
|
322
|
+
async function userData(context) {
|
|
323
|
+
const user = context.user ?? {};
|
|
324
|
+
const built = {};
|
|
325
|
+
const hashed = [
|
|
326
|
+
[
|
|
327
|
+
"em",
|
|
328
|
+
user.email,
|
|
329
|
+
normaliseEmail
|
|
330
|
+
],
|
|
331
|
+
[
|
|
332
|
+
"ph",
|
|
333
|
+
user.phone,
|
|
334
|
+
normalisePhone
|
|
335
|
+
],
|
|
336
|
+
[
|
|
337
|
+
"fn",
|
|
338
|
+
user.firstName,
|
|
339
|
+
normaliseName
|
|
340
|
+
],
|
|
341
|
+
[
|
|
342
|
+
"ln",
|
|
343
|
+
user.lastName,
|
|
344
|
+
normaliseName
|
|
345
|
+
]
|
|
346
|
+
];
|
|
347
|
+
for (const [field, raw, normalise] of hashed) {
|
|
348
|
+
if (raw === void 0) continue;
|
|
349
|
+
const normalised = normalise(raw);
|
|
350
|
+
if (!normalised) continue;
|
|
351
|
+
built[field] = await sha256(normalised);
|
|
352
|
+
}
|
|
353
|
+
if (user.userId !== void 0) built.external_id = user.userId;
|
|
354
|
+
if (context.ip !== void 0) built.client_ip_address = context.ip;
|
|
355
|
+
if (context.userAgent !== void 0) built.client_user_agent = context.userAgent;
|
|
356
|
+
const fbp = context.cookies._fbp;
|
|
357
|
+
if (fbp) built.fbp = fbp;
|
|
358
|
+
const fbc = context.cookies._fbc;
|
|
359
|
+
if (fbc) built.fbc = fbc;
|
|
360
|
+
return built;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Meta's Conversions API.
|
|
364
|
+
*
|
|
365
|
+
* If any item lacks a nonempty `item_id`, the complete `contents` list is
|
|
366
|
+
* omitted while totals and order metadata remain unchanged. This avoids
|
|
367
|
+
* presenting a partial order or inventing product identifiers. The error
|
|
368
|
+
* channel receives one payload-free warning after HTTP acceptance.
|
|
369
|
+
*
|
|
370
|
+
* @param credentials - The pixel id and an access token.
|
|
371
|
+
* @param config - Event overrides, the default action source, and Meta's own
|
|
372
|
+
* levers: the API version, a test event code, and Limited Data Use.
|
|
373
|
+
* @returns A provider for `createTracking` from `tracklane/server`.
|
|
374
|
+
*
|
|
375
|
+
* @example
|
|
376
|
+
* ```ts
|
|
377
|
+
* import { createTracking, meta } from 'tracklane/server';
|
|
378
|
+
*
|
|
379
|
+
* const { track } = createTracking({
|
|
380
|
+
* providers: [meta({ pixelId: '1234567890', accessToken: process.env.META_TOKEN! })],
|
|
381
|
+
* });
|
|
382
|
+
*
|
|
383
|
+
* await track('purchase', order, {
|
|
384
|
+
* user: { email: order.email },
|
|
385
|
+
* cookies: request.headers.get('cookie'),
|
|
386
|
+
* dedupId: order.id,
|
|
387
|
+
* ip,
|
|
388
|
+
* userAgent,
|
|
389
|
+
* });
|
|
390
|
+
* ```
|
|
391
|
+
*/
|
|
392
|
+
function meta(credentials, config = {}) {
|
|
393
|
+
const { pixelId, accessToken } = credentials;
|
|
394
|
+
const { actionSource = "website", apiVersion = DEFAULT_API_VERSION, testEventCode } = config;
|
|
395
|
+
return {
|
|
396
|
+
name: "meta",
|
|
397
|
+
default: "passthrough",
|
|
398
|
+
events: {
|
|
399
|
+
...CANONICAL_EVENTS,
|
|
400
|
+
...config.events
|
|
401
|
+
},
|
|
402
|
+
async track(name, data, context, report) {
|
|
403
|
+
const user = await userData(context);
|
|
404
|
+
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.");
|
|
405
|
+
const event = {
|
|
406
|
+
event_name: name,
|
|
407
|
+
event_time: Math.floor(context.timestamp / 1e3),
|
|
408
|
+
action_source: context.source ?? actionSource,
|
|
409
|
+
user_data: user
|
|
410
|
+
};
|
|
411
|
+
if (context.dedupId !== void 0) event.event_id = context.dedupId;
|
|
412
|
+
if (context.url !== void 0) event.event_source_url = context.url;
|
|
413
|
+
const custom = customData(data);
|
|
414
|
+
if (Object.keys(custom).length > 0) event.custom_data = custom;
|
|
415
|
+
if (config.dataProcessingOptions) {
|
|
416
|
+
event.data_processing_options = config.dataProcessingOptions;
|
|
417
|
+
if (config.dataProcessingOptionsCountry !== void 0) event.data_processing_options_country = config.dataProcessingOptionsCountry;
|
|
418
|
+
if (config.dataProcessingOptionsState !== void 0) event.data_processing_options_state = config.dataProcessingOptionsState;
|
|
419
|
+
}
|
|
420
|
+
const body = new URLSearchParams({
|
|
421
|
+
data: JSON.stringify([event]),
|
|
422
|
+
access_token: accessToken
|
|
423
|
+
});
|
|
424
|
+
if (testEventCode) body.set("test_event_code", testEventCode);
|
|
425
|
+
const response = await fetch(`${GRAPH}/${apiVersion}/${encodeURIComponent(pixelId)}/events`, {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
428
|
+
body
|
|
429
|
+
});
|
|
430
|
+
if (!response.ok) {
|
|
431
|
+
const body = await response.text().catch(() => "");
|
|
432
|
+
throw new VendorResponseError(`meta: the Conversions API answered ${response.status}`, {
|
|
433
|
+
provider: "meta",
|
|
434
|
+
status: response.status,
|
|
435
|
+
body
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (data.items && custom.contents === void 0) report("contents omitted: every item needs a nonempty item_id; totals were preserved");
|
|
439
|
+
const result = await response.json().catch(() => null);
|
|
440
|
+
const messages = result?.messages;
|
|
441
|
+
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`);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
//#endregion
|
|
446
|
+
//#region src/providers/posthog.server.ts
|
|
447
|
+
const DEFAULT_HOST = "https://us.i.posthog.com";
|
|
448
|
+
/**
|
|
449
|
+
* The distinct_id lives inside a cookie posthog-js writes by default, named
|
|
450
|
+
* after this project's own api key: `ph_<api_key>_posthog`. Its value is
|
|
451
|
+
* `encodeURIComponent(JSON.stringify({ distinct_id, ... }))`, so it has to be
|
|
452
|
+
* decoded before it parses as JSON.
|
|
453
|
+
*
|
|
454
|
+
* Verified against a live project rather than read off the SDK's source: the
|
|
455
|
+
* cookie was percent-encoded, held `distinct_id` alongside `$device_id` and
|
|
456
|
+
* `$user_state`, and the id parsed out of it matched what the page's own
|
|
457
|
+
* `posthog.get_distinct_id()` returned. A server event sent with it landed on
|
|
458
|
+
* the same person the browser had created, which is the whole point.
|
|
459
|
+
*
|
|
460
|
+
* This is only the anonymous fallback. It is not read when the caller already
|
|
461
|
+
* knows who the visitor is; see the comment in `track` for why.
|
|
462
|
+
*/
|
|
463
|
+
function anonymousId(cookies, apiKey) {
|
|
464
|
+
const raw = cookies[`ph_${apiKey}_posthog`];
|
|
465
|
+
if (!raw) return void 0;
|
|
466
|
+
try {
|
|
467
|
+
const parsed = JSON.parse(decodeURIComponent(raw));
|
|
468
|
+
return typeof parsed.distinct_id === "string" ? parsed.distinct_id : void 0;
|
|
469
|
+
} catch {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* PostHog deduplicates on `uuid`, and the field holds a UUID rather than any
|
|
475
|
+
* string: an invalid one is documented as dropped on ingestion, and has been
|
|
476
|
+
* reported answering 400 instead. Since `dedupId` is whatever the host chose
|
|
477
|
+
* — Meta's `event_id` takes anything, and this library's own example is an
|
|
478
|
+
* order id — forwarding it unchecked would turn a duplicated purchase into no
|
|
479
|
+
* purchase at all.
|
|
480
|
+
*
|
|
481
|
+
* Accepts any RFC 4122 layout rather than v4 alone, because v7 is what a
|
|
482
|
+
* system minting ids today tends to produce.
|
|
483
|
+
*/
|
|
484
|
+
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;
|
|
485
|
+
/**
|
|
486
|
+
* PostHog on the server, through the Capture API.
|
|
487
|
+
*
|
|
488
|
+
* **Deduplicating a retried webhook takes two things, not one.** `dedupId`
|
|
489
|
+
* becomes PostHog's `uuid`, and PostHog collapses events sharing `uuid`, event
|
|
490
|
+
* name, timestamp and `distinct_id` — so a `timestamp` left to default to the
|
|
491
|
+
* clock differs on the retry and nothing is deduplicated. Pin it to a value
|
|
492
|
+
* derived from the order, as below. The `dedupId` itself must be a UUID:
|
|
493
|
+
* anything else is reported through `onError` and sent without the field,
|
|
494
|
+
* because PostHog rejects other shapes rather than ignoring them.
|
|
495
|
+
*
|
|
496
|
+
* Deduplication is also eventual, happening during a background merge, so both
|
|
497
|
+
* rows are visible for a while and a test that counts immediately will fail.
|
|
498
|
+
*
|
|
499
|
+
* @param credentials - The project API key, and the ingestion host for
|
|
500
|
+
* anything other than PostHog's default US Cloud instance.
|
|
501
|
+
* @param config - Rarely needed event name overrides.
|
|
502
|
+
* @returns A provider for `createTracking` from `tracklane/server`.
|
|
503
|
+
*
|
|
504
|
+
* @example
|
|
505
|
+
* ```ts
|
|
506
|
+
* import { createTracking, posthog } from 'tracklane/server';
|
|
507
|
+
*
|
|
508
|
+
* const { track } = createTracking({
|
|
509
|
+
* providers: [posthog({ apiKey: process.env.POSTHOG_API_KEY! })],
|
|
510
|
+
* });
|
|
511
|
+
*
|
|
512
|
+
* // Both fields come from the order, so the payment provider's second
|
|
513
|
+
* // delivery of the same webhook produces the same event.
|
|
514
|
+
* await track('purchase', order, {
|
|
515
|
+
* user: { userId: order.userId },
|
|
516
|
+
* cookies: request.headers.get('cookie'),
|
|
517
|
+
* dedupId: order.eventId, // a UUID your checkout minted with the order
|
|
518
|
+
* timestamp: order.paidAt,
|
|
519
|
+
* });
|
|
520
|
+
* ```
|
|
521
|
+
*/
|
|
522
|
+
function posthog(credentials, config = {}) {
|
|
523
|
+
const { apiKey, host = DEFAULT_HOST } = credentials;
|
|
524
|
+
return {
|
|
525
|
+
name: "posthog",
|
|
526
|
+
default: "passthrough",
|
|
527
|
+
...config.events ? { events: config.events } : {},
|
|
528
|
+
async track(name, data, context, report) {
|
|
529
|
+
const id = context.user?.userId ?? anonymousId(context.cookies, apiKey);
|
|
530
|
+
if (!id) throw new Error("posthog: cannot resolve distinct_id — no user.userId 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.");
|
|
531
|
+
const properties = { ...data };
|
|
532
|
+
if (context.traits) properties.$set = context.traits;
|
|
533
|
+
const dedup = {};
|
|
534
|
+
if (context.dedupId !== void 0) if (UUID.test(context.dedupId)) dedup.uuid = context.dedupId;
|
|
535
|
+
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");
|
|
536
|
+
const body = {
|
|
537
|
+
api_key: apiKey,
|
|
538
|
+
event: name,
|
|
539
|
+
distinct_id: id,
|
|
540
|
+
...dedup,
|
|
541
|
+
properties,
|
|
542
|
+
timestamp: new Date(context.timestamp).toISOString()
|
|
543
|
+
};
|
|
544
|
+
const response = await fetch(`${host}/i/v0/e/`, {
|
|
545
|
+
method: "POST",
|
|
546
|
+
headers: { "content-type": "application/json" },
|
|
547
|
+
body: JSON.stringify(body)
|
|
548
|
+
});
|
|
549
|
+
if (!response.ok) {
|
|
550
|
+
const body = await response.text().catch(() => "");
|
|
551
|
+
throw new VendorResponseError(`posthog: the capture endpoint answered ${response.status}`, {
|
|
552
|
+
provider: "posthog",
|
|
553
|
+
status: response.status,
|
|
554
|
+
body
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/server.ts
|
|
562
|
+
/** `name=value; name=value`, the shape of a `Cookie` header. */
|
|
563
|
+
function parseCookies(cookies) {
|
|
564
|
+
if (!cookies) return {};
|
|
565
|
+
if (typeof cookies !== "string") return cookies;
|
|
566
|
+
const parsed = {};
|
|
567
|
+
for (const pair of cookies.split(";")) {
|
|
568
|
+
const separator = pair.indexOf("=");
|
|
569
|
+
if (separator < 1) continue;
|
|
570
|
+
const name = pair.slice(0, separator).trim();
|
|
571
|
+
if (name) parsed[name] = pair.slice(separator + 1).trim();
|
|
572
|
+
}
|
|
573
|
+
return parsed;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Creates the server tracking surface.
|
|
577
|
+
*
|
|
578
|
+
* There is no `identify` here and nothing is held between calls: a server
|
|
579
|
+
* instance is shared by every request, so a retained identity would attach
|
|
580
|
+
* one visitor to another visitor's conversion. Identity travels with the
|
|
581
|
+
* call, which is also what every conversion API expects.
|
|
582
|
+
*
|
|
583
|
+
* There is no `consent` call either. Consent commands are properties of a
|
|
584
|
+
* vendor's on-page tag, and a server request has no tag. The one vendor that
|
|
585
|
+
* accepts consent server-side reads it from the context.
|
|
586
|
+
*
|
|
587
|
+
* @param options - The providers to send to, and an optional error channel.
|
|
588
|
+
* @returns The `track` call.
|
|
589
|
+
*
|
|
590
|
+
* @example
|
|
591
|
+
* ```ts
|
|
592
|
+
* import { createTracking, ga4 } from 'tracklane/server';
|
|
593
|
+
*
|
|
594
|
+
* const { track } = createTracking({
|
|
595
|
+
* providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret })],
|
|
596
|
+
* });
|
|
597
|
+
*
|
|
598
|
+
* await track('purchase', order, { cookies: request.headers.get('cookie') });
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
function createTracking(options) {
|
|
602
|
+
const { providers } = options;
|
|
603
|
+
assertUniqueProviders(providers);
|
|
604
|
+
const report = makeReporter(options.onError);
|
|
605
|
+
return { async track(name, data = {}, context = {}) {
|
|
606
|
+
const { cookies, timestamp, ...rest } = context;
|
|
607
|
+
const resolved = {
|
|
608
|
+
...rest,
|
|
609
|
+
cookies: parseCookies(cookies),
|
|
610
|
+
timestamp: timestamp instanceof Date ? timestamp.getTime() : timestamp ?? Date.now()
|
|
611
|
+
};
|
|
612
|
+
const sends = providers.map(async (provider) => {
|
|
613
|
+
const target = resolveEvent(provider, name, (message) => {
|
|
614
|
+
report({
|
|
615
|
+
provider: provider.name,
|
|
616
|
+
event: name,
|
|
617
|
+
severity: "warning",
|
|
618
|
+
message
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
if (target === void 0) return;
|
|
622
|
+
try {
|
|
623
|
+
await provider.track(target, data, resolved, (message) => {
|
|
624
|
+
report({
|
|
625
|
+
provider: provider.name,
|
|
626
|
+
event: name,
|
|
627
|
+
severity: "warning",
|
|
628
|
+
message
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
} catch (cause) {
|
|
632
|
+
report({
|
|
633
|
+
provider: provider.name,
|
|
634
|
+
event: name,
|
|
635
|
+
severity: "error",
|
|
636
|
+
message: messageOf(cause),
|
|
637
|
+
cause
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
await Promise.all(sends);
|
|
642
|
+
} };
|
|
643
|
+
}
|
|
644
|
+
//#endregion
|
|
645
|
+
export { ga4 as a, linkedin as i, posthog as n, meta as r, createTracking as t };
|
|
646
|
+
|
|
647
|
+
//# sourceMappingURL=server-DnP7RtEn.js.map
|