Files
acbb821c5c feat: Only use remote config (#2895)
* feat: Make RemoteConfig the sole config loading mechanism

Remove the __preview_remote_config gate, the legacy flags() method, and
the _flagsCalled pattern. RemoteConfig is now always used to load SDK
config, with a 5-minute visibility-aware refresh interval for flags.

Key changes:
- remote-config.ts: Add refresh interval with visibility-aware polling,
  refresh() only reloads flags (not full pipeline) to avoid SiteApps
  duplicate listeners, add stop() for cleanup
- posthog-featureflags.ts: Remove flags() method and _flagsCalled,
  always use /flags/?v=2, always include timezone
- posthog-core.ts: Store RemoteConfigLoader instance, call stop() on
  reset(), fix process_person config alias preservation
- posthog-config.ts: Remove __preview_remote_config type

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Handle remote config failure gracefully and fix CI

When the remote config endpoint fails, pass an empty config to
_onRemoteConfig so extensions (autocapture, etc.) still initialize
with defaults. Also fix terser-mangled-names.json alphabetical
ordering, remove remaining config=true references from flags URLs,
and add config endpoint mock for Playwright tests.

* fix: Mock remote config endpoint in Playwright test infrastructure

Add config endpoint mock to both setup.ts and network.ts fixtures
so that RemoteConfig (now the sole config mechanism) loads properly
in Playwright mocked tests. Uses the same flagsResponse data to
ensure test-specific overrides (surveys, session recording, etc.)
are included in the config.

* fix: Update Playwright tests for RemoteConfig-first request flow

- flags.spec.ts: Add timezone to expected flags payload
- hydration-error.spec.ts: Add config endpoint mock so session
  recording loads properly
- network-recorder.spec.ts: Update expected capture order - config
  JSON fetch now precedes flags fetch

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Stabilize tests for RemoteConfig-first loading

- Replace jest.runAllTimers() with advanceTimersByTime(10) in
  $feature_flag_error tracking tests to avoid infinite loop from
  RemoteConfigLoader's 5-minute setInterval
- Use set-based assertions in network-recorder Playwright tests to
  handle non-deterministic request ordering and flaky config.js entries
- Add 200ms settling wait before mouse move tests to prevent premature
  buffer flushes from async flag loading

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Remove unreliable flags fetch assertion from network recorder test

The /flags/ fetch doesn't reliably appear in Performance Resource Timing
entries in CI chromium due to debounced timing. This assertion is not
essential - the test's purpose is verifying the network recorder captures
user-initiated fetch/xhr requests to example.com.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Prevent empty remote config from overwriting persisted feature settings

When remote config fetch fails and returns an empty object {}, several
consumers were blindly writing "disabled" defaults to persistence,
overwriting previously valid server config. This could degrade a
returning user's experience on the next page load.

Add guards to check for key existence before writing to persistence:
- heatmaps: check for 'heatmaps' key
- product tours: check for 'productTours' key
- dead clicks: check for 'captureDeadClicks' key
- web vitals: check for 'capturePerformance' key
- error tracking: check for 'errorTracking' key

This matches the pattern already used by session recording.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

* fix: Address PR review feedback for remote config handling

1. Add guards to exception-autocapture and autocapture onRemoteConfig methods
   to prevent overwriting persisted values when config fetch fails:
   - exception-autocapture: check for 'autocaptureExceptions' key
   - autocapture: check for 'autocapture_opt_out' key

2. Simplify visibility handling in RemoteConfigLoader:
   - Remove event listener and timestamp tracking complexity
   - Check visibility directly in refresh() method
   - Interval runs continuously, refresh() is a no-op when tab is hidden
   - Eliminates handler, event listener, and timestamp tracking

3. Document the 5-minute refresh interval choice:
   - Balances flag freshness with server load
   - Most sessions are shorter than 5 minutes anyway

4. Update test helper to provide minimal remote config with autocapture_opt_out
   so extensions initialize properly in tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

* docs: Add comment explaining autocapture's intentional opt-out model

Clarifies why autocapture doesn't have a guard against missing
autocapture_opt_out key, unlike other extensions. This prevents
future confusion during code reviews.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

* include changeset

---------

Co-authored-by: dylan <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-17 20:14:48 +00:00

166 lines
5.3 KiB
TypeScript

import { expect, Page, Response } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { Compression, FlagsResponse } from '@/types'
import { testPage } from './page'
// read directory ../../dist and get all files
const files = fs.readdirSync(path.join(__dirname, '../../dist'))
export const testNetwork = testPage.extend<{
network: NetworkPage
mockIngestion: boolean
flagsOverrides: Partial<FlagsResponse>
staticOverrides: Record<string, string>
}>({
staticOverrides: [{}, { option: true }],
flagsOverrides: [
{
sessionRecording: undefined,
isAuthenticated: false,
capturePerformance: true,
},
{ option: true },
],
mockIngestion: true,
network: [
async ({ page, flagsOverrides, mockIngestion, staticOverrides }, use) => {
const networkPage = new NetworkPage(page)
await networkPage.mockStatic(staticOverrides)
if (flagsOverrides) {
await networkPage.mockFlags(flagsOverrides)
}
if (mockIngestion) {
await networkPage.mockIngestion()
}
await use(networkPage)
networkPage.expectNoFailed()
},
{ auto: true },
],
})
export class NetworkPage {
responses: Response[] = []
constructor(private page: Page) {
page.on('response', (res: Response) => {
this.responses.push(res)
})
}
async mockFlags(flagsOverrides: Partial<FlagsResponse>) {
// Prepare the mocked Flags API response
const flagsResponse: FlagsResponse = {
editorParams: {},
flags: {
'session-recording-player': {
key: '7569-insight-cohorts',
enabled: true,
variant: undefined,
reason: {
code: 'condition_match',
condition_index: 0,
description: 'Matched condition set 1',
},
metadata: {
id: 1421,
version: 1,
description: undefined,
payload: undefined,
},
},
},
featureFlags: { 'session-recording-player': true },
featureFlagPayloads: {},
errorsWhileComputingFlags: false,
toolbarParams: {},
toolbarVersion: 'toolbar',
isAuthenticated: false,
siteApps: [],
supportedCompression: [Compression.GZipJS],
autocaptureExceptions: false,
...flagsOverrides,
}
await this.page.route('**/flags/*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(flagsResponse),
})
})
// Mock the remote config endpoint to return the same config data.
// RemoteConfig is now the sole config loading mechanism.
await this.page.route(/\/array\/[^/]+\/config(\?|$)/, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(flagsResponse),
})
})
}
async mockSurveys(surveysResponse: any[]) {
await this.page.route('**/surveys/**', async (route) => {
await route.fulfill({
json: {
surveys: surveysResponse,
},
})
})
}
async mockIngestion() {
await this.page.route('**/e/**', async (route) => {
await route.fulfill({
headers: { loaded: 'mock captured' },
json: {},
})
})
}
async waitForSurveys() {
await this.page.waitForResponse('**/surveys/**')
}
async mockStatic(staticOverrides: Record<string, string | undefined>) {
await Promise.all(
files.map((file) => {
return this.page.route(`**/static/${file}*`, async (route) => {
const source = staticOverrides[file] ?? file
await route.fulfill({
headers: { loaded: 'using relative path by playwright', source: source },
path: `./dist/${source}`,
})
})
})
)
}
expectNoFailed(): void {
expect(this.responses.filter((response) => !response.ok)).toHaveLength(0)
}
/**
* Runs the provided action, waiting for the network requests matching the provided url patterns to complete.
* Intended when running an action causes network requests that need to complete before we should continue.
*/
async waitingForNetworkCausedBy(options: {
urlPatternsToWaitFor: (string | RegExp)[]
action: () => Promise<void>
}): Promise<void> {
const responsePromises = options.urlPatternsToWaitFor.map((urlPattern) => {
return this.page.waitForResponse(urlPattern)
})
await options.action()
// eslint-disable-next-line compat/compat
await Promise.allSettled(responsePromises)
}
async waitForFlags() {
await this.page.waitForResponse(/flags/)
}
}