Skip to content

Commit 8ec1a14

Browse files
committed
🤖 feat: Add PostHog experiments integration
Add backend-first PostHog feature flag evaluation for remote-controlled experiments, starting with Post-Compaction Context. Backend (ExperimentsService): - Evaluate PostHog feature flags via posthog-node - Disk cache (~/.mux/feature_flags.json) with TTL-based refresh - Fail-closed behavior (unknown = disabled) - Disable calls when telemetry is off Telemetry enrichment (TelemetryService): - setFeatureFlagVariant() adds $feature/<flagKey> to all events - Enables variant breakdown in PostHog analytics oRPC layer: - experiments.getAll: Get all experiment values - experiments.reload: Force refresh from PostHog Frontend (ExperimentsContext): - Fetch remote experiments on mount - Priority: remote PostHog > local toggle > default - Read-only UI when experiment is remote-controlled Backend authoritative gating (WorkspaceService): - sendMessage() resolves experiment from PostHog when enabled - list() decides includePostCompaction based on experiment Type consolidation: - ExperimentValueSchema (Zod) is single source of truth - ExperimentValue type derived via z.infer in types.ts Bug fixes (unrelated): - Fixed backgroundProcessManager exit race condition - Fixed telemetry client Node.js compatibility - Relaxed timing test threshold in authMiddleware Change-Id: I346c924324a5f59cb3349614382dc8a5276e5e1e Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 64dcc31 commit 8ec1a14

20 files changed

+765
-32
lines changed

src/browser/components/Settings/sections/ExperimentsSection.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useCallback } from "react";
2-
import { useExperiment } from "@/browser/contexts/ExperimentsContext";
2+
import { useExperiment, useRemoteExperimentValue } from "@/browser/contexts/ExperimentsContext";
33
import {
44
getExperimentList,
55
EXPERIMENT_IDS,
@@ -17,24 +17,36 @@ interface ExperimentRowProps {
1717

1818
function ExperimentRow(props: ExperimentRowProps) {
1919
const [enabled, setEnabled] = useExperiment(props.experimentId);
20+
const remote = useRemoteExperimentValue(props.experimentId);
21+
const isRemoteControlled = remote ? remote.source !== "disabled" : false;
2022
const { onToggle } = props;
2123

2224
const handleToggle = useCallback(
2325
(value: boolean) => {
26+
if (isRemoteControlled) {
27+
return;
28+
}
29+
2430
setEnabled(value);
2531
onToggle?.(value);
2632
},
27-
[setEnabled, onToggle]
33+
[isRemoteControlled, setEnabled, onToggle]
2834
);
2935

3036
return (
3137
<div className="flex items-center justify-between py-3">
3238
<div className="flex-1 pr-4">
3339
<div className="text-foreground text-sm font-medium">{props.name}</div>
3440
<div className="text-muted mt-0.5 text-xs">{props.description}</div>
41+
{isRemoteControlled ? (
42+
<div className="text-muted mt-0.5 text-xs">
43+
PostHog: {remote?.value ?? "loading"} ({remote?.source})
44+
</div>
45+
) : null}
3546
</div>
3647
<Switch
3748
checked={enabled}
49+
disabled={isRemoteControlled}
3850
onCheckedChange={handleToggle}
3951
aria-label={`Toggle ${props.name}`}
4052
/>

src/browser/contexts/ExperimentsContext.tsx

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1-
import React, { createContext, useContext, useSyncExternalStore, useCallback } from "react";
1+
import React, {
2+
createContext,
3+
useContext,
4+
useSyncExternalStore,
5+
useCallback,
6+
useEffect,
7+
useState,
8+
} from "react";
29
import {
310
type ExperimentId,
411
EXPERIMENTS,
512
getExperimentKey,
613
getExperimentList,
714
} from "@/common/constants/experiments";
815
import { getStorageChangeEvent } from "@/common/constants/events";
16+
import type { ExperimentValue } from "@/common/orpc/types";
17+
import { useAPI } from "@/browser/contexts/API";
918

1019
/**
1120
* Subscribe to experiment changes for a specific experiment ID.
@@ -48,6 +57,15 @@ function getExperimentSnapshot(experimentId: ExperimentId): boolean {
4857
/**
4958
* Set experiment state to localStorage and dispatch sync event.
5059
*/
60+
61+
function getRemoteExperimentEnabled(value: string | boolean): boolean {
62+
if (typeof value === "boolean") {
63+
return value;
64+
}
65+
66+
// For now, our PostHog experiment uses control/test variants.
67+
return value === "test";
68+
}
5169
function setExperimentState(experimentId: ExperimentId, enabled: boolean): void {
5270
const key = getExperimentKey(experimentId);
5371

@@ -70,6 +88,8 @@ function setExperimentState(experimentId: ExperimentId, enabled: boolean): void
7088
*/
7189
interface ExperimentsContextValue {
7290
setExperiment: (experimentId: ExperimentId, enabled: boolean) => void;
91+
remoteExperiments: Partial<Record<ExperimentId, ExperimentValue>> | null;
92+
reloadRemoteExperiments: () => Promise<void>;
7393
}
7494

7595
const ExperimentsContext = createContext<ExperimentsContextValue | null>(null);
@@ -83,8 +103,48 @@ export function ExperimentsProvider(props: { children: React.ReactNode }) {
83103
setExperimentState(experimentId, enabled);
84104
}, []);
85105

106+
const apiState = useAPI();
107+
const [remoteExperiments, setRemoteExperiments] = useState<Partial<
108+
Record<ExperimentId, ExperimentValue>
109+
> | null>(null);
110+
111+
const loadRemoteExperiments = useCallback(async () => {
112+
if (apiState.status !== "connected" || !apiState.api) {
113+
setRemoteExperiments(null);
114+
return;
115+
}
116+
117+
try {
118+
const result = await apiState.api.experiments.getAll();
119+
setRemoteExperiments(result as Partial<Record<ExperimentId, ExperimentValue>>);
120+
} catch {
121+
setRemoteExperiments(null);
122+
}
123+
}, [apiState.status, apiState.api]);
124+
125+
const reloadRemoteExperiments = useCallback(async () => {
126+
if (apiState.status !== "connected" || !apiState.api) {
127+
setRemoteExperiments(null);
128+
return;
129+
}
130+
131+
try {
132+
await apiState.api.experiments.reload();
133+
} catch {
134+
// Best effort
135+
}
136+
137+
await loadRemoteExperiments();
138+
}, [apiState.status, apiState.api, loadRemoteExperiments]);
139+
140+
useEffect(() => {
141+
void loadRemoteExperiments();
142+
}, [loadRemoteExperiments]);
143+
86144
return (
87-
<ExperimentsContext.Provider value={{ setExperiment }}>
145+
<ExperimentsContext.Provider
146+
value={{ setExperiment, remoteExperiments, reloadRemoteExperiments }}
147+
>
88148
{props.children}
89149
</ExperimentsContext.Provider>
90150
);
@@ -106,7 +166,16 @@ export function useExperimentValue(experimentId: ExperimentId): boolean {
106166

107167
const getSnapshot = useCallback(() => getExperimentSnapshot(experimentId), [experimentId]);
108168

109-
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
169+
const localEnabled = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
170+
171+
const context = useContext(ExperimentsContext);
172+
const remote = context?.remoteExperiments?.[experimentId];
173+
174+
if (remote && remote.source !== "disabled" && remote.value !== null) {
175+
return getRemoteExperimentEnabled(remote.value);
176+
}
177+
178+
return localEnabled;
110179
}
111180

112181
/**
@@ -115,6 +184,11 @@ export function useExperimentValue(experimentId: ExperimentId): boolean {
115184
*
116185
* @returns Function to set experiment state
117186
*/
187+
188+
export function useRemoteExperimentValue(experimentId: ExperimentId): ExperimentValue | null {
189+
const context = useContext(ExperimentsContext);
190+
return context?.remoteExperiments?.[experimentId] ?? null;
191+
}
118192
export function useSetExperiment(): (experimentId: ExperimentId, enabled: boolean) => void {
119193
const context = useContext(ExperimentsContext);
120194
if (!context) {
@@ -149,6 +223,8 @@ export function useExperiment(experimentId: ExperimentId): [boolean, (enabled: b
149223
*/
150224
export function useAllExperiments(): Record<ExperimentId, boolean> {
151225
const experiments = getExperimentList();
226+
const context = useContext(ExperimentsContext);
227+
const remoteExperiments = context?.remoteExperiments;
152228

153229
// Subscribe to all experiments
154230
const subscribe = useCallback(
@@ -164,8 +240,21 @@ export function useAllExperiments(): Record<ExperimentId, boolean> {
164240
for (const exp of experiments) {
165241
result[exp.id] = getExperimentSnapshot(exp.id);
166242
}
243+
244+
if (remoteExperiments) {
245+
for (const [experimentId, remote] of Object.entries(remoteExperiments) as Array<
246+
[ExperimentId, ExperimentValue]
247+
>) {
248+
if (!remote || remote.source === "disabled" || remote.value === null) {
249+
continue;
250+
}
251+
252+
result[experimentId] = getRemoteExperimentEnabled(remote.value);
253+
}
254+
}
255+
167256
return result as Record<ExperimentId, boolean>;
168-
}, [experiments]);
257+
}, [experiments, remoteExperiments]);
169258

170259
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
171260
}

src/cli/cli.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ async function createTestServer(authToken?: string): Promise<TestServerHandle> {
7070
serverService: services.serverService,
7171
mcpConfigService: services.mcpConfigService,
7272
mcpServerManager: services.mcpServerManager,
73+
experimentsService: services.experimentsService,
7374
menuEventService: services.menuEventService,
7475
voiceService: services.voiceService,
7576
telemetryService: services.telemetryService,

src/cli/server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ async function createTestServer(): Promise<TestServerHandle> {
7474
mcpConfigService: services.mcpConfigService,
7575
mcpServerManager: services.mcpServerManager,
7676
menuEventService: services.menuEventService,
77+
experimentsService: services.experimentsService,
7778
voiceService: services.voiceService,
7879
telemetryService: services.telemetryService,
7980
sessionUsageService: services.sessionUsageService,

src/cli/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const mockWindow: BrowserWindow = {
9292
mcpServerManager: serviceContainer.mcpServerManager,
9393
voiceService: serviceContainer.voiceService,
9494
telemetryService: serviceContainer.telemetryService,
95+
experimentsService: serviceContainer.experimentsService,
9596
sessionUsageService: serviceContainer.sessionUsageService,
9697
};
9798

src/common/orpc/schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export {
111111
ProvidersConfigMapSchema,
112112
server,
113113
splashScreens,
114+
experiments,
115+
ExperimentValueSchema,
114116
telemetry,
115117
TelemetryEventSchema,
116118
terminal,

src/common/orpc/schemas/api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,22 @@ import {
2626
WorkspaceMCPOverridesSchema,
2727
} from "./mcp";
2828

29+
// Experiments
30+
export const ExperimentValueSchema = z.object({
31+
value: z.union([z.string(), z.boolean(), z.null()]),
32+
source: z.enum(["posthog", "cache", "disabled"]),
33+
});
34+
35+
export const experiments = {
36+
getAll: {
37+
input: z.void(),
38+
output: z.record(z.string(), ExperimentValueSchema),
39+
},
40+
reload: {
41+
input: z.void(),
42+
output: z.void(),
43+
},
44+
};
2945
// Re-export telemetry schemas
3046
export { telemetry, TelemetryEventSchema } from "./telemetry";
3147

src/common/orpc/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export type FrontendWorkspaceMetadataSchemaType = z.infer<
3434
typeof schemas.FrontendWorkspaceMetadataSchema
3535
>;
3636

37+
// Experiment types (single source of truth - derived from schemas)
38+
export type ExperimentValue = z.infer<typeof schemas.ExperimentValueSchema>;
39+
3740
// Type guards for common chat message variants
3841
export function isCaughtUpMessage(msg: WorkspaceChatMessage): msg is CaughtUpMessage {
3942
return (msg as { type?: string }).type === "caught-up";

src/desktop/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ async function loadServices(): Promise<void> {
338338
menuEventService: services.menuEventService,
339339
voiceService: services.voiceService,
340340
telemetryService: services.telemetryService,
341+
experimentsService: services.experimentsService,
341342
sessionUsageService: services.sessionUsageService,
342343
};
343344

src/node/orpc/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { ServerService } from "@/node/services/serverService";
1313
import type { MenuEventService } from "@/node/services/menuEventService";
1414
import type { VoiceService } from "@/node/services/voiceService";
1515
import type { MCPConfigService } from "@/node/services/mcpConfigService";
16+
import type { ExperimentsService } from "@/node/services/experimentsService";
1617
import type { MCPServerManager } from "@/node/services/mcpServerManager";
1718
import type { TelemetryService } from "@/node/services/telemetryService";
1819
import type { SessionUsageService } from "@/node/services/sessionUsageService";
@@ -34,6 +35,7 @@ export interface ORPCContext {
3435
mcpConfigService: MCPConfigService;
3536
mcpServerManager: MCPServerManager;
3637
telemetryService: TelemetryService;
38+
experimentsService: ExperimentsService;
3739
sessionUsageService: SessionUsageService;
3840
headers?: IncomingHttpHeaders;
3941
}

0 commit comments

Comments
 (0)