Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/utils/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<key> 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'));
Expand Down
21 changes: 21 additions & 0 deletions src/utils/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,22 @@ export class Analytics {
event: eventName,
properties: {
...this.tags,
...this.wizardFlagFeatureProperties(),
...properties,
},
});
}

/** `$feature/<key>` 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<string, string> {
if (this.activeFlags === null) return {};
const props: Record<string, string> = {};
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.
Expand Down Expand Up @@ -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;
}

Expand Down
Loading