@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.
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/posthog/api/[...path]/route.d.ts +6 -0
- package/dist/modules/posthog/api/[...path]/route.d.ts.map +1 -0
- package/dist/modules/posthog/api/[...path]/route.js +625 -0
- package/dist/modules/posthog/api/[...path]/route.js.map +1 -0
- package/dist/modules/posthog/commands/identify-user.d.ts +12 -0
- package/dist/modules/posthog/commands/identify-user.d.ts.map +1 -0
- package/dist/modules/posthog/commands/identify-user.js +31 -0
- package/dist/modules/posthog/commands/identify-user.js.map +1 -0
- package/dist/modules/posthog/commands/track-event.d.ts +15 -0
- package/dist/modules/posthog/commands/track-event.d.ts.map +1 -0
- package/dist/modules/posthog/commands/track-event.js +33 -0
- package/dist/modules/posthog/commands/track-event.js.map +1 -0
- package/dist/modules/posthog/entities/event/model.d.ts +3 -0
- package/dist/modules/posthog/entities/event/model.d.ts.map +1 -0
- package/dist/modules/posthog/entities/event/model.js +4 -0
- package/dist/modules/posthog/entities/event/model.js.map +1 -0
- package/dist/modules/posthog/entities/insight/model.d.ts +3 -0
- package/dist/modules/posthog/entities/insight/model.d.ts.map +1 -0
- package/dist/modules/posthog/entities/insight/model.js +4 -0
- package/dist/modules/posthog/entities/insight/model.js.map +1 -0
- package/dist/modules/posthog/entities/person/model.d.ts +3 -0
- package/dist/modules/posthog/entities/person/model.d.ts.map +1 -0
- package/dist/modules/posthog/entities/person/model.js +4 -0
- package/dist/modules/posthog/entities/person/model.js.map +1 -0
- package/dist/modules/posthog/queries/graph.d.ts +3 -0
- package/dist/modules/posthog/queries/graph.d.ts.map +1 -0
- package/dist/modules/posthog/queries/graph.js +23 -0
- package/dist/modules/posthog/queries/graph.js.map +1 -0
- package/dist/modules/posthog/queries/lib/execute.d.ts +26 -0
- package/dist/modules/posthog/queries/lib/execute.d.ts.map +1 -0
- package/dist/modules/posthog/queries/lib/execute.js +93 -0
- package/dist/modules/posthog/queries/lib/execute.js.map +1 -0
- package/dist/modules/posthog/queries/lib/schema.d.ts +13 -0
- package/dist/modules/posthog/queries/lib/schema.d.ts.map +1 -0
- package/dist/modules/posthog/queries/lib/schema.js +42 -0
- package/dist/modules/posthog/queries/lib/schema.js.map +1 -0
- package/dist/modules/posthog/queries/lib/translate.d.ts +15 -0
- package/dist/modules/posthog/queries/lib/translate.d.ts.map +1 -0
- package/dist/modules/posthog/queries/lib/translate.js +72 -0
- package/dist/modules/posthog/queries/lib/translate.js.map +1 -0
- package/dist/modules/posthog/schemas.d.ts +103 -0
- package/dist/modules/posthog/schemas.d.ts.map +1 -0
- package/dist/modules/posthog/schemas.js +42 -0
- package/dist/modules/posthog/schemas.js.map +1 -0
- package/package.json +37 -0
- package/src/index.ts +1 -0
- package/src/modules/posthog/api/[...path]/route.ts +672 -0
- package/src/modules/posthog/commands/identify-user.ts +32 -0
- package/src/modules/posthog/commands/track-event.ts +34 -0
- package/src/modules/posthog/entities/event/model.ts +4 -0
- package/src/modules/posthog/entities/insight/model.ts +4 -0
- package/src/modules/posthog/entities/person/model.ts +4 -0
- package/src/modules/posthog/queries/graph.ts +24 -0
- package/src/modules/posthog/queries/lib/execute.ts +111 -0
- package/src/modules/posthog/queries/lib/schema.ts +45 -0
- package/src/modules/posthog/queries/lib/translate.ts +73 -0
- 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
|
+
}
|