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

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