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