DevToolbarContext.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. 'use client'
  2. import { ensurePlatformSuffix, posthogClient, type ClientTelemetryEvent } from 'common'
  3. import {
  4. createContext,
  5. useCallback,
  6. useContext,
  7. useEffect,
  8. useRef,
  9. useState,
  10. type ReactNode,
  11. } from 'react'
  12. import type {
  13. DevTelemetryEvent,
  14. DevTelemetryToolbarContextType,
  15. ServerTelemetryEvent,
  16. } from './types'
  17. import { getCookie } from './utils'
  18. // Duplicated for tree-shaking — bundler must see literal process.env reference.
  19. // Keep in sync: index.ts, DevToolbar.tsx, DevToolbarTrigger.tsx, feature-flags.tsx
  20. const env = process.env.NEXT_PUBLIC_ENVIRONMENT
  21. const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
  22. const IS_LOCAL_DEV = env === 'local'
  23. const MAX_EVENTS = 200
  24. const STORAGE_KEY = 'dev-telemetry-toolbar-enabled'
  25. const SSE_INITIAL_RETRY_MS = 1000
  26. const SSE_MAX_RETRY_MS = 30000
  27. const SSE_BACKOFF_MULTIPLIER = 2
  28. declare global {
  29. interface Window {
  30. devTelemetry?: () => void
  31. }
  32. }
  33. const DevToolbarContext = createContext<DevTelemetryToolbarContextType | null>(null)
  34. interface DevToolbarProviderProps {
  35. children: ReactNode
  36. apiUrl: string
  37. }
  38. export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps) {
  39. const [isEnabled, setIsEnabled] = useState(false)
  40. const [isOpen, setIsOpen] = useState(false)
  41. const [events, setEvents] = useState<DevTelemetryEvent[]>([])
  42. const sseRetryDelayRef = useRef(SSE_INITIAL_RETRY_MS)
  43. const sseRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null)
  44. const dismissToolbar = useCallback(() => {
  45. try {
  46. localStorage.removeItem(STORAGE_KEY)
  47. } catch {}
  48. setIsEnabled(false)
  49. setIsOpen(false)
  50. }, [])
  51. useEffect(() => {
  52. if (!IS_TOOLBAR_ENABLED) return
  53. let stored: string | null = null
  54. try {
  55. stored = localStorage.getItem(STORAGE_KEY)
  56. } catch {}
  57. if (stored === 'true') {
  58. setIsEnabled(true)
  59. }
  60. window.devTelemetry = () => {
  61. try {
  62. localStorage.setItem(STORAGE_KEY, 'true')
  63. } catch {}
  64. setIsEnabled(true)
  65. }
  66. return () => {
  67. delete window.devTelemetry
  68. }
  69. }, [])
  70. const appendEvent = useCallback((event: DevTelemetryEvent) => {
  71. setEvents((prev) => {
  72. const key = `${event.source}-${event.id}`
  73. if (prev.some((e) => `${e.source}-${e.id}` === key)) return prev
  74. return [...prev.slice(-(MAX_EVENTS - 1)), event]
  75. })
  76. }, [])
  77. useEffect(() => {
  78. if (!isEnabled) return
  79. const unsubscribe = posthogClient.subscribeToEvents((clientEvent: ClientTelemetryEvent) => {
  80. appendEvent({
  81. id: clientEvent.id,
  82. timestamp: clientEvent.timestamp,
  83. source: 'client',
  84. eventType: clientEvent.eventType,
  85. eventName: clientEvent.eventName,
  86. distinctId: clientEvent.distinctId,
  87. properties: clientEvent.properties,
  88. })
  89. })
  90. return unsubscribe
  91. }, [appendEvent, isEnabled])
  92. useEffect(() => {
  93. if (!IS_LOCAL_DEV || !isEnabled || typeof EventSource === 'undefined') return
  94. let eventSource: EventSource | null = null
  95. let isMounted = true
  96. const connect = () => {
  97. if (!isMounted) return
  98. const sessionId = getCookie('session_id')
  99. const streamUrl = `${ensurePlatformSuffix(apiUrl)}/telemetry/stream${
  100. sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : ''
  101. }`
  102. eventSource = new EventSource(streamUrl, { withCredentials: true })
  103. eventSource.onopen = () => {
  104. sseRetryDelayRef.current = SSE_INITIAL_RETRY_MS
  105. }
  106. eventSource.onmessage = (event) => {
  107. try {
  108. const data = JSON.parse(event.data) as ServerTelemetryEvent
  109. appendEvent({
  110. id: data.id,
  111. timestamp: data.timestamp,
  112. source: 'server',
  113. eventType: data.eventType,
  114. eventName: data.eventName,
  115. distinctId: data.distinctId,
  116. properties: data.properties,
  117. })
  118. } catch (e) {
  119. console.error('[DevToolbar] Failed to parse SSE event:', e)
  120. }
  121. }
  122. eventSource.onerror = () => {
  123. if (!isMounted) return
  124. eventSource?.close()
  125. eventSource = null
  126. const delay = sseRetryDelayRef.current
  127. console.warn(`[DevToolbar] SSE connection error, reconnecting in ${delay}ms...`)
  128. if (sseRetryTimeoutRef.current) {
  129. clearTimeout(sseRetryTimeoutRef.current)
  130. sseRetryTimeoutRef.current = null
  131. }
  132. sseRetryTimeoutRef.current = setTimeout(() => {
  133. if (isMounted) {
  134. connect()
  135. }
  136. }, delay)
  137. sseRetryDelayRef.current = Math.min(delay * SSE_BACKOFF_MULTIPLIER, SSE_MAX_RETRY_MS)
  138. }
  139. }
  140. connect()
  141. return () => {
  142. isMounted = false
  143. eventSource?.close()
  144. if (sseRetryTimeoutRef.current) {
  145. clearTimeout(sseRetryTimeoutRef.current)
  146. sseRetryTimeoutRef.current = null
  147. }
  148. }
  149. }, [apiUrl, appendEvent, isEnabled])
  150. if (!IS_TOOLBAR_ENABLED) {
  151. return <>{children}</>
  152. }
  153. return (
  154. <DevToolbarContext.Provider
  155. value={{
  156. isEnabled,
  157. isOpen,
  158. setIsOpen,
  159. events,
  160. setEvents,
  161. dismissToolbar,
  162. }}
  163. >
  164. {children}
  165. </DevToolbarContext.Provider>
  166. )
  167. }
  168. export function useDevToolbar() {
  169. const context = useContext(DevToolbarContext)
  170. if (!context) {
  171. return {
  172. isEnabled: false,
  173. isOpen: false,
  174. setIsOpen: () => {},
  175. events: [],
  176. setEvents: () => {},
  177. dismissToolbar: () => {},
  178. }
  179. }
  180. return context
  181. }