import posthog, { PostHogConfig } from 'posthog-js' // Limit the max number of queued events // (e.g. if a user navigates around a lot before accepting consent) const MAX_PENDING_EVENTS = 20 export interface ClientTelemetryEvent { id: string timestamp: number eventType: 'capture' | 'identify' | 'pageview' | 'pageleave' eventName: string distinctId?: string properties?: Record } type ClientTelemetryListener = (event: ClientTelemetryEvent) => void interface PostHogClientConfig { apiKey?: string apiHost?: string uiHost?: string } class PostHogClient { /** True after posthog.init() is called (prevents double-init) */ private initStarted = false /** True after the `loaded` callback fires, meaning PostHog has fully bootstrapped */ private initialized = false private pendingGroups: Record = {} private pendingIdentification: { userId: string; properties?: Record } | null = null private pendingEvents: Array<{ event: string; properties: Record }> = [] private pendingExposures: Array<{ experimentId: string; properties: Record }> = [] private config: PostHogClientConfig private readonly maxPendingEvents = MAX_PENDING_EVENTS private devListeners: Set = new Set() private pendingFeatureFlagCallbacks: Set<() => void> = new Set() constructor(config: PostHogClientConfig = {}) { const apiHost = config.apiHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://ph.briven.green' const uiHost = config.uiHost || process.env.NEXT_PUBLIC_POSTHOG_UI_HOST || 'https://eu.posthog.com' this.config = { apiKey: config.apiKey || process.env.NEXT_PUBLIC_POSTHOG_KEY, apiHost, uiHost, } } init(hasConsent: boolean = true) { if (this.initStarted || typeof window === 'undefined' || !hasConsent) return if (!this.config.apiKey) { console.warn('PostHog API key not found. Skipping initialization.') return } const config: Partial = { api_host: this.config.apiHost, ui_host: this.config.uiHost, autocapture: false, // We'll manually track events capture_pageview: false, // We'll manually track pageviews capture_pageleave: false, // We'll manually track page leaves loaded: (posthog) => { // Apply pending properties that were set before PostHog // initialized due to poor connection or user not accepting // consent right away // Apply any pending groups Object.entries(this.pendingGroups).forEach(([type, id]) => { posthog.group(type, id) }) this.pendingGroups = {} // Apply any pending identification if (this.pendingIdentification) { try { posthog.identify( this.pendingIdentification.userId, this.pendingIdentification.properties ) } catch (error) { console.error('PostHog identify failed:', error) } this.pendingIdentification = null } // Flush any pending events this.pendingEvents.forEach(({ event, properties }) => { try { posthog.capture(event, properties, { transport: 'sendBeacon' }) } catch (error) { console.error('PostHog capture failed:', error) } }) this.pendingEvents = [] this.initialized = true // Flush any pending experiment exposures (with deduplication) this.pendingExposures.forEach(({ experimentId, properties }) => { this.fireExposureIfNew(experimentId, properties) }) this.pendingExposures = [] }, } this.initStarted = true posthog.init(this.config.apiKey, config) // Register any feature flag callbacks that were queued before init this.pendingFeatureFlagCallbacks.forEach((cb) => posthog.onFeatureFlags(cb)) this.pendingFeatureFlagCallbacks.clear() } capturePageView(properties: Record, hasConsent: boolean = true) { if (!hasConsent) return if (!this.initialized) { // Queue the event for when PostHog initializes (up to cap) // (e.g. poor connection or user not accepting consent right away) if (this.pendingEvents.length >= this.maxPendingEvents) { this.pendingEvents.shift() // Remove oldest event } this.pendingEvents.push({ event: '$pageview', properties }) return } try { // Store groups from properties if present (for later group() calls) if (properties.$groups) { Object.entries(properties.$groups).forEach(([type, id]) => { if (id) posthog.group(type, id as string) }) } posthog.capture('$pageview', properties, { transport: 'sendBeacon' }) this.emitToDevListeners('pageview', '$pageview', properties) } catch (error) { console.error('PostHog pageview capture failed:', error) } } capturePageLeave(properties: Record, hasConsent: boolean = true) { if (!hasConsent) return if (!this.initialized) { // Queue the event for when PostHog initializes (up to cap) // (e.g. poor connection or user not accepting consent right away) if (this.pendingEvents.length >= this.maxPendingEvents) { this.pendingEvents.shift() // Remove oldest event } this.pendingEvents.push({ event: '$pageleave', properties }) return } try { // Use sendBeacon for page leave to survive tab close posthog.capture('$pageleave', properties, { transport: 'sendBeacon' }) this.emitToDevListeners('pageleave', '$pageleave', properties) } catch (error) { console.error('PostHog pageleave capture failed:', error) } } identify(userId: string, properties?: Record, hasConsent: boolean = true) { if (!hasConsent) return if (!this.initialized) { // Queue the identification for when PostHog initializes. Merge properties // across pre-init calls for the same user so callers don't clobber each // other (e.g. useTelemetryIdentify sets gotrue_id, then a separate effect // sets org_count — both should land when the SDK flushes). const pending = this.pendingIdentification this.pendingIdentification = pending && pending.userId === userId ? { userId, properties: { ...pending.properties, ...properties } } : { userId, properties } return } try { posthog.identify(userId, properties) this.emitToDevListeners('identify', '$identify', { userId, ...properties }) } catch (error) { console.error('PostHog identify failed:', error) } } reset() { this.pendingIdentification = null this.pendingGroups = {} this.pendingEvents = [] this.pendingExposures = [] if (!this.initStarted) return try { posthog.reset() } catch (error) { console.error('PostHog reset failed:', error) } } /** * Returns PostHog's distinct_id, which holds first-touch attribution data. * Falls back to reading from PostHog cookie if SDK isn't initialized yet * (e.g., immediately after OAuth redirect before PostHog loads). */ getDistinctId(): string | undefined { if (this.initialized) { try { return posthog.get_distinct_id() } catch (error) { console.error('PostHog getDistinctId failed:', error) } } // Fallback: parse distinct_id from PostHog cookie return this.getDistinctIdFromCookie() } /** * Parse distinct_id from PostHog cookie. * PostHog stores data in a cookie named `ph__posthog` with format: * { distinct_id: "...", ... } */ private getDistinctIdFromCookie(): string | undefined { if (typeof document === 'undefined') return undefined try { const cookieName = `ph_${this.config.apiKey}_posthog` const cookies = document.cookie.split(';') for (const cookie of cookies) { const trimmed = cookie.trim() const eqIndex = trimmed.indexOf('=') if (eqIndex === -1) continue const name = trimmed.substring(0, eqIndex) if (name !== cookieName) continue // Use substring instead of split to handle '=' chars in the value const cookieValue = decodeURIComponent(trimmed.substring(eqIndex + 1)) const phData = JSON.parse(cookieValue) if (phData.distinct_id && typeof phData.distinct_id === 'string') { return phData.distinct_id } } } catch { // No op, cookie may not exist (first visit) or be malformed } return undefined } /** * Returns the current value of a person property as stored locally by posthog-js. * Returns undefined if PostHog hasn't initialized or the property hasn't been set. * Use this to gate behavior on whether a property has actually landed in the SDK * (e.g., waiting for an identify to complete before evaluating flag-dependent UI). * * Person properties set via `identify(id, props)` are stored under the * `$stored_person_properties` bucket in persistence — `get_property(key)` * reads top-level super properties, not person properties, so we index in. */ getPersonProperty(key: string): unknown { if (!this.initialized) return undefined try { const stored = posthog.get_property('$stored_person_properties') if (!stored || typeof stored !== 'object') return undefined return (stored as Record)[key] } catch { return undefined } } /** * Returns a PostHog feature flag value directly from the client-side SDK. * Use this for www/docs pages where server-side evaluation lacks full person context. * In local dev, DevToolbar overrides (x-ph-flag-overrides cookie) take priority. */ getFeatureFlag(key: string): string | boolean | undefined { if (typeof document === 'undefined') return undefined if (process.env.NODE_ENV === 'development') { try { const cookieEntry = document.cookie .split(';') .map((c) => c.trim()) .find((c) => c.startsWith('x-ph-flag-overrides=')) if (cookieEntry) { const overrides = JSON.parse( decodeURIComponent(cookieEntry.substring('x-ph-flag-overrides='.length)) ) if (key in overrides) return overrides[key] } } catch {} } if (!this.initialized) return undefined try { return posthog.getFeatureFlag(key) } catch { return undefined } } /** * Subscribe to PostHog feature flag loads/reloads. * Returns an unsubscribe function. */ onFeatureFlags(callback: () => void): () => void { if (!this.initStarted) { // Queue until init() is called this.pendingFeatureFlagCallbacks.add(callback) return () => this.pendingFeatureFlagCallbacks.delete(callback) } if (typeof posthog.onFeatureFlags !== 'function') return () => {} return posthog.onFeatureFlags(callback) ?? (() => {}) } /** * Returns PostHog's session_id for the current session. * Returns undefined until PostHog's `loaded` callback fires. */ getSessionId(): string | undefined { if (!this.initialized) return undefined try { return posthog.get_session_id() } catch (error) { console.error('PostHog getSessionId failed:', error) return undefined } } /** * Captures an experiment exposure event with session-based deduplication. * Events are queued if PostHog is not yet initialized, then deduped on flush. */ captureExperimentExposure( experimentId: string, properties: Record, hasConsent: boolean = true ) { if (!hasConsent) return if (!this.initialized) { // Only queue if not already queued for this experiment (first exposure wins) if (!this.pendingExposures.some((e) => e.experimentId === experimentId)) { if (this.pendingExposures.length >= this.maxPendingEvents) { this.pendingExposures.shift() } this.pendingExposures.push({ experimentId, properties }) } return } this.fireExposureIfNew(experimentId, properties) } private fireExposureIfNew(experimentId: string, properties: Record) { const sessionId = this.getSessionId() if (!sessionId) return const storageKey = `ph_exposed:${experimentId}` try { if (sessionStorage.getItem(storageKey) === sessionId) return const eventName = `${experimentId}_experiment_exposed` posthog.capture(eventName, { experiment_id: experimentId, ...properties }) sessionStorage.setItem(storageKey, sessionId) } catch (error) { console.error('PostHog experiment exposure capture failed:', error) } } subscribeToEvents(listener: ClientTelemetryListener): () => void { this.devListeners.add(listener) return () => this.devListeners.delete(listener) } private emitToDevListeners( eventType: ClientTelemetryEvent['eventType'], eventName: string, properties?: Record ) { if (this.devListeners.size === 0) return let distinctId: string | undefined try { const id = posthog.get_distinct_id?.() if (id && id.length > 0) { distinctId = id } } catch {} const event: ClientTelemetryEvent = { id: `client-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, timestamp: Date.now(), eventType, eventName, distinctId, properties, } this.devListeners.forEach((listener) => { try { listener(event) } catch (e) { console.error('Dev telemetry listener error:', e) } }) } } export const posthogClient = new PostHogClient()