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