diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dd892db5..66284670 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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_` 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) diff --git a/docs/adr/0042-posthog-release-channels-feature-flags.md b/docs/adr/0042-posthog-release-channels-feature-flags.md new file mode 100644 index 00000000..98205b80 --- /dev/null +++ b/docs/adr/0042-posthog-release-channels-feature-flags.md @@ -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_`) 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. diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 55508bf4..2b9f1311 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -14,6 +14,7 @@ pub struct Settings { pub analytics_enabled: Option, pub anonymous_id: Option, pub update_channel: Option, + pub release_channel: Option, } fn settings_path() -> Result { @@ -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) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 5cdc7f1e..60ae7c3b 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -126,6 +126,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit(null) @@ -150,7 +151,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit { const prevAnalytics = settings.analytics_enabled ?? false @@ -205,6 +207,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit @@ -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) { +
+ + +
+ Alpha users see all features. Beta/Stable see features as they are promoted. +
+
+
diff --git a/src/hooks/useFeatureFlag.ts b/src/hooks/useFeatureFlag.ts index b1da55db..010cadb3 100644 --- a/src/hooks/useFeatureFlag.ts +++ b/src/hooks/useFeatureFlag.ts @@ -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_`) — 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 = { - 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) } diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index e721afdb..d02811cb 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -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 } diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index cdce3b2e..fca7c1e4 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -18,6 +18,7 @@ const EMPTY_SETTINGS: Settings = { analytics_enabled: null, anonymous_id: null, update_channel: null, + release_channel: null, } export function useSettings() { diff --git a/src/hooks/useTelemetry.test.ts b/src/hooks/useTelemetry.test.ts index 90171fa9..f02122d5 100644 --- a/src/hooks/useTelemetry.test.ts +++ b/src/hooks/useTelemetry.test.ts @@ -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', () => { diff --git a/src/hooks/useTelemetry.ts b/src/hooks/useTelemetry.ts index 460f2b0f..f407ffd5 100644 --- a/src/hooks/useTelemetry.ts +++ b/src/hooks/useTelemetry.ts @@ -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 { @@ -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]) } diff --git a/src/lib/telemetry.test.ts b/src/lib/telemetry.test.ts index 5c4c9b78..e3a0dccc 100644 --- a/src/lib/telemetry.test.ts +++ b/src/lib/telemetry.test.ts @@ -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) + }) +}) diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 7b90b08f..34f4b2cb 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -40,7 +40,7 @@ export function teardownSentry(): void { sentryInitialized = false } -export async function initPostHog(anonymousId: string): Promise { +export async function initPostHog(anonymousId: string, releaseChannel?: string): Promise { 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 { 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 = {} + +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): void { posthogInstance?.capture(name, properties) } diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index ddea56d5..c6c5a02c 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -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 any> = { analytics_enabled: s.analytics_enabled, anonymous_id: s.anonymous_id, update_channel: s.update_channel, + release_channel: s.release_channel, } return null }, diff --git a/src/types.ts b/src/types.ts index f9f37997..c0e38414 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 {