@mantajs/plugin-posthog-proxy 0.2.0-beta.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.
Files changed (61) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +2 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/modules/posthog/api/[...path]/route.d.ts +6 -0
  6. package/dist/modules/posthog/api/[...path]/route.d.ts.map +1 -0
  7. package/dist/modules/posthog/api/[...path]/route.js +625 -0
  8. package/dist/modules/posthog/api/[...path]/route.js.map +1 -0
  9. package/dist/modules/posthog/commands/identify-user.d.ts +12 -0
  10. package/dist/modules/posthog/commands/identify-user.d.ts.map +1 -0
  11. package/dist/modules/posthog/commands/identify-user.js +31 -0
  12. package/dist/modules/posthog/commands/identify-user.js.map +1 -0
  13. package/dist/modules/posthog/commands/track-event.d.ts +15 -0
  14. package/dist/modules/posthog/commands/track-event.d.ts.map +1 -0
  15. package/dist/modules/posthog/commands/track-event.js +33 -0
  16. package/dist/modules/posthog/commands/track-event.js.map +1 -0
  17. package/dist/modules/posthog/entities/event/model.d.ts +3 -0
  18. package/dist/modules/posthog/entities/event/model.d.ts.map +1 -0
  19. package/dist/modules/posthog/entities/event/model.js +4 -0
  20. package/dist/modules/posthog/entities/event/model.js.map +1 -0
  21. package/dist/modules/posthog/entities/insight/model.d.ts +3 -0
  22. package/dist/modules/posthog/entities/insight/model.d.ts.map +1 -0
  23. package/dist/modules/posthog/entities/insight/model.js +4 -0
  24. package/dist/modules/posthog/entities/insight/model.js.map +1 -0
  25. package/dist/modules/posthog/entities/person/model.d.ts +3 -0
  26. package/dist/modules/posthog/entities/person/model.d.ts.map +1 -0
  27. package/dist/modules/posthog/entities/person/model.js +4 -0
  28. package/dist/modules/posthog/entities/person/model.js.map +1 -0
  29. package/dist/modules/posthog/queries/graph.d.ts +3 -0
  30. package/dist/modules/posthog/queries/graph.d.ts.map +1 -0
  31. package/dist/modules/posthog/queries/graph.js +23 -0
  32. package/dist/modules/posthog/queries/graph.js.map +1 -0
  33. package/dist/modules/posthog/queries/lib/execute.d.ts +26 -0
  34. package/dist/modules/posthog/queries/lib/execute.d.ts.map +1 -0
  35. package/dist/modules/posthog/queries/lib/execute.js +93 -0
  36. package/dist/modules/posthog/queries/lib/execute.js.map +1 -0
  37. package/dist/modules/posthog/queries/lib/schema.d.ts +13 -0
  38. package/dist/modules/posthog/queries/lib/schema.d.ts.map +1 -0
  39. package/dist/modules/posthog/queries/lib/schema.js +42 -0
  40. package/dist/modules/posthog/queries/lib/schema.js.map +1 -0
  41. package/dist/modules/posthog/queries/lib/translate.d.ts +15 -0
  42. package/dist/modules/posthog/queries/lib/translate.d.ts.map +1 -0
  43. package/dist/modules/posthog/queries/lib/translate.js +72 -0
  44. package/dist/modules/posthog/queries/lib/translate.js.map +1 -0
  45. package/dist/modules/posthog/schemas.d.ts +103 -0
  46. package/dist/modules/posthog/schemas.d.ts.map +1 -0
  47. package/dist/modules/posthog/schemas.js +42 -0
  48. package/dist/modules/posthog/schemas.js.map +1 -0
  49. package/package.json +37 -0
  50. package/src/index.ts +1 -0
  51. package/src/modules/posthog/api/[...path]/route.ts +672 -0
  52. package/src/modules/posthog/commands/identify-user.ts +32 -0
  53. package/src/modules/posthog/commands/track-event.ts +34 -0
  54. package/src/modules/posthog/entities/event/model.ts +4 -0
  55. package/src/modules/posthog/entities/insight/model.ts +4 -0
  56. package/src/modules/posthog/entities/person/model.ts +4 -0
  57. package/src/modules/posthog/queries/graph.ts +24 -0
  58. package/src/modules/posthog/queries/lib/execute.ts +111 -0
  59. package/src/modules/posthog/queries/lib/schema.ts +45 -0
  60. package/src/modules/posthog/queries/lib/translate.ts +73 -0
  61. package/src/modules/posthog/schemas.ts +48 -0
@@ -0,0 +1,2 @@
1
+ export declare const pluginName = "@mantajs/plugin-posthog-proxy";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,UAAU,kCAAkC,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export const pluginName = '@mantajs/plugin-posthog-proxy';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,UAAU,GAAG,+BAA+B,CAAA"}
@@ -0,0 +1,6 @@
1
+ export declare function OPTIONS(): Promise<Response>;
2
+ export declare function GET(req: Request): Promise<Response>;
3
+ export declare function POST(req: Request & {
4
+ app?: any;
5
+ }): Promise<Response>;
6
+ //# sourceMappingURL=route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../../../../src/modules/posthog/api/[...path]/route.ts"],"names":[],"mappings":"AAyEA,wBAAsB,OAAO,sBAE5B;AAED,wBAAsB,GAAG,CAAC,GAAG,EAAE,OAAO,qBAerC;AAED,wBAAsB,IAAI,CAAC,GAAG,EAAE,OAAO,GAAG;IAAE,GAAG,CAAC,EAAE,GAAG,CAAA;CAAE,qBA8FtD"}
@@ -0,0 +1,625 @@
1
+ // PostHog Proxy — catch-all raw route (escape hatch, NOT CQRS)
2
+ // Forwards all requests from the PostHog JS SDK (/capture/, /decide/, /e/, /s/, etc.)
3
+ // + optional Klaviyo identity bridge + inflight identity enrichment.
4
+ //
5
+ // Identity bridge: extracts $_kx (newsletter click) or $kla_id (__kla_id cookie)
6
+ // from event properties, resolves the email via Klaviyo API, and sends $identify to PostHog.
7
+ //
8
+ // Inflight identity enrichment (added 2026-04-21):
9
+ // Before forwarding any batch to PostHog, we decompress → parse → and for
10
+ // every event carrying a distinct_id but no $set.email, we look up the
11
+ // email (cache first, HogQL fallback) and inject `$set.email`. This way
12
+ // PostHog stores the event WITH the identity on it, so downstream
13
+ // consumers (our cart tracker, analytics queries, etc.) don't have to
14
+ // join against person.properties.email.
15
+ // The cache is populated from three sources:
16
+ // 1. Events that already carry $set.email (pass-through seeding)
17
+ // 2. Klaviyo / checkout identity bridges resolving an email
18
+ // 3. HogQL fallback `person.properties.email` lookup (5 min TTL)
19
+ import { gunzipSync, gzipSync } from 'node:zlib';
20
+ // ── In-memory caches ────────────────────────────────────────────────
21
+ const identityCache = new Map();
22
+ const identifiedIds = new Set();
23
+ const EMAIL_CACHE_TTL_MS = 5 * 60 * 1000;
24
+ const distinctIdToEmail = new Map();
25
+ function cacheEmail(distinctId, email) {
26
+ distinctIdToEmail.set(distinctId, { email, expires_at: Date.now() + EMAIL_CACHE_TTL_MS });
27
+ }
28
+ function getCachedEmail(distinctId) {
29
+ const entry = distinctIdToEmail.get(distinctId);
30
+ if (!entry)
31
+ return undefined;
32
+ if (entry.expires_at < Date.now()) {
33
+ distinctIdToEmail.delete(distinctId);
34
+ return undefined;
35
+ }
36
+ return entry.email;
37
+ }
38
+ const CORS_HEADERS = {
39
+ 'Access-Control-Allow-Origin': '*',
40
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
41
+ 'Access-Control-Allow-Headers': 'Content-Type',
42
+ };
43
+ function getConfig() {
44
+ return {
45
+ host: process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com',
46
+ publicToken: process.env.POSTHOG_TOKEN,
47
+ klaviyoApiKey: process.env.KLAVIYO_API_KEY,
48
+ apiKey: process.env.POSTHOG_API_KEY,
49
+ };
50
+ }
51
+ // ── Route handlers ──────────────────────────────────────────────────
52
+ export async function OPTIONS() {
53
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
54
+ }
55
+ export async function GET(req) {
56
+ const config = getConfig();
57
+ const targetUrl = `${config.host}${extractPath(req)}`;
58
+ const headers = {};
59
+ const ua = req.headers.get('user-agent');
60
+ if (ua)
61
+ headers['user-agent'] = ua;
62
+ const clientIp = req.headers.get('x-forwarded-for') ?? req.headers.get('x-real-ip');
63
+ if (clientIp)
64
+ headers['x-forwarded-for'] = clientIp;
65
+ const resp = await fetch(targetUrl, { headers });
66
+ return new Response(await resp.text(), {
67
+ status: resp.status,
68
+ headers: { ...CORS_HEADERS, 'Content-Type': resp.headers.get('Content-Type') ?? 'application/json' },
69
+ });
70
+ }
71
+ export async function POST(req) {
72
+ const config = getConfig();
73
+ const path = extractPath(req);
74
+ const targetUrl = `${config.host}${path}`;
75
+ // Read body as raw bytes to preserve gzip encoding for forwarding
76
+ const rawBytes = new Uint8Array(await req.arrayBuffer());
77
+ const ct = req.headers.get('content-type');
78
+ const headers = {};
79
+ if (ct)
80
+ headers['content-type'] = ct;
81
+ const ua = req.headers.get('user-agent');
82
+ if (ua)
83
+ headers['user-agent'] = ua;
84
+ // Forward client IP so PostHog GeoIP resolves the real user location, not the proxy's
85
+ const clientIp = req.headers.get('x-forwarded-for') ?? req.headers.get('x-real-ip');
86
+ if (clientIp)
87
+ headers['x-forwarded-for'] = clientIp;
88
+ // ── Parse body once (used for log + enrichment + identity bridges) ─
89
+ // Session recordings arrive as binary bytes — tryDecompress or JSON.parse
90
+ // fail, we fall through to forward as-is.
91
+ let parsed = null;
92
+ const isGzipped = rawBytes.length > 1 && rawBytes[0] === 0x1f && rawBytes[1] === 0x8b;
93
+ if (rawBytes.length > 0) {
94
+ try {
95
+ const jsonText = tryDecompress(rawBytes);
96
+ if (jsonText)
97
+ parsed = JSON.parse(jsonText);
98
+ }
99
+ catch {
100
+ // Not parseable (binary session recording, etc.) — parsed stays null
101
+ }
102
+ }
103
+ // ── Inflight identity enrichment ───────────────────────────────
104
+ // Inject $set.email on events whose distinct_id we already know an
105
+ // email for. Cache first, HogQL fallback for unknown ids. Blocks the
106
+ // forward briefly on cold cache (single lookup per distinct_id per TTL).
107
+ let forwardBytes = rawBytes;
108
+ if (parsed) {
109
+ const events = extractEventList(parsed);
110
+ for (const evt of events)
111
+ logEvent(path, evt);
112
+ const modified = await enrichEventsWithEmail(events, config);
113
+ if (modified) {
114
+ try {
115
+ const patchedJson = JSON.stringify(parsed);
116
+ forwardBytes = isGzipped
117
+ ? new Uint8Array(gzipSync(Buffer.from(patchedJson)))
118
+ : new Uint8Array(Buffer.from(patchedJson, 'utf-8'));
119
+ }
120
+ catch (err) {
121
+ console.error('[posthog-proxy] Re-serialize failed — forwarding original:', err);
122
+ forwardBytes = rawBytes;
123
+ }
124
+ }
125
+ }
126
+ // Forward (potentially enriched) bytes to PostHog
127
+ const resp = await fetch(targetUrl, { method: 'POST', headers, body: forwardBytes });
128
+ const responseBody = await resp.text();
129
+ // ── Identity bridges (fire-and-forget) ──────────────────────────
130
+ if (parsed) {
131
+ // Checkout identity: resolve email from checkout:contact_info_submitted
132
+ processCheckoutIdentity(parsed, config, clientIp).catch((err) => {
133
+ console.error('[posthog-proxy] Checkout identity error:', err);
134
+ });
135
+ // Klaviyo identity: resolve email from $_kx / $kla_id tokens.
136
+ // Pass the request's `app` so processEvents can emit a framework event
137
+ // every time it resolves a Klaviyo identity (consumed by demo-owned
138
+ // subscribers, e.g. `klaviyo-identity-to-session` which stamps the
139
+ // currently-open visitor_session as newsletter-acquired).
140
+ if (config.klaviyoApiKey) {
141
+ processEvents(parsed, config, clientIp, req.app).catch((err) => {
142
+ console.error('[posthog-proxy] Klaviyo bridge error:', err);
143
+ });
144
+ }
145
+ // Publish an internal framework event. Any subscriber listening to
146
+ // 'posthog.events.received' (e.g. a demo-side subscriber that routes to
147
+ // ingestCartEvent) can react. The plugin stays pure — no DB writes,
148
+ // no command coupling, no demo schema knowledge.
149
+ //
150
+ // If the app or event bus is not available, skip silently — the plugin
151
+ // must work in environments that don't register a Manta app.
152
+ const app = req.app;
153
+ if (app?.emit) {
154
+ app.emit('posthog.events.received', { body: parsed }).catch((err) => {
155
+ console.error('[posthog-proxy] emit posthog.events.received error:', err);
156
+ });
157
+ }
158
+ }
159
+ return new Response(responseBody, {
160
+ status: resp.status,
161
+ headers: { ...CORS_HEADERS, 'Content-Type': resp.headers.get('Content-Type') ?? 'application/json' },
162
+ });
163
+ }
164
+ /** Normalize a PostHog batch body into an array of event objects. */
165
+ function extractEventList(body) {
166
+ if (Array.isArray(body))
167
+ return body;
168
+ const obj = body;
169
+ if (Array.isArray(obj.batch))
170
+ return obj.batch;
171
+ return [obj];
172
+ }
173
+ function logEvent(path, evt) {
174
+ const eventName = evt.event;
175
+ if (eventName === '$snapshot')
176
+ return;
177
+ const props = evt.properties;
178
+ const distinctId = (evt.distinct_id ?? props?.distinct_id);
179
+ console.log(`[posthog-proxy] ${path} ← event: ${eventName ?? '?'} | distinct_id: ${distinctId ?? '?'} | url: ${props?.$current_url ?? '-'}`);
180
+ }
181
+ /** Known paths where an email could be on a raw event (same spread as extractEmailFromCheckout). */
182
+ function getEventKnownEmail(evt) {
183
+ const props = evt.properties;
184
+ if (!props)
185
+ return null;
186
+ const $set = props.$set;
187
+ if (typeof $set?.email === 'string')
188
+ return $set.email;
189
+ const checkout = props.checkout;
190
+ if (typeof checkout?.email === 'string')
191
+ return checkout.email;
192
+ // @legacy-schema-v1 — root-level email paths from pre-unified pixel
193
+ if (typeof props.email === 'string')
194
+ return props.email;
195
+ if (typeof props.$email === 'string')
196
+ return props.$email;
197
+ return null;
198
+ }
199
+ /**
200
+ * Enrich events in place: for each event missing $set.email, inject the
201
+ * known email (cache or HogQL lookup) for its distinct_id. Returns true
202
+ * if at least one event was modified (= the body must be re-serialized
203
+ * before forwarding).
204
+ */
205
+ async function enrichEventsWithEmail(events, config) {
206
+ // Pass 1: seed the cache from every event that already has an email.
207
+ // This lets a same-batch cart event without $set.email benefit from the
208
+ // $identify event sitting right next to it.
209
+ for (const evt of events) {
210
+ const props = evt.properties;
211
+ const distinctId = (evt.distinct_id ?? props?.distinct_id);
212
+ if (!distinctId)
213
+ continue;
214
+ const email = getEventKnownEmail(evt);
215
+ if (email)
216
+ cacheEmail(distinctId, email);
217
+ }
218
+ // Pass 2: collect events that still need enrichment, deduplicate by
219
+ // distinct_id so a burst of cart events for the same user triggers at
220
+ // most ONE HogQL lookup.
221
+ const eventsToEnrich = [];
222
+ const lookupsNeeded = new Set();
223
+ for (const evt of events) {
224
+ const eventName = evt.event;
225
+ if (eventName === '$snapshot')
226
+ continue;
227
+ const props = evt.properties;
228
+ const distinctId = (evt.distinct_id ?? props?.distinct_id);
229
+ if (!distinctId)
230
+ continue;
231
+ if (getEventKnownEmail(evt))
232
+ continue;
233
+ eventsToEnrich.push({ evt, distinctId });
234
+ const cached = getCachedEmail(distinctId);
235
+ if (cached === undefined)
236
+ lookupsNeeded.add(distinctId);
237
+ }
238
+ if (eventsToEnrich.length === 0)
239
+ return false;
240
+ // Parallel HogQL lookups for cold-cache distinct_ids. Cache null results
241
+ // too to prevent re-querying for ids with no identity.
242
+ if (lookupsNeeded.size > 0) {
243
+ await Promise.all(Array.from(lookupsNeeded).map(async (id) => {
244
+ const email = await lookupEmailFromPostHog(id, config);
245
+ cacheEmail(id, email);
246
+ }));
247
+ }
248
+ // Apply enrichment
249
+ let modified = false;
250
+ for (const { evt, distinctId } of eventsToEnrich) {
251
+ const email = getCachedEmail(distinctId);
252
+ if (!email)
253
+ continue;
254
+ if (!evt.properties)
255
+ evt.properties = {};
256
+ const props = evt.properties;
257
+ const $set = props.$set ?? {};
258
+ props.$set = { ...$set, email };
259
+ modified = true;
260
+ }
261
+ return modified;
262
+ }
263
+ /**
264
+ * HogQL lookup for `person.properties.email` by distinct_id. Returns null
265
+ * when the api key is missing, the request fails, or the person has no
266
+ * known email.
267
+ */
268
+ async function lookupEmailFromPostHog(distinctId, config) {
269
+ if (!config.apiKey)
270
+ return null;
271
+ const safe = distinctId.replace(/'/g, "''");
272
+ try {
273
+ const res = await fetch(`${config.host}/api/projects/@current/query/`, {
274
+ method: 'POST',
275
+ headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' },
276
+ body: JSON.stringify({
277
+ query: {
278
+ kind: 'HogQLQuery',
279
+ query: `SELECT person.properties.email FROM events WHERE distinct_id = '${safe}' AND person.properties.email IS NOT NULL AND person.properties.email != '' ORDER BY timestamp DESC LIMIT 1`,
280
+ },
281
+ }),
282
+ });
283
+ if (!res.ok)
284
+ return null;
285
+ const data = (await res.json());
286
+ return data.results?.[0]?.[0] ?? null;
287
+ }
288
+ catch {
289
+ return null;
290
+ }
291
+ }
292
+ /** Try to decompress gzip, fall back to raw text */
293
+ function tryDecompress(bytes) {
294
+ if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
295
+ try {
296
+ return gunzipSync(Buffer.from(bytes)).toString('utf-8');
297
+ }
298
+ catch {
299
+ return null;
300
+ }
301
+ }
302
+ try {
303
+ return new TextDecoder().decode(bytes);
304
+ }
305
+ catch {
306
+ return null;
307
+ }
308
+ }
309
+ // ── Helpers ─────────────────────────────────────────────────────────
310
+ function extractPath(req) {
311
+ const url = new URL(req.url);
312
+ return url.pathname.replace(/^\/api\/posthog/, '') || '/';
313
+ }
314
+ // ── Checkout identity bridge ────────────────────────────────────
315
+ // When checkout:contact_info_submitted arrives, extract the email
316
+ // and $identify the anonymous checkout distinct_id.
317
+ const CHECKOUT_EVENTS_WITH_EMAIL = new Set([
318
+ 'checkout:contact_info_submitted',
319
+ 'checkout:completed',
320
+ 'checkout:shipping_info_submitted',
321
+ ]);
322
+ async function processCheckoutIdentity(body, config, clientIp) {
323
+ const events = Array.isArray(body) ? body : (body.batch ?? [body]);
324
+ for (const event of events) {
325
+ const eventName = event.event;
326
+ if (!eventName)
327
+ continue;
328
+ // Log full properties for ALL checkout:* events (debug)
329
+ if (eventName.startsWith('checkout:')) {
330
+ console.log(`[posthog-proxy] CHECKOUT EVENT DUMP: ${eventName}`, JSON.stringify(event, null, 2));
331
+ }
332
+ // Only try to identify on events that carry email
333
+ if (!CHECKOUT_EVENTS_WITH_EMAIL.has(eventName))
334
+ continue;
335
+ const props = event.properties;
336
+ const distinctId = (event.distinct_id ?? props?.distinct_id);
337
+ if (!distinctId)
338
+ continue;
339
+ if (identifiedIds.has(distinctId))
340
+ continue;
341
+ const $set = props?.$set;
342
+ // Extract email from $set (confirmed Shopify structure)
343
+ const email = extractEmailFromCheckout(event, props);
344
+ if (!email) {
345
+ console.log(`[posthog-proxy] ${eventName}: no email found for ${distinctId}`);
346
+ continue;
347
+ }
348
+ const firstName = $set?.first_name;
349
+ const lastName = $set?.last_name;
350
+ // v2 unified schema: shopify_customer_id lives on CheckoutPayload.
351
+ // @legacy-schema-v1 fallback: v1 put the Shopify customer ID as `$set.id`.
352
+ const checkout = props?.checkout;
353
+ const shopifyCustomerId = (checkout?.shopify_customer_id ?? $set?.id);
354
+ // 1. Send $identify — keep the original distinct_id, put person data in $set
355
+ console.log(`[posthog-proxy] ${eventName}: found email ${email} for ${distinctId} — sending $identify`);
356
+ await sendPostHogEvent(config, clientIp, {
357
+ api_key: config.publicToken,
358
+ event: '$identify',
359
+ distinct_id: distinctId,
360
+ properties: {
361
+ $set: {
362
+ email,
363
+ ...(firstName && { first_name: firstName }),
364
+ ...(lastName && { last_name: lastName }),
365
+ ...(shopifyCustomerId && { shopify_customer_id: shopifyCustomerId }),
366
+ checkout_identified: true,
367
+ identified_at: new Date().toISOString(),
368
+ },
369
+ },
370
+ });
371
+ // Warm the email cache so the inflight enrichment on subsequent
372
+ // cart/checkout events hits without needing a HogQL roundtrip.
373
+ cacheEmail(distinctId, email);
374
+ // 2. Merge store distinct_id → checkout distinct_id (same person)
375
+ const storeDistinctId = (props?._distinct_id ?? props?._store_distinct_id);
376
+ if (storeDistinctId && storeDistinctId !== distinctId) {
377
+ console.log(`[posthog-proxy] ${eventName}: merging store ${storeDistinctId} → checkout ${distinctId} via $identify`);
378
+ // Identify the STORE distinct_id with the same email — PostHog merges both into one person
379
+ await sendPostHogEvent(config, clientIp, {
380
+ api_key: config.publicToken,
381
+ event: '$identify',
382
+ distinct_id: storeDistinctId,
383
+ properties: {
384
+ $set: {
385
+ email,
386
+ ...(firstName && { first_name: firstName }),
387
+ ...(lastName && { last_name: lastName }),
388
+ checkout_identified: true,
389
+ identified_at: new Date().toISOString(),
390
+ },
391
+ },
392
+ });
393
+ identifiedIds.add(storeDistinctId);
394
+ cacheEmail(storeDistinctId, email);
395
+ }
396
+ // 3. Send $create_alias — link Shopify customer ID to this distinct_id
397
+ if (shopifyCustomerId) {
398
+ console.log(`[posthog-proxy] ${eventName}: aliasing shopify_customer_id ${shopifyCustomerId} → ${distinctId}`);
399
+ await sendPostHogEvent(config, clientIp, {
400
+ api_key: config.publicToken,
401
+ event: '$create_alias',
402
+ distinct_id: distinctId,
403
+ properties: {
404
+ alias: String(shopifyCustomerId),
405
+ },
406
+ });
407
+ }
408
+ identifiedIds.add(distinctId);
409
+ }
410
+ }
411
+ /**
412
+ * Resolve an email from a checkout event across supported schemas.
413
+ *
414
+ * v2 (unified schema): email is at `properties.checkout.email` (canonical)
415
+ * or `properties.$set.email` (after identify)
416
+ *
417
+ * Other paths are `@legacy-schema-v1` fallbacks for old events still in
418
+ * PostHog storage. Safe to remove once retention rolls past the v2 cutover.
419
+ * → BACKLOG.md: "Remove PostHog legacy schema v1"
420
+ */
421
+ function extractEmailFromCheckout(_event, props) {
422
+ // v2 canonical: $set.email (from identify) and checkout.email (Shopify pixel)
423
+ const $set = props?.$set;
424
+ if (typeof $set?.email === 'string')
425
+ return $set.email;
426
+ const checkout = props?.checkout;
427
+ if (typeof checkout?.email === 'string')
428
+ return checkout.email;
429
+ // @legacy-schema-v1 — root-level email + misc Shopify paths from v1 pixel
430
+ if (typeof props?.email === 'string')
431
+ return props.email;
432
+ if (typeof props?.$email === 'string')
433
+ return props.$email;
434
+ if (typeof $set?.$email === 'string')
435
+ return $set.$email;
436
+ const customer = (checkout?.customer ?? props?.customer);
437
+ if (typeof customer?.email === 'string')
438
+ return customer.email;
439
+ const billing = (checkout?.billingAddress ?? props?.billingAddress);
440
+ if (typeof billing?.email === 'string')
441
+ return billing.email;
442
+ if (typeof props?.$user_email === 'string')
443
+ return props.$user_email;
444
+ return null;
445
+ }
446
+ // ── Klaviyo identity bridge ─────────────────────────────────────────
447
+ async function processEvents(body, config, clientIp,
448
+ // biome-ignore lint/suspicious/noExplicitAny: app is host-injected — see POST(req) handler above
449
+ app) {
450
+ const events = Array.isArray(body) ? body : (body.batch ?? [body]);
451
+ for (const event of events) {
452
+ // Skip session recording snapshots (no user properties)
453
+ if (event.event === '$snapshot')
454
+ continue;
455
+ const props = event.properties;
456
+ const distinctId = (event.distinct_id ?? props?.distinct_id);
457
+ if (!distinctId)
458
+ continue;
459
+ if (identifiedIds.has(distinctId))
460
+ continue;
461
+ // Try to extract a Klaviyo exchange token from multiple sources:
462
+ // 1. $_kx in properties (PostHog SDK cookie)
463
+ // 2. $kla_id from __kla_id cookie (registered via posthog.register)
464
+ // 3. $_kx or _kx in $set (PostHog SDK puts URL params in $set)
465
+ // 4. _kx from $current_url query param (newsletter link)
466
+ const $set = props?.$set;
467
+ const kxFromUrl = extractKxFromUrl(props?.$current_url);
468
+ const exchangeId = extractExchangeId(props?.$_kx, props?.$kla_id, $set?.$_kx ?? $set?._kx, kxFromUrl);
469
+ if (!exchangeId)
470
+ continue;
471
+ console.log(`[posthog-proxy] Resolving Klaviyo identity for distinct_id: ${distinctId}, exchangeId: ${exchangeId.slice(0, 30)}...`);
472
+ try {
473
+ const email = await resolveKlaviyoEmail(exchangeId, config);
474
+ console.log(`[posthog-proxy] Klaviyo result: ${email ?? 'null'}`);
475
+ if (email) {
476
+ await identifyInPostHog(distinctId, email, config, clientIp);
477
+ identifiedIds.add(distinctId);
478
+ cacheEmail(distinctId, email);
479
+ console.log(`[posthog-proxy] ✓ Identified ${distinctId} as ${email}`);
480
+ // Publish a framework event so demo-side subscribers can react
481
+ // (e.g. stamp the currently-open visitor_session as
482
+ // newsletter-acquired). Mirrors the `posthog.events.received`
483
+ // pattern in the POST handler — fire-and-forget, guarded by
484
+ // `app?.emit` so the plugin still works without a Manta host.
485
+ // Event name kebab-case to satisfy the framework's subscriber-name
486
+ // regex (`[a-zA-Z0-9][a-zA-Z0-9.-]*`, no underscores).
487
+ const emit = app?.emit;
488
+ if (emit) {
489
+ emit('posthog.klaviyo-identity-resolved', { distinct_id: distinctId, email }).catch((err) => {
490
+ console.warn(`[posthog-proxy] emit posthog.klaviyo-identity-resolved failed: ${err.message}`);
491
+ });
492
+ }
493
+ }
494
+ }
495
+ catch (err) {
496
+ console.log(`[posthog-proxy] ERROR resolving: ${err.message}`);
497
+ }
498
+ }
499
+ }
500
+ /**
501
+ * Extract the $exchange_id from either $_kx or $kla_id.
502
+ *
503
+ * $_kx: raw exchange_id from newsletter URL param (e.g. "g8yDA5d2_J7Ub...")
504
+ * OR base64 JSON from PostHog SDK cookie reading.
505
+ * $kla_id: base64 JSON from __kla_id cookie: {"cid":"...", "$exchange_id":"..."}
506
+ * If only cid exists (no $exchange_id), the user is anonymous — skip.
507
+ */
508
+ function extractExchangeId(...tokens) {
509
+ for (const token of tokens) {
510
+ if (!token)
511
+ continue;
512
+ // Try base64 JSON first (cookie format)
513
+ try {
514
+ const decoded = JSON.parse(Buffer.from(token, 'base64').toString());
515
+ if (decoded.$exchange_id)
516
+ return decoded.$exchange_id;
517
+ }
518
+ catch {
519
+ // Not base64 JSON — could be raw exchange_id from URL
520
+ // Raw exchange_ids contain dots (e.g. "g8yDA5d2_J7Ub...VeFGwD")
521
+ if (token.includes('.') && token.length > 10)
522
+ return token;
523
+ }
524
+ }
525
+ return null;
526
+ }
527
+ function extractKxFromUrl(url) {
528
+ if (!url)
529
+ return null;
530
+ try {
531
+ const parsed = new URL(url);
532
+ return parsed.searchParams.get('_kx');
533
+ }
534
+ catch {
535
+ return null;
536
+ }
537
+ }
538
+ /**
539
+ * Resolve a Klaviyo $exchange_id to an email using the profile-import endpoint.
540
+ * This is the correct API for exchange tokens (not GET /profiles/?filter=...).
541
+ */
542
+ async function resolveKlaviyoEmail(exchangeId, config) {
543
+ if (identityCache.has(exchangeId))
544
+ return identityCache.get(exchangeId);
545
+ if (!config.klaviyoApiKey)
546
+ return null;
547
+ const MAX_RETRIES = 3;
548
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
549
+ try {
550
+ const res = await fetch('https://a.klaviyo.com/api/profile-import/', {
551
+ method: 'POST',
552
+ headers: {
553
+ Authorization: `Klaviyo-API-Key ${config.klaviyoApiKey}`,
554
+ 'Content-Type': 'application/json',
555
+ accept: 'application/json',
556
+ revision: '2024-10-15',
557
+ },
558
+ body: JSON.stringify({
559
+ data: {
560
+ type: 'profile',
561
+ attributes: { _kx: exchangeId },
562
+ },
563
+ }),
564
+ });
565
+ if (!res.ok) {
566
+ console.error(`[posthog-proxy] Klaviyo API error ${res.status}: ${await res.text()}`);
567
+ return null;
568
+ }
569
+ const data = (await res.json());
570
+ const email = data.data?.attributes?.email;
571
+ if (email) {
572
+ identityCache.set(exchangeId, email);
573
+ console.log(`[posthog-proxy] Klaviyo resolved: ${email}`);
574
+ }
575
+ return email ?? null;
576
+ }
577
+ catch (err) {
578
+ if (attempt < MAX_RETRIES) {
579
+ console.warn(`[posthog-proxy] Klaviyo fetch failed (attempt ${attempt}/${MAX_RETRIES}), retrying...`);
580
+ await new Promise((r) => setTimeout(r, 200 * attempt));
581
+ continue;
582
+ }
583
+ console.error(`[posthog-proxy] Klaviyo failed after ${MAX_RETRIES} attempts:`, err.message);
584
+ return null;
585
+ }
586
+ }
587
+ return null;
588
+ }
589
+ /** Low-level: send a single event to PostHog ingest API */
590
+ async function sendPostHogEvent(config, clientIp, payload) {
591
+ if (!config.publicToken) {
592
+ console.warn('[posthog-proxy] POSTHOG_TOKEN not set — cannot send event');
593
+ return;
594
+ }
595
+ try {
596
+ const fetchHeaders = { 'Content-Type': 'application/json' };
597
+ if (clientIp)
598
+ fetchHeaders['x-forwarded-for'] = clientIp;
599
+ const res = await fetch(`${config.host}/i/v0/e/`, {
600
+ method: 'POST',
601
+ headers: fetchHeaders,
602
+ body: JSON.stringify(payload),
603
+ });
604
+ console.log(`[posthog-proxy] PostHog ${payload?.event} response: ${res.status}`);
605
+ }
606
+ catch (err) {
607
+ console.error(`[posthog-proxy] sendPostHogEvent error (${payload?.event}):`, err);
608
+ }
609
+ }
610
+ /** Klaviyo identity bridge: identify anonymous distinct_id with resolved email */
611
+ async function identifyInPostHog(distinctId, email, config, clientIp) {
612
+ await sendPostHogEvent(config, clientIp, {
613
+ api_key: config.publicToken,
614
+ event: '$identify',
615
+ distinct_id: distinctId,
616
+ properties: {
617
+ $set: {
618
+ email,
619
+ klaviyo_identified: true,
620
+ identified_at: new Date().toISOString(),
621
+ },
622
+ },
623
+ });
624
+ }
625
+ //# sourceMappingURL=route.js.map