Skip to content
Merged
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
12 changes: 6 additions & 6 deletions assets/js/welcome.js

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions src/bootstrap/variant-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ function recordCachedExposure(ph: typeof posthog): void {
}
}

/**
* Background kill-switch watch for cache-served renders. recordCachedExposure
* above only sees the kill-switch value persisted from the last load; if ops
* flips the switch ON afterwards, this picks up the FRESH value once PostHog
* reloads flags and clears the assignment cache so the kill takes effect on the
* next load (spec §3.1.4). Read with `send_event: false` so it never fires
* `$feature_flag_called` — the ops kill-switch is bucket-agnostic, so reading it
* after identify() is safe (unlike the experiment flag's exposure, which must
* stay pre-identify). Same synchronous-fire guard as resolveFlag; unsubscribes
* after the first reload.
*/
function watchKillSwitch(ph: typeof posthog): void {
let unsub: (() => void) | undefined;
let fired = false;
unsub = ph.onFeatureFlags(() => {
fired = true;
const killValue = ph.getFeatureFlag(KILL_SWITCH_KEY, { send_event: false });
if (killValue === true || killValue === 'on') clearCache();
unsub?.();
});
if (fired) unsub();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the kill-switch watch through cached callbacks

On cache-served repeat visits, persisted PostHog flags exist, and the nearby resolveFlag comment already notes that onFeatureFlags can fire inline in that state. This guard immediately unsubscribes the new watcher after that stale cached callback, so it observes only the same value recordCachedExposure already read and is gone before the post-identify flag reload that should contain a freshly flipped kill-switch. In that scenario, a kill-switch enabled after the prior visit will not clear wcpos_landing_assignment for the next load.

Useful? React with 👍 / 👎.

}

/** Waits for flags or times out. Exposure accounting: getFeatureFlag is always
* called once flags arrive — even after a timeout/cache render — so
* $feature_flag_called stays in lockstep with rendered events (spec §3.1.4).
Expand Down Expand Up @@ -144,6 +167,9 @@ export async function prepareVariant(ph: typeof posthog, anonId: string | undefi
// Synchronous, before this function returns → before the bootstrap's
// identify(): keeps $feature_flag_called in the anon_id bucket (spec §5.1).
recordCachedExposure(ph);
// Non-exposure background watch so a freshly-flipped kill-switch still clears
// the cache for next load (does not read the experiment flag → no exposure).
watchKillSwitch(ph);
return { variant: fast.variant, renderSource: 'cache', assets: resolveAssets(fast.variant, null, VARIANT_ASSETS, ASSET_VERSION) };
}

Expand Down
5 changes: 5 additions & 0 deletions src/shared/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ export function initAnalytics(): typeof posthog {
capture_heatmaps: false,
capture_dead_clicks: false,
opt_out_capturing_by_default: preview,
// Preview (GitHub Pages harness): never resolve production feature flags.
// The harness seeds its own assignment cache to render a chosen variant; a
// live `landing-variant` flag must not win over that, and preview iframe
// loads should not hit the production flag endpoint at all.
advanced_disable_flags: preview,
...(anonId && !hasPersistedIdentity()
? { bootstrap: { distinctID: anonId, isIdentifiedID: false } }
: {}),
Expand Down
18 changes: 18 additions & 0 deletions tests/exposure-ordering.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ test('kill-switch read does not pollute exposure and clears cache for next load'
assert.match(helper, /clearCache\(\)/, 'kill-switch on a cached render clears the cache (takes effect next load)');
});

test('fresh kill-switch is watched without re-reading the experiment flag (no post-identify exposure)', () => {
// The async kill-switch watch picks up a switch flipped after the last load,
// but must NOT read the experiment flag (FLAG_KEY) — that would record
// $feature_flag_called post-identify in the wrong bucket.
assert.match(loader, /function watchKillSwitch/);
const watch = slice(loader, 'function watchKillSwitch', '\n}');
assert.match(watch, /KILL_SWITCH_KEY,\s*\{\s*send_event:\s*false\s*\}/, 'kill-switch watch is non-exposure');
assert.doesNotMatch(watch, /getFeatureFlag\(FLAG_KEY\)/, 'kill-switch watch must not read the experiment flag');
assert.match(watch, /clearCache\(\)/);
const cacheBranch = slice(loader, "fast.renderSource === 'cache'", 'return {');
assert.match(cacheBranch, /watchKillSwitch\(ph\)/, 'cache path arms the fresh kill-switch watch');
});

test('preview disables production flag resolution', () => {
const analytics = readFileSync(new URL('../src/shared/analytics.ts', import.meta.url), 'utf8');
assert.match(analytics, /advanced_disable_flags:\s*preview/, 'preview must not resolve production feature flags');
});

test('bootstrap reads the flag (prepareVariant) before identifyConsented', () => {
// Ordering: prepareVariant (which does the synchronous exposure read on the
// cache path, and awaits resolveFlag on the cold path) resolves before identify.
Expand Down
Loading