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:
Test
2026-04-03 21:22:28 +02:00
parent 3172425a16
commit a0fc97e5cf
13 changed files with 137 additions and 28 deletions

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -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 }

View File

@@ -18,6 +18,7 @@ const EMPTY_SETTINGS: Settings = {
analytics_enabled: null,
anonymous_id: null,
update_channel: null,
release_channel: null,
}
export function useSettings() {

View File

@@ -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', () => {

View File

@@ -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])
}

View File

@@ -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)
})
})

View File

@@ -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)
}

View File

@@ -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
},

View File

@@ -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 {