posthog-client.ts 14 KB

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