feat: release channels (alpha/beta/stable) via PostHog feature flags
- Add `release_channel` to Settings (Rust + TypeScript) - Add channel selector in Settings panel (alpha/beta/stable) - Pass `release_channel` as PostHog person property on identify - Add `isFeatureEnabled()` helper: alpha always true, beta/stable use PostHog flags with hardcoded fallback defaults - Update `useFeatureFlag` to delegate to PostHog-backed evaluation (localStorage overrides still work for dev/QA) - Create ADR-0042 (supersedes ADR-0017) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
const [githubUsername, setGithubUsername] = useState(settings.github_username)
|
||||
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
|
||||
const [updateChannel, setUpdateChannel] = useState(settings.update_channel ?? 'stable')
|
||||
const [releaseChannel, setReleaseChannel] = useState(settings.release_channel ?? 'stable')
|
||||
const [crashReporting, setCrashReporting] = useState(settings.crash_reporting_enabled ?? false)
|
||||
const [analytics, setAnalytics] = useState(settings.analytics_enabled ?? false)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
@@ -150,7 +151,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
analytics_enabled: analytics,
|
||||
anonymous_id: (crashReporting || analytics) ? (settings.anonymous_id ?? crypto.randomUUID()) : settings.anonymous_id,
|
||||
update_channel: updateChannel === 'stable' ? null : updateChannel,
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, updateChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
release_channel: releaseChannel === 'stable' ? null : releaseChannel,
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, updateChannel, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
|
||||
const handleSave = () => {
|
||||
const prevAnalytics = settings.analytics_enabled ?? false
|
||||
@@ -205,6 +207,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
|
||||
pullInterval={pullInterval} setPullInterval={setPullInterval}
|
||||
updateChannel={updateChannel} setUpdateChannel={setUpdateChannel}
|
||||
releaseChannel={releaseChannel} setReleaseChannel={setReleaseChannel}
|
||||
crashReporting={crashReporting} setCrashReporting={setCrashReporting}
|
||||
analytics={analytics} setAnalytics={setAnalytics}
|
||||
/>
|
||||
@@ -240,6 +243,7 @@ interface SettingsBodyProps {
|
||||
onGitHubDisconnect: () => void
|
||||
pullInterval: number; setPullInterval: (v: number) => void
|
||||
updateChannel: string; setUpdateChannel: (v: string) => void
|
||||
releaseChannel: string; setReleaseChannel: (v: string) => void
|
||||
crashReporting: boolean; setCrashReporting: (v: boolean) => void
|
||||
analytics: boolean; setAnalytics: (v: boolean) => void
|
||||
}
|
||||
@@ -323,6 +327,24 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Release channel</label>
|
||||
<select
|
||||
value={props.releaseChannel}
|
||||
onChange={(e) => props.setReleaseChannel(e.target.value)}
|
||||
className="border border-border bg-transparent text-foreground rounded"
|
||||
style={{ fontSize: 13, padding: '8px 10px', outline: 'none', fontFamily: 'inherit' }}
|
||||
data-testid="settings-release-channel"
|
||||
>
|
||||
<option value="stable">Stable</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="alpha">Alpha (bleeding edge)</option>
|
||||
</select>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.4 }}>
|
||||
Alpha users see all features. Beta/Stable see features as they are promoted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* Local feature flag hook (V1 — no remote fetching).
|
||||
* Feature flag hook backed by PostHog + release channels.
|
||||
*
|
||||
* Flags are resolved in order:
|
||||
* 1. localStorage override (`ff_<name>`) — for dev/QA testing
|
||||
* 2. Compile-time defaults in FLAG_DEFAULTS
|
||||
*
|
||||
* To add a new flag: add its name to the FeatureFlagName union and
|
||||
* set its default in FLAG_DEFAULTS.
|
||||
* 2. PostHog feature flags (evaluated by release channel)
|
||||
* 3. Alpha channel always returns true (sees all features)
|
||||
*/
|
||||
|
||||
export type FeatureFlagName = 'example_flag'
|
||||
import { isFeatureEnabled } from '../lib/telemetry'
|
||||
|
||||
const FLAG_DEFAULTS: Record<FeatureFlagName, boolean> = {
|
||||
example_flag: false,
|
||||
}
|
||||
export type FeatureFlagName = 'example_flag'
|
||||
|
||||
export function useFeatureFlag(flag: FeatureFlagName): boolean {
|
||||
try {
|
||||
@@ -22,5 +18,5 @@ export function useFeatureFlag(flag: FeatureFlagName): boolean {
|
||||
} catch {
|
||||
// localStorage may be unavailable in some contexts
|
||||
}
|
||||
return FLAG_DEFAULTS[flag] ?? false
|
||||
return isFeatureEnabled(flag)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const defaultSettings: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
const savedSettings: Settings = {
|
||||
@@ -28,6 +29,7 @@ const savedSettings: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
let mockSettingsStore: Settings = { ...defaultSettings }
|
||||
|
||||
@@ -18,6 +18,7 @@ const EMPTY_SETTINGS: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
|
||||
@@ -13,13 +13,15 @@ vi.mock('../lib/telemetry', () => ({
|
||||
teardownSentry: () => mockTeardownSentry(),
|
||||
initPostHog: (...args: unknown[]) => mockInitPostHog(...args),
|
||||
teardownPostHog: () => mockTeardownPostHog(),
|
||||
updatePostHogIdentify: vi.fn(),
|
||||
setReleaseChannel: vi.fn(),
|
||||
}))
|
||||
|
||||
const baseSettings: Settings = {
|
||||
openai_key: null, google_key: null,
|
||||
github_token: null, github_username: null, auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null, crash_reporting_enabled: null,
|
||||
analytics_enabled: null, anonymous_id: null, update_channel: null,
|
||||
analytics_enabled: null, anonymous_id: null, update_channel: null, release_channel: null,
|
||||
}
|
||||
|
||||
describe('useTelemetry', () => {
|
||||
@@ -50,7 +52,7 @@ describe('useTelemetry', () => {
|
||||
renderHook(() =>
|
||||
useTelemetry({ ...baseSettings, analytics_enabled: true, anonymous_id: 'test-uuid' }, true)
|
||||
)
|
||||
expect(mockInitPostHog).toHaveBeenCalledWith('test-uuid')
|
||||
expect(mockInitPostHog).toHaveBeenCalledWith('test-uuid', 'stable')
|
||||
})
|
||||
|
||||
it('tears down Sentry when crash reporting is disabled after being enabled', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { initSentry, teardownSentry, initPostHog, teardownPostHog } from '../lib/telemetry'
|
||||
import { initSentry, teardownSentry, initPostHog, teardownPostHog, updatePostHogIdentify, setReleaseChannel } from '../lib/telemetry'
|
||||
import type { Settings } from '../types'
|
||||
|
||||
function tauriCall(command: string): Promise<void> {
|
||||
@@ -29,13 +29,18 @@ export function useTelemetry(settings: Settings, loaded: boolean): void {
|
||||
tauriCall('reinit_telemetry').catch(() => {})
|
||||
}
|
||||
|
||||
const channel = settings.release_channel ?? 'stable'
|
||||
setReleaseChannel(channel)
|
||||
|
||||
if (analyticsEnabled && id && !prevAnalytics.current) {
|
||||
initPostHog(id)
|
||||
initPostHog(id, channel)
|
||||
} else if (!analyticsEnabled && prevAnalytics.current) {
|
||||
teardownPostHog()
|
||||
} else if (analyticsEnabled && id) {
|
||||
updatePostHogIdentify(channel)
|
||||
}
|
||||
|
||||
prevCrash.current = crashEnabled
|
||||
prevAnalytics.current = analyticsEnabled
|
||||
}, [loaded, settings.crash_reporting_enabled, settings.analytics_enabled, settings.anonymous_id])
|
||||
}, [loaded, settings.crash_reporting_enabled, settings.analytics_enabled, settings.anonymous_id, settings.release_channel])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { _scrubPathsForTest as scrubPaths, trackEvent } from './telemetry'
|
||||
import { _scrubPathsForTest as scrubPaths, trackEvent, isFeatureEnabled, setReleaseChannel } from './telemetry'
|
||||
|
||||
describe('telemetry scrubPaths', () => {
|
||||
it('redacts macOS absolute paths', () => {
|
||||
@@ -43,3 +43,21 @@ describe('trackEvent', () => {
|
||||
expect(() => trackEvent('note_created', { has_type: 1, creation_path: 'cmd_n' })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFeatureEnabled', () => {
|
||||
it('returns true for alpha channel regardless of flag state', () => {
|
||||
setReleaseChannel('alpha')
|
||||
expect(isFeatureEnabled('any_flag')).toBe(true)
|
||||
expect(isFeatureEnabled('nonexistent_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for stable channel when PostHog is not initialized', () => {
|
||||
setReleaseChannel('stable')
|
||||
expect(isFeatureEnabled('some_flag')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for beta channel when PostHog is not initialized', () => {
|
||||
setReleaseChannel('beta')
|
||||
expect(isFeatureEnabled('some_flag')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export function teardownSentry(): void {
|
||||
sentryInitialized = false
|
||||
}
|
||||
|
||||
export async function initPostHog(anonymousId: string): Promise<void> {
|
||||
export async function initPostHog(anonymousId: string, releaseChannel?: string): Promise<void> {
|
||||
if (posthogInstance || !POSTHOG_KEY) return
|
||||
const posthog = (await import('posthog-js')).default
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
@@ -50,7 +50,7 @@ export async function initPostHog(anonymousId: string): Promise<void> {
|
||||
persistence: 'memory',
|
||||
disable_session_recording: true,
|
||||
})
|
||||
posthog.identify(anonymousId)
|
||||
posthog.identify(anonymousId, releaseChannel ? { release_channel: releaseChannel } : undefined)
|
||||
posthogInstance = posthog
|
||||
}
|
||||
|
||||
@@ -61,6 +61,24 @@ export function teardownPostHog(): void {
|
||||
posthogInstance = null
|
||||
}
|
||||
|
||||
export function updatePostHogIdentify(releaseChannel: string): void {
|
||||
posthogInstance?.identify(undefined, { release_channel: releaseChannel })
|
||||
}
|
||||
|
||||
/** Hardcoded defaults for first launch with no network (PostHog cache empty). */
|
||||
const FEATURE_DEFAULTS: Record<string, boolean> = {}
|
||||
|
||||
let currentReleaseChannel: string = 'stable'
|
||||
|
||||
export function setReleaseChannel(channel: string): void {
|
||||
currentReleaseChannel = channel
|
||||
}
|
||||
|
||||
export function isFeatureEnabled(flagKey: string): boolean {
|
||||
if (currentReleaseChannel === 'alpha') return true
|
||||
return posthogInstance?.isFeatureEnabled(flagKey) ?? FEATURE_DEFAULTS[flagKey] ?? false
|
||||
}
|
||||
|
||||
export function trackEvent(name: string, properties?: Record<string, string | number>): void {
|
||||
posthogInstance?.capture(name, properties)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ let mockSettings: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
let mockLastVaultPath: string | null = null
|
||||
@@ -210,6 +211,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
analytics_enabled: s.analytics_enabled,
|
||||
anonymous_id: s.anonymous_id,
|
||||
update_channel: s.update_channel,
|
||||
release_channel: s.release_channel,
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface Settings {
|
||||
analytics_enabled: boolean | null
|
||||
anonymous_id: string | null
|
||||
update_channel: string | null
|
||||
release_channel: string | null
|
||||
}
|
||||
|
||||
export interface GitPullResult {
|
||||
|
||||
Reference in New Issue
Block a user