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:
@@ -826,9 +826,13 @@ flowchart LR
|
||||
- **Canary**: `useUpdater` hook fetches `latest-canary.json` via HTTP, compares versions, and opens the GitHub release page for manual download
|
||||
- Canary versions use semver prerelease: `0.YYYYMMDD.N-canary`
|
||||
|
||||
### Feature Flags (Local V1)
|
||||
### Feature Flags (PostHog + Release Channels)
|
||||
|
||||
Feature flags use a local-only system with no external dependencies:
|
||||
Feature flags are backed by PostHog and evaluated per release channel:
|
||||
|
||||
- **Alpha**: all features always enabled (no PostHog lookup)
|
||||
- **Beta**: sees features where the PostHog flag targets `release_channel = beta`
|
||||
- **Stable** (default): sees features where the flag targets `release_channel = stable`
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlag } from './hooks/useFeatureFlag'
|
||||
@@ -838,18 +842,14 @@ const enabled = useFeatureFlag('example_flag') // boolean
|
||||
|
||||
**Resolution order:**
|
||||
1. `localStorage` override: key `ff_<name>` with value `"true"` or `"false"`
|
||||
2. Compile-time default in `FLAG_DEFAULTS` map
|
||||
2. `isFeatureEnabled(flag)` in `telemetry.ts` → checks release channel, then PostHog, then hardcoded defaults
|
||||
|
||||
**How to add a new flag:**
|
||||
1. Add the flag name to the `FeatureFlagName` union type in `src/hooks/useFeatureFlag.ts`
|
||||
2. Set its default in the `FLAG_DEFAULTS` record
|
||||
2. Create the flag on PostHog dashboard with rollout rules per channel
|
||||
3. Use `useFeatureFlag('your_flag')` in components
|
||||
|
||||
**Design decisions:**
|
||||
- No remote fetching, no PostHog dependency — zero privacy concerns
|
||||
- `localStorage` overrides allow dev/QA testing without rebuilding
|
||||
- Type-safe flag names via TypeScript union type
|
||||
- API surface is compatible with future migration to remote flags
|
||||
Release channel is selectable in Settings (alpha / beta / stable) and passed to PostHog as a person property via `identify()`. See ADR-0042.
|
||||
|
||||
## Platform Support — iOS / iPadOS (Prototype)
|
||||
|
||||
|
||||
37
docs/adr/0042-posthog-release-channels-feature-flags.md
Normal file
37
docs/adr/0042-posthog-release-channels-feature-flags.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0042"
|
||||
title: "PostHog-based release channels and feature flags"
|
||||
status: active
|
||||
date: 2026-04-03
|
||||
supersedes: "0017"
|
||||
---
|
||||
## Context
|
||||
|
||||
ADR-0017 introduced canary/stable update channels with localStorage-based feature flags. This worked for local development but lacked remote flag management — promoting a feature from beta to stable required a code change and rebuild.
|
||||
|
||||
## Decision
|
||||
|
||||
**Replace localStorage feature flags with PostHog-based feature flags, evaluated per release channel (alpha/beta/stable). The release channel is a user-selectable setting; PostHog flag rules determine which features are visible for each channel.**
|
||||
|
||||
- **Alpha**: all features always enabled (no PostHog lookup needed, works offline)
|
||||
- **Beta**: sees features where the PostHog flag targets `release_channel = beta`
|
||||
- **Stable** (default): sees features where the PostHog flag targets `release_channel = stable`
|
||||
- Promotion = flipping a PostHog flag on the dashboard. Zero code changes, zero rebuilds.
|
||||
- `isFeatureEnabled(flagKey)` in `telemetry.ts` is the single evaluation point.
|
||||
- localStorage overrides (`ff_<name>`) still work for dev/QA testing (checked first).
|
||||
- Offline: PostHog caches flags in localStorage; alpha always works; first-launch-no-network falls back to hardcoded defaults.
|
||||
|
||||
## Options considered
|
||||
|
||||
* **Option A**: Keep localStorage-only flags (ADR-0017) — no server dependency, but no remote management.
|
||||
* **Option B** (chosen): PostHog feature flags — we already use PostHog for analytics, so no new dependency. Remote flag management, per-channel targeting, gradual rollouts via PostHog dashboard.
|
||||
* **Option C**: Dedicated feature flag service (LaunchDarkly, Unleash) — more powerful but adds a new vendor dependency.
|
||||
|
||||
## Consequences
|
||||
|
||||
* `release_channel` added to Settings (persisted via Tauri backend, not vault).
|
||||
* `useTelemetry` passes `release_channel` as a PostHog person property on identify.
|
||||
* `isFeatureEnabled()` checks channel → PostHog → hardcoded defaults.
|
||||
* `useFeatureFlag` hook updated to delegate to `isFeatureEnabled` (after localStorage override check).
|
||||
* ADR-0017 is superseded — the canary update channel remains, but feature gating moves from localStorage to PostHog.
|
||||
@@ -14,6 +14,7 @@ pub struct Settings {
|
||||
pub analytics_enabled: Option<bool>,
|
||||
pub anonymous_id: Option<String>,
|
||||
pub update_channel: Option<String>,
|
||||
pub release_channel: Option<String>,
|
||||
}
|
||||
|
||||
fn settings_path() -> Result<PathBuf, String> {
|
||||
@@ -67,6 +68,10 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
||||
.update_channel
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
release_channel: settings
|
||||
.release_channel
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&cleaned)
|
||||
|
||||
@@ -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