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.
- package/README.md +1 -1
- package/dist/browser-ChcWOQ3N.js +473 -0
- package/dist/browser-ChcWOQ3N.js.map +1 -0
- package/dist/{browser-_0LRgTpx.d.ts → browser-k4gaaQgF.d.ts} +200 -21
- package/dist/browser.d.ts +2 -2
- package/dist/browser.js +2 -397
- package/dist/conformance.d.ts +7 -1
- package/dist/conformance.js +72 -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 +1 -1
- package/dist/server-bgUN2MOi.js +822 -0
- package/dist/server-bgUN2MOi.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -531
- package/dist/{errors-DLRGPWMj.js → src-DLRGPWMj.js} +1 -1
- package/dist/src-DLRGPWMj.js.map +1 -0
- package/package.json +4 -3
- package/dist/browser.js.map +0 -1
- package/dist/dispatch-BLBoCn-b.js.map +0 -1
- package/dist/errors-DLRGPWMj.js.map +0 -1
- package/dist/server.js.map +0 -1
|
@@ -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
|