mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## Problem We need a local-only UI to inspect client and server telemetry events and override flags during development without touching non-local env behavior. This work is intended to be shared across Studio, Docs, and WWW. ## Changes - Introduced a shared `dev-tools` package with the Dev Telemetry Toolbar UI, trigger, and provider. - Wired the toolbar into Studio, Docs, and WWW app shells (local-only gating). - Added a local-only `devTelemetry()` opt-in with storage gating and SSE subscription. - Wired client PostHog events into a local listener and re-exported types. - Added local flag override cookie support in the UI and CODEOWNERS for the new package. - Added unit tests covering local/non-local behavior and flag utilities. ## Testing Manual (local only): - Start each app locally: `pnpm dev:studio`, `pnpm dev:docs`, `pnpm dev:www` - Open the app, run `devTelemetry()` in the browser console - Click around and confirm both client and server events appear (client will be page views only) - Verify feature flag overrides (PostHog + ConfigCat) persist and restore correctly - Confirm dismissing the toolbar clears local storage and hides the trigger Unblocked by https://github.com/supabase/platform/pull/29172 Resolves GROWTH-591 Demo: [github.com/user-attachments/assets/60b376db-7440-4ada-82f5-d1bd4af4db3b](https://github.com/user-attachments/assets/60b376db-7440-4ada-82f5-d1bd4af4db3b) Screenshots: <img width="1368" height="972" alt="1" src="https://github.com/user-attachments/assets/d2f20a0c-191f-4118-bb5e-15b25f5a54a9" /> <img width="1423" height="790" alt="2" src="https://github.com/user-attachments/assets/115598e2-7287-49bf-9ed7-71ecc679dee3" /> <img width="1433" height="882" alt="3" src="https://github.com/user-attachments/assets/51f666f2-9efc-410f-baec-378bdee9dbfe" /> <img width="608" height="483" alt="4" src="https://github.com/user-attachments/assets/584d6cf5-1b2f-4cee-9e6a-d55ce2e3bae5" /> <img width="628" height="305" alt="5" src="https://github.com/user-attachments/assets/991a9b39-578a-4565-b110-537a02040a53" /> <img width="659" height="447" alt="6" src="https://github.com/user-attachments/assets/95ef405c-fffa-44af-bf6a-f974b780e3fc" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Developer Toolbar (local only): view client/server telemetry, inspect events, and manage/override feature flags with persistent overrides, filtering, and clear/reload. * Client-side telemetry hooks: surface structured events to dev tooling for realtime inspection. * **Bug Fixes** * Fixed end-of-file newline in shared code. * **Chores** * Added dev-tools package, integrated provider and trigger across Studio, Docs, and marketing sites, and added CODEOWNERS entry. * **Tests** * Added comprehensive tests and test setup for the DevToolbar. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useFeatureFlags } from 'common'
|
|
import { Activity, ChevronDown, ChevronUp, Flag } from 'lucide-react'
|
|
import {
|
|
type ChangeEvent,
|
|
type Dispatch,
|
|
type SetStateAction,
|
|
useCallback,
|
|
useEffect,
|
|
useState,
|
|
} from 'react'
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Input_Shadcn_ as Input,
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
Switch,
|
|
Tabs_Shadcn_ as Tabs,
|
|
TabsContent_Shadcn_ as TabsContent,
|
|
TabsList_Shadcn_ as TabsList,
|
|
TabsTrigger_Shadcn_ as TabsTrigger,
|
|
cn,
|
|
} from 'ui'
|
|
|
|
import { useDevToolbar } from './DevToolbarContext'
|
|
import type { DevTelemetryEvent } from './types'
|
|
import {
|
|
CC_ORIGINALS_KEY,
|
|
PH_ORIGINALS_KEY,
|
|
deleteCookie,
|
|
getCookie,
|
|
parseOverrideValue,
|
|
readOriginals,
|
|
safeJsonParse,
|
|
setCookie,
|
|
valuesAreEqual,
|
|
writeOriginals,
|
|
} from './utils'
|
|
|
|
const IS_LOCAL_DEV = process.env.NODE_ENV === 'development'
|
|
|
|
function EventCard({ event }: { event: DevTelemetryEvent }) {
|
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
|
|
const handleToggle = () => setIsExpanded((prev) => !prev)
|
|
|
|
return (
|
|
<div className="border rounded-md p-3 bg-surface-100">
|
|
<button
|
|
type="button"
|
|
aria-expanded={isExpanded}
|
|
aria-label={`${event.eventName} event details`}
|
|
className="flex items-start justify-between cursor-pointer gap-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm w-full text-left"
|
|
onClick={handleToggle}
|
|
>
|
|
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
|
<div className="flex items-start gap-2 flex-wrap">
|
|
<Badge variant={event.source === 'client' ? 'default' : 'success'} className="shrink-0">
|
|
{event.source}
|
|
</Badge>
|
|
<Badge variant="secondary" className="shrink-0">
|
|
{event.eventType}
|
|
</Badge>
|
|
<span className="font-mono text-sm break-all">{event.eventName}</span>
|
|
</div>
|
|
{event.distinctId && (
|
|
<div
|
|
className="text-xs text-foreground-muted font-mono truncate"
|
|
title={event.distinctId}
|
|
>
|
|
ID: {event.distinctId}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-foreground-muted shrink-0">
|
|
<span className="text-xs whitespace-nowrap">
|
|
{new Date(event.timestamp).toLocaleTimeString()}
|
|
</span>
|
|
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</div>
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<pre className="mt-3 p-2 bg-surface-200 rounded text-xs overflow-x-auto max-h-[300px] overflow-y-auto">
|
|
{JSON.stringify(event.properties, null, 2)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FlagCard({
|
|
flagName,
|
|
currentValue,
|
|
originalValue,
|
|
isOverridden,
|
|
onToggle,
|
|
}: {
|
|
flagName: string
|
|
currentValue: unknown
|
|
originalValue: unknown
|
|
isOverridden: boolean
|
|
onToggle: (value: unknown) => void
|
|
}) {
|
|
const valueType = typeof originalValue
|
|
const isNull = originalValue === null
|
|
const inputProps = {
|
|
value: String(currentValue),
|
|
onChange: (event: ChangeEvent<HTMLInputElement>) => onToggle(event.target.value),
|
|
className: 'w-32',
|
|
}
|
|
|
|
return (
|
|
<div className={cn('border rounded-md p-3', isOverridden && 'border-warning bg-warning/5')}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
<span className="font-mono text-sm truncate">{flagName}</span>
|
|
{isOverridden && (
|
|
<Badge variant="warning" className="shrink-0">
|
|
Overridden
|
|
</Badge>
|
|
)}
|
|
{isNull && (
|
|
<Badge variant="secondary" className="shrink-0">
|
|
null
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{isNull ? (
|
|
<Input value="null" disabled className="w-32 opacity-50" />
|
|
) : valueType === 'boolean' ? (
|
|
<Switch
|
|
checked={currentValue as boolean}
|
|
onCheckedChange={(checked) => onToggle(checked)}
|
|
/>
|
|
) : valueType === 'number' ? (
|
|
<Input type="number" {...inputProps} />
|
|
) : (
|
|
<Input {...inputProps} />
|
|
)}
|
|
</div>
|
|
|
|
{isOverridden && (
|
|
<div className="mt-2 text-xs text-foreground-muted">
|
|
Original:{' '}
|
|
<code className="bg-surface-200 px-1 rounded">{JSON.stringify(originalValue)}</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function DevToolbar() {
|
|
const { isEnabled, isOpen, setIsOpen, events, setEvents } = useDevToolbar()
|
|
const [activeTab, setActiveTab] = useState<string>('events')
|
|
const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog')
|
|
const [eventFilter, setEventFilter] = useState<string>('')
|
|
const { posthog: posthogFlags, configcat: configcatFlags } = useFeatureFlags()
|
|
const [phFlagOverrides, setPhFlagOverrides] = useState<Record<string, unknown>>({})
|
|
const [ccFlagOverrides, setCcFlagOverrides] = useState<Record<string, unknown>>({})
|
|
const [phFlagOriginals, setPhFlagOriginals] = useState<Record<string, unknown>>({})
|
|
const [ccFlagOriginals, setCcFlagOriginals] = useState<Record<string, unknown>>({})
|
|
|
|
const loadOverrides = useCallback(
|
|
(cookieName: string, label: string, setter: (value: Record<string, unknown>) => void) => {
|
|
const parsed = safeJsonParse<Record<string, unknown>>(getCookie(cookieName), {}, label)
|
|
if (Object.keys(parsed).length > 0) {
|
|
setter(parsed)
|
|
}
|
|
},
|
|
[]
|
|
)
|
|
|
|
const saveOverrides = useCallback(
|
|
(
|
|
cookieName: string,
|
|
overrides: Record<string, unknown>,
|
|
setter: (value: Record<string, unknown>) => void
|
|
) => {
|
|
setter(overrides)
|
|
if (Object.keys(overrides).length > 0) {
|
|
setCookie(cookieName, JSON.stringify(overrides), '/')
|
|
} else {
|
|
deleteCookie(cookieName)
|
|
}
|
|
},
|
|
[]
|
|
)
|
|
|
|
const updateOriginals = useCallback(
|
|
(
|
|
storageKey: typeof PH_ORIGINALS_KEY | typeof CC_ORIGINALS_KEY,
|
|
setter: Dispatch<SetStateAction<Record<string, unknown>>>
|
|
) =>
|
|
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
|
setter((prev) => {
|
|
const next = updater(prev)
|
|
writeOriginals(storageKey, next)
|
|
return next
|
|
})
|
|
},
|
|
[]
|
|
)
|
|
|
|
useEffect(() => {
|
|
loadOverrides('x-ph-flag-overrides', 'PostHog flag overrides', setPhFlagOverrides)
|
|
loadOverrides('x-cc-flag-overrides', 'ConfigCat flag overrides', setCcFlagOverrides)
|
|
}, [loadOverrides])
|
|
|
|
useEffect(() => {
|
|
setPhFlagOriginals(readOriginals(PH_ORIGINALS_KEY))
|
|
setCcFlagOriginals(readOriginals(CC_ORIGINALS_KEY))
|
|
}, [])
|
|
|
|
const updatePhOriginals = updateOriginals(PH_ORIGINALS_KEY, setPhFlagOriginals)
|
|
const updateCcOriginals = updateOriginals(CC_ORIGINALS_KEY, setCcFlagOriginals)
|
|
|
|
const togglePhFlagOverride = (flagName: string, value: unknown) => {
|
|
const originalValue = phFlagOriginals[flagName] ?? posthogFlags[flagName]
|
|
const parsedValue = parseOverrideValue(value, originalValue)
|
|
if (valuesAreEqual(parsedValue, originalValue)) {
|
|
const newOverrides = { ...phFlagOverrides }
|
|
delete newOverrides[flagName]
|
|
saveOverrides('x-ph-flag-overrides', newOverrides, setPhFlagOverrides)
|
|
return
|
|
}
|
|
|
|
updatePhOriginals((prev) =>
|
|
flagName in prev ? prev : { ...prev, [flagName]: posthogFlags[flagName] }
|
|
)
|
|
const newOverrides = { ...phFlagOverrides, [flagName]: parsedValue }
|
|
saveOverrides('x-ph-flag-overrides', newOverrides, setPhFlagOverrides)
|
|
}
|
|
|
|
const toggleCcFlagOverride = (flagName: string, value: unknown) => {
|
|
const originalValue = ccFlagOriginals[flagName] ?? configcatFlags[flagName]
|
|
const parsedValue = parseOverrideValue(value, originalValue)
|
|
if (valuesAreEqual(parsedValue, originalValue)) {
|
|
const newOverrides = { ...ccFlagOverrides }
|
|
delete newOverrides[flagName]
|
|
saveOverrides('x-cc-flag-overrides', newOverrides, setCcFlagOverrides)
|
|
return
|
|
}
|
|
|
|
updateCcOriginals((prev) =>
|
|
flagName in prev ? prev : { ...prev, [flagName]: configcatFlags[flagName] }
|
|
)
|
|
const newOverrides = { ...ccFlagOverrides, [flagName]: parsedValue }
|
|
saveOverrides('x-cc-flag-overrides', newOverrides, setCcFlagOverrides)
|
|
}
|
|
|
|
const clearAllOverrides = () => {
|
|
setPhFlagOverrides({})
|
|
setCcFlagOverrides({})
|
|
setPhFlagOriginals({})
|
|
setCcFlagOriginals({})
|
|
deleteCookie('x-ph-flag-overrides')
|
|
deleteCookie('x-cc-flag-overrides')
|
|
writeOriginals(PH_ORIGINALS_KEY, {})
|
|
writeOriginals(CC_ORIGINALS_KEY, {})
|
|
window.location.reload()
|
|
}
|
|
|
|
const normalizedFilter = eventFilter.trim().toLowerCase()
|
|
const filteredEvents = (
|
|
normalizedFilter
|
|
? events.filter(
|
|
(e) =>
|
|
e.eventName.toLowerCase().includes(normalizedFilter) ||
|
|
e.eventType.toLowerCase().includes(normalizedFilter)
|
|
)
|
|
: events
|
|
)
|
|
.slice()
|
|
.sort((a, b) => b.timestamp - a.timestamp)
|
|
|
|
const phOverrideCount = Object.keys(phFlagOverrides).length
|
|
const ccOverrideCount = Object.keys(ccFlagOverrides).length
|
|
const totalOverrideCount = phOverrideCount + ccOverrideCount
|
|
|
|
if (!IS_LOCAL_DEV || !isEnabled) return null
|
|
|
|
return (
|
|
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
|
<SheetContent
|
|
side="bottom"
|
|
size="lg"
|
|
className="flex flex-col p-0 gap-0 overflow-hidden"
|
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
>
|
|
<SheetHeader className="px-6 py-4 border-b shrink-0 space-y-0">
|
|
<div className="flex items-center gap-3">
|
|
<Activity className="w-5 h-5 text-brand-500" />
|
|
<SheetTitle className="text-lg font-semibold">Dev Telemetry</SheetTitle>
|
|
<Badge variant="secondary">Local Only</Badge>
|
|
</div>
|
|
<SheetDescription className="sr-only">
|
|
View telemetry events and feature flags for local development
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden px-6 pt-4">
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={setActiveTab}
|
|
className="flex-1 flex flex-col min-h-0 overflow-hidden"
|
|
>
|
|
<TabsList className="shrink-0 mb-4">
|
|
<TabsTrigger value="events" className="flex items-center gap-2 px-4">
|
|
<Activity className="w-4 h-4" />
|
|
Events ({filteredEvents.length})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="flags" className="flex items-center gap-2 px-4">
|
|
<Flag className="w-4 h-4" />
|
|
Flags {totalOverrideCount > 0 && `(${totalOverrideCount} overrides)`}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
{activeTab === 'events' && (
|
|
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
|
<div className="flex items-center gap-4 pb-4 shrink-0">
|
|
<Input
|
|
placeholder="Filter events..."
|
|
value={eventFilter}
|
|
onChange={(e) => setEventFilter(e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Button type="outline" onClick={() => setEvents([])}>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-2 pb-6">
|
|
{filteredEvents.length === 0 ? (
|
|
<div className="text-center text-foreground-muted py-8">
|
|
No events yet. Interact with the app to see telemetry events.
|
|
</div>
|
|
) : (
|
|
filteredEvents.map((event) => (
|
|
<EventCard key={`${event.source}-${event.id}`} event={event} />
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'flags' && (
|
|
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
|
{totalOverrideCount > 0 && (
|
|
<div className="flex items-center justify-between p-3 bg-warning/10 rounded-md mb-4 shrink-0">
|
|
<span className="text-sm text-warning">
|
|
{totalOverrideCount} flag(s) overridden
|
|
{phOverrideCount > 0 && ccOverrideCount > 0
|
|
? ` (${phOverrideCount} PostHog, ${ccOverrideCount} ConfigCat)`
|
|
: ''}
|
|
</span>
|
|
<Button type="outline" onClick={clearAllOverrides}>
|
|
Clear & Reload
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<Tabs
|
|
value={flagsSubTab}
|
|
onValueChange={(v) => setFlagsSubTab(v as 'posthog' | 'configcat')}
|
|
className="flex-1 flex flex-col min-h-0 overflow-hidden"
|
|
>
|
|
<TabsList className="shrink-0 mb-4">
|
|
<TabsTrigger value="posthog" className="px-4">
|
|
PostHog {phOverrideCount > 0 && `(${phOverrideCount})`}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="configcat" className="px-4">
|
|
ConfigCat {ccOverrideCount > 0 && `(${ccOverrideCount})`}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{flagsSubTab === 'posthog' && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto pb-6">
|
|
<div className="space-y-4">
|
|
{Object.keys(posthogFlags).length === 0 ? (
|
|
<div className="text-center text-foreground-muted py-8">
|
|
No PostHog feature flags loaded yet.
|
|
</div>
|
|
) : (
|
|
Object.entries(posthogFlags).map(([flagName, flagValue]) => (
|
|
<FlagCard
|
|
key={flagName}
|
|
flagName={flagName}
|
|
currentValue={
|
|
phFlagOverrides[flagName] ?? phFlagOriginals[flagName] ?? flagValue
|
|
}
|
|
originalValue={phFlagOriginals[flagName] ?? flagValue}
|
|
isOverridden={flagName in phFlagOverrides}
|
|
onToggle={(value) => togglePhFlagOverride(flagName, value)}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{flagsSubTab === 'configcat' && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto pb-6">
|
|
<div className="space-y-4">
|
|
{Object.keys(configcatFlags).length === 0 ? (
|
|
<div className="text-center text-foreground-muted py-8">
|
|
No ConfigCat feature flags loaded yet.
|
|
</div>
|
|
) : (
|
|
Object.entries(configcatFlags).map(([flagName, flagValue]) => (
|
|
<FlagCard
|
|
key={flagName}
|
|
flagName={flagName}
|
|
currentValue={
|
|
ccFlagOverrides[flagName] ?? ccFlagOriginals[flagName] ?? flagValue
|
|
}
|
|
originalValue={ccFlagOriginals[flagName] ?? flagValue}
|
|
isOverridden={flagName in ccFlagOverrides}
|
|
onToggle={(value) => toggleCcFlagOverride(flagName, value)}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Tabs>
|
|
</div>
|
|
)}
|
|
</Tabs>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|