From 11d99b675ceb55b57ece500c8735d342dc703f1c Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 24 Jul 2026 21:15:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20emit=20flag=20exposure=20?= =?UTF-8?q?=E2=80=94=20$feature=5Fflag=5Fcalled=20+=20$feature/=20props=20?= =?UTF-8?q?for=20wizard=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk flag fetch emits no per-flag events, so experiments on wizard flags never counted an exposure (387866 ran blind). After the snapshot loads, emit $feature_flag_called once per wizard-owned flag, and stamp $feature/ onto every subsequent capture so metrics filter by variant. Generated-By: PostHog Code Task-Id: 2d43c175-646e-4d73-a974-760280c8c95b --- src/utils/__tests__/analytics.test.ts | 54 +++++++++++++++++++++++++++ src/utils/analytics.ts | 21 +++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/utils/__tests__/analytics.test.ts b/src/utils/__tests__/analytics.test.ts index dee3eabab..9591ff630 100644 --- a/src/utils/__tests__/analytics.test.ts +++ b/src/utils/__tests__/analytics.test.ts @@ -209,6 +209,60 @@ describe('Analytics', () => { }); }); + describe('flag exposure emission', () => { + beforeEach(() => { + (mockPostHogInstance as any).getAllFlagsAndPayloads = vi + .fn() + .mockResolvedValue({ + featureFlags: { + 'wizard-orchestrator': true, + 'wizard-orchestrator-override': 'sol-review', + 'unrelated-flag': 'variant-x', + }, + featureFlagPayloads: {}, + }); + }); + + it('emits $feature_flag_called once per wizard-owned flag after the fetch', async () => { + await analytics.getAllFlagsForWizard(); + const exposureCalls = mockPostHogInstance.capture.mock.calls.filter( + ([arg]) => (arg as any).event === '$feature_flag_called', + ); + expect( + exposureCalls.map(([arg]) => [ + (arg as any).properties.$feature_flag, + (arg as any).properties.$feature_flag_response, + ]), + ).toEqual([ + ['wizard-orchestrator', 'true'], + ['wizard-orchestrator-override', 'sol-review'], + ]); + }); + + it('stamps $feature/ for wizard-owned flags on subsequent captures, never unrelated flags', async () => { + await analytics.getAllFlagsForWizard(); + analytics.wizardCapture('switchboard resolved', { program: 'x' }); + const call = mockPostHogInstance.capture.mock.calls.find( + ([arg]) => (arg as any).event === 'wizard: switchboard resolved', + ); + const props = (call![0] as any).properties; + expect(props['$feature/wizard-orchestrator']).toBe('true'); + expect(props['$feature/wizard-orchestrator-override']).toBe('sol-review'); + expect(props['$feature/unrelated-flag']).toBeUndefined(); + }); + + it('captures before the fetch carry no $feature props', () => { + analytics.wizardCapture('early event'); + const call = mockPostHogInstance.capture.mock.calls.find( + ([arg]) => (arg as any).event === 'wizard: early event', + ); + const keys = Object.keys((call![0] as any).properties).filter((k) => + k.startsWith('$feature/'), + ); + expect(keys).toEqual([]); + }); + }); + describe('build tag', () => { it("tags dev/test runs as 'dev'", () => { analytics.captureException(new Error('e')); diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 117f2baf1..1861d741c 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -227,11 +227,22 @@ export class Analytics { event: eventName, properties: { ...this.tags, + ...this.wizardFlagFeatureProperties(), ...properties, }, }); } + /** `$feature/` for every wizard-owned flag in the run snapshot, so experiments can filter any wizard event by variant. Empty until the flags are fetched. */ + private wizardFlagFeatureProperties(): Record { + if (this.activeFlags === null) return {}; + const props: Record = {}; + for (const [key, value] of Object.entries(this.activeFlags)) { + if (key.startsWith('wizard-')) props[`$feature/${key}`] = value; + } + return props; + } + /** * Capture a wizard-specific event. Automatically prepends "wizard: " to the event name. * All new wizard analytics should use this method instead of capture() directly. @@ -304,6 +315,16 @@ export class Analytics { this.activeFlags = merged.flags; this.activeFlagPayloads = merged.payloads; logToFile('[flags] evaluated', this.activeFlags); + // The bulk fetch emits no per-flag events, so experiments on wizard flags + // would never count an exposure; emit the default exposure event once per + // run for each wizard-owned flag. + for (const [key, value] of Object.entries(this.activeFlags)) { + if (!key.startsWith('wizard-')) continue; + this.capture('$feature_flag_called', { + $feature_flag: key, + $feature_flag_response: value, + }); + } return this.activeFlags; }