| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425 |
- 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<string, unknown>
- }
- 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<string, string> = {}
- private pendingIdentification: { userId: string; properties?: Record<string, any> } | null = null
- private pendingEvents: Array<{ event: string; properties: Record<string, any> }> = []
- private pendingExposures: Array<{ experimentId: string; properties: Record<string, any> }> = []
- private config: PostHogClientConfig
- private readonly maxPendingEvents = MAX_PENDING_EVENTS
- private devListeners: Set<ClientTelemetryListener> = 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<PostHogConfig> = {
- 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<string, any>, 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<string, any>, 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<string, any>, 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_<api_key>_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<string, unknown>)[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<string, any>,
- 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<string, any>) {
- 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<string, unknown>
- ) {
- 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()
|