posthog-client.ts425 lines · main
1import posthog, { PostHogConfig } from 'posthog-js'
2
3// Limit the max number of queued events
4// (e.g. if a user navigates around a lot before accepting consent)
5const MAX_PENDING_EVENTS = 20
6
7export interface ClientTelemetryEvent {
8 id: string
9 timestamp: number
10 eventType: 'capture' | 'identify' | 'pageview' | 'pageleave'
11 eventName: string
12 distinctId?: string
13 properties?: Record<string, unknown>
14}
15
16type ClientTelemetryListener = (event: ClientTelemetryEvent) => void
17
18interface PostHogClientConfig {
19 apiKey?: string
20 apiHost?: string
21 uiHost?: string
22}
23
24class PostHogClient {
25 /** True after posthog.init() is called (prevents double-init) */
26 private initStarted = false
27 /** True after the `loaded` callback fires, meaning PostHog has fully bootstrapped */
28 private initialized = false
29 private pendingGroups: Record<string, string> = {}
30 private pendingIdentification: { userId: string; properties?: Record<string, any> } | null = null
31 private pendingEvents: Array<{ event: string; properties: Record<string, any> }> = []
32 private pendingExposures: Array<{ experimentId: string; properties: Record<string, any> }> = []
33 private config: PostHogClientConfig
34 private readonly maxPendingEvents = MAX_PENDING_EVENTS
35 private devListeners: Set<ClientTelemetryListener> = new Set()
36 private pendingFeatureFlagCallbacks: Set<() => void> = new Set()
37
38 constructor(config: PostHogClientConfig = {}) {
39 const apiHost =
40 config.apiHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://ph.briven.green'
41 const uiHost =
42 config.uiHost || process.env.NEXT_PUBLIC_POSTHOG_UI_HOST || 'https://eu.posthog.com'
43
44 this.config = {
45 apiKey: config.apiKey || process.env.NEXT_PUBLIC_POSTHOG_KEY,
46 apiHost,
47 uiHost,
48 }
49 }
50
51 init(hasConsent: boolean = true) {
52 if (this.initStarted || typeof window === 'undefined' || !hasConsent) return
53
54 if (!this.config.apiKey) {
55 console.warn('PostHog API key not found. Skipping initialization.')
56 return
57 }
58
59 const config: Partial<PostHogConfig> = {
60 api_host: this.config.apiHost,
61 ui_host: this.config.uiHost,
62 autocapture: false, // We'll manually track events
63 capture_pageview: false, // We'll manually track pageviews
64 capture_pageleave: false, // We'll manually track page leaves
65 loaded: (posthog) => {
66 // Apply pending properties that were set before PostHog
67 // initialized due to poor connection or user not accepting
68 // consent right away
69
70 // Apply any pending groups
71 Object.entries(this.pendingGroups).forEach(([type, id]) => {
72 posthog.group(type, id)
73 })
74 this.pendingGroups = {}
75
76 // Apply any pending identification
77 if (this.pendingIdentification) {
78 try {
79 posthog.identify(
80 this.pendingIdentification.userId,
81 this.pendingIdentification.properties
82 )
83 } catch (error) {
84 console.error('PostHog identify failed:', error)
85 }
86 this.pendingIdentification = null
87 }
88
89 // Flush any pending events
90 this.pendingEvents.forEach(({ event, properties }) => {
91 try {
92 posthog.capture(event, properties, { transport: 'sendBeacon' })
93 } catch (error) {
94 console.error('PostHog capture failed:', error)
95 }
96 })
97 this.pendingEvents = []
98
99 this.initialized = true
100
101 // Flush any pending experiment exposures (with deduplication)
102 this.pendingExposures.forEach(({ experimentId, properties }) => {
103 this.fireExposureIfNew(experimentId, properties)
104 })
105 this.pendingExposures = []
106 },
107 }
108
109 this.initStarted = true
110 posthog.init(this.config.apiKey, config)
111
112 // Register any feature flag callbacks that were queued before init
113 this.pendingFeatureFlagCallbacks.forEach((cb) => posthog.onFeatureFlags(cb))
114 this.pendingFeatureFlagCallbacks.clear()
115 }
116
117 capturePageView(properties: Record<string, any>, hasConsent: boolean = true) {
118 if (!hasConsent) return
119
120 if (!this.initialized) {
121 // Queue the event for when PostHog initializes (up to cap)
122 // (e.g. poor connection or user not accepting consent right away)
123 if (this.pendingEvents.length >= this.maxPendingEvents) {
124 this.pendingEvents.shift() // Remove oldest event
125 }
126 this.pendingEvents.push({ event: '$pageview', properties })
127 return
128 }
129
130 try {
131 // Store groups from properties if present (for later group() calls)
132 if (properties.$groups) {
133 Object.entries(properties.$groups).forEach(([type, id]) => {
134 if (id) posthog.group(type, id as string)
135 })
136 }
137
138 posthog.capture('$pageview', properties, { transport: 'sendBeacon' })
139
140 this.emitToDevListeners('pageview', '$pageview', properties)
141 } catch (error) {
142 console.error('PostHog pageview capture failed:', error)
143 }
144 }
145
146 capturePageLeave(properties: Record<string, any>, hasConsent: boolean = true) {
147 if (!hasConsent) return
148
149 if (!this.initialized) {
150 // Queue the event for when PostHog initializes (up to cap)
151 // (e.g. poor connection or user not accepting consent right away)
152 if (this.pendingEvents.length >= this.maxPendingEvents) {
153 this.pendingEvents.shift() // Remove oldest event
154 }
155 this.pendingEvents.push({ event: '$pageleave', properties })
156 return
157 }
158
159 try {
160 // Use sendBeacon for page leave to survive tab close
161 posthog.capture('$pageleave', properties, { transport: 'sendBeacon' })
162
163 this.emitToDevListeners('pageleave', '$pageleave', properties)
164 } catch (error) {
165 console.error('PostHog pageleave capture failed:', error)
166 }
167 }
168
169 identify(userId: string, properties?: Record<string, any>, hasConsent: boolean = true) {
170 if (!hasConsent) return
171
172 if (!this.initialized) {
173 // Queue the identification for when PostHog initializes. Merge properties
174 // across pre-init calls for the same user so callers don't clobber each
175 // other (e.g. useTelemetryIdentify sets gotrue_id, then a separate effect
176 // sets org_count — both should land when the SDK flushes).
177 const pending = this.pendingIdentification
178 this.pendingIdentification =
179 pending && pending.userId === userId
180 ? { userId, properties: { ...pending.properties, ...properties } }
181 : { userId, properties }
182 return
183 }
184
185 try {
186 posthog.identify(userId, properties)
187
188 this.emitToDevListeners('identify', '$identify', { userId, ...properties })
189 } catch (error) {
190 console.error('PostHog identify failed:', error)
191 }
192 }
193
194 reset() {
195 this.pendingIdentification = null
196 this.pendingGroups = {}
197 this.pendingEvents = []
198 this.pendingExposures = []
199
200 if (!this.initStarted) return
201
202 try {
203 posthog.reset()
204 } catch (error) {
205 console.error('PostHog reset failed:', error)
206 }
207 }
208
209 /**
210 * Returns PostHog's distinct_id, which holds first-touch attribution data.
211 * Falls back to reading from PostHog cookie if SDK isn't initialized yet
212 * (e.g., immediately after OAuth redirect before PostHog loads).
213 */
214 getDistinctId(): string | undefined {
215 if (this.initialized) {
216 try {
217 return posthog.get_distinct_id()
218 } catch (error) {
219 console.error('PostHog getDistinctId failed:', error)
220 }
221 }
222
223 // Fallback: parse distinct_id from PostHog cookie
224 return this.getDistinctIdFromCookie()
225 }
226
227 /**
228 * Parse distinct_id from PostHog cookie.
229 * PostHog stores data in a cookie named `ph_<api_key>_posthog` with format:
230 * { distinct_id: "...", ... }
231 */
232 private getDistinctIdFromCookie(): string | undefined {
233 if (typeof document === 'undefined') return undefined
234
235 try {
236 const cookieName = `ph_${this.config.apiKey}_posthog`
237 const cookies = document.cookie.split(';')
238
239 for (const cookie of cookies) {
240 const trimmed = cookie.trim()
241 const eqIndex = trimmed.indexOf('=')
242 if (eqIndex === -1) continue
243
244 const name = trimmed.substring(0, eqIndex)
245 if (name !== cookieName) continue
246
247 // Use substring instead of split to handle '=' chars in the value
248 const cookieValue = decodeURIComponent(trimmed.substring(eqIndex + 1))
249 const phData = JSON.parse(cookieValue)
250
251 if (phData.distinct_id && typeof phData.distinct_id === 'string') {
252 return phData.distinct_id
253 }
254 }
255 } catch {
256 // No op, cookie may not exist (first visit) or be malformed
257 }
258
259 return undefined
260 }
261
262 /**
263 * Returns the current value of a person property as stored locally by posthog-js.
264 * Returns undefined if PostHog hasn't initialized or the property hasn't been set.
265 * Use this to gate behavior on whether a property has actually landed in the SDK
266 * (e.g., waiting for an identify to complete before evaluating flag-dependent UI).
267 *
268 * Person properties set via `identify(id, props)` are stored under the
269 * `$stored_person_properties` bucket in persistence — `get_property(key)`
270 * reads top-level super properties, not person properties, so we index in.
271 */
272 getPersonProperty(key: string): unknown {
273 if (!this.initialized) return undefined
274 try {
275 const stored = posthog.get_property('$stored_person_properties')
276 if (!stored || typeof stored !== 'object') return undefined
277 return (stored as Record<string, unknown>)[key]
278 } catch {
279 return undefined
280 }
281 }
282
283 /**
284 * Returns a PostHog feature flag value directly from the client-side SDK.
285 * Use this for www/docs pages where server-side evaluation lacks full person context.
286 * In local dev, DevToolbar overrides (x-ph-flag-overrides cookie) take priority.
287 */
288 getFeatureFlag(key: string): string | boolean | undefined {
289 if (typeof document === 'undefined') return undefined
290
291 if (process.env.NODE_ENV === 'development') {
292 try {
293 const cookieEntry = document.cookie
294 .split(';')
295 .map((c) => c.trim())
296 .find((c) => c.startsWith('x-ph-flag-overrides='))
297 if (cookieEntry) {
298 const overrides = JSON.parse(
299 decodeURIComponent(cookieEntry.substring('x-ph-flag-overrides='.length))
300 )
301 if (key in overrides) return overrides[key]
302 }
303 } catch {}
304 }
305
306 if (!this.initialized) return undefined
307
308 try {
309 return posthog.getFeatureFlag(key)
310 } catch {
311 return undefined
312 }
313 }
314
315 /**
316 * Subscribe to PostHog feature flag loads/reloads.
317 * Returns an unsubscribe function.
318 */
319 onFeatureFlags(callback: () => void): () => void {
320 if (!this.initStarted) {
321 // Queue until init() is called
322 this.pendingFeatureFlagCallbacks.add(callback)
323 return () => this.pendingFeatureFlagCallbacks.delete(callback)
324 }
325 if (typeof posthog.onFeatureFlags !== 'function') return () => {}
326 return posthog.onFeatureFlags(callback) ?? (() => {})
327 }
328
329 /**
330 * Returns PostHog's session_id for the current session.
331 * Returns undefined until PostHog's `loaded` callback fires.
332 */
333 getSessionId(): string | undefined {
334 if (!this.initialized) return undefined
335
336 try {
337 return posthog.get_session_id()
338 } catch (error) {
339 console.error('PostHog getSessionId failed:', error)
340 return undefined
341 }
342 }
343
344 /**
345 * Captures an experiment exposure event with session-based deduplication.
346 * Events are queued if PostHog is not yet initialized, then deduped on flush.
347 */
348 captureExperimentExposure(
349 experimentId: string,
350 properties: Record<string, any>,
351 hasConsent: boolean = true
352 ) {
353 if (!hasConsent) return
354
355 if (!this.initialized) {
356 // Only queue if not already queued for this experiment (first exposure wins)
357 if (!this.pendingExposures.some((e) => e.experimentId === experimentId)) {
358 if (this.pendingExposures.length >= this.maxPendingEvents) {
359 this.pendingExposures.shift()
360 }
361 this.pendingExposures.push({ experimentId, properties })
362 }
363 return
364 }
365
366 this.fireExposureIfNew(experimentId, properties)
367 }
368
369 private fireExposureIfNew(experimentId: string, properties: Record<string, any>) {
370 const sessionId = this.getSessionId()
371 if (!sessionId) return
372
373 const storageKey = `ph_exposed:${experimentId}`
374
375 try {
376 if (sessionStorage.getItem(storageKey) === sessionId) return
377
378 const eventName = `${experimentId}_experiment_exposed`
379 posthog.capture(eventName, { experiment_id: experimentId, ...properties })
380 sessionStorage.setItem(storageKey, sessionId)
381 } catch (error) {
382 console.error('PostHog experiment exposure capture failed:', error)
383 }
384 }
385
386 subscribeToEvents(listener: ClientTelemetryListener): () => void {
387 this.devListeners.add(listener)
388 return () => this.devListeners.delete(listener)
389 }
390
391 private emitToDevListeners(
392 eventType: ClientTelemetryEvent['eventType'],
393 eventName: string,
394 properties?: Record<string, unknown>
395 ) {
396 if (this.devListeners.size === 0) return
397
398 let distinctId: string | undefined
399 try {
400 const id = posthog.get_distinct_id?.()
401 if (id && id.length > 0) {
402 distinctId = id
403 }
404 } catch {}
405
406 const event: ClientTelemetryEvent = {
407 id: `client-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
408 timestamp: Date.now(),
409 eventType,
410 eventName,
411 distinctId,
412 properties,
413 }
414
415 this.devListeners.forEach((listener) => {
416 try {
417 listener(event)
418 } catch (e) {
419 console.error('Dev telemetry listener error:', e)
420 }
421 })
422 }
423}
424
425export const posthogClient = new PostHogClient()