diff --git a/cypress/e2e/capture.cy.ts b/cypress/e2e/capture.cy.ts index bc11604d1..c3cd27214 100644 --- a/cypress/e2e/capture.cy.ts +++ b/cypress/e2e/capture.cy.ts @@ -382,6 +382,7 @@ describe('Event capture', () => { token: 'test_token', distinct_id: 'new-id', person_properties: {}, + $anon_distinct_id: payload.$anon_distinct_id, groups: { company: 'id:5', playlist: 'id:77', diff --git a/functional_tests/feature-flags.test.ts b/functional_tests/feature-flags.test.ts index d2fd05fbf..6e4fe65f5 100644 --- a/functional_tests/feature-flags.test.ts +++ b/functional_tests/feature-flags.test.ts @@ -178,7 +178,7 @@ describe('FunctionalTests / Feature Flags', () => { expect(getRequests(token)['/decide/']).toEqual([ // This is the initial call to the decide endpoint on PostHog init, with all info added from `loaded`. { - // $anon_distinct_id: 'anon-id', // no anonymous ID is sent because this was overridden on load + $anon_distinct_id: 'anon-id', distinct_id: 'test-id', groups: { playlist: 'id:77' }, person_properties: { diff --git a/playground/nextjs/pages/_app.tsx b/playground/nextjs/pages/_app.tsx index 3d74c2fe6..55caa3b40 100644 --- a/playground/nextjs/pages/_app.tsx +++ b/playground/nextjs/pages/_app.tsx @@ -33,7 +33,9 @@ export default function App({ Component, pageProps }: AppProps) { } }, []) - const localhostDomain = process.env.NEXT_PUBLIC_CROSSDOMAIN ? 'https://localhost:8000' : 'http://localhost:8000' + const localhostDomain = process.env.NEXT_PUBLIC_CROSSDOMAIN + ? 'https://localhost:8000' + : process.env.NEXT_PUBLIC_POSTHOG_HOST return ( diff --git a/src/__tests__/decide.ts b/src/__tests__/decide.ts deleted file mode 100644 index a7c8ec266..000000000 --- a/src/__tests__/decide.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { Decide } from '../decide' -import { PostHogPersistence } from '../posthog-persistence' -import { RequestRouter } from '../utils/request-router' -import { PostHog } from '../posthog-core' -import { DecideResponse, PostHogConfig, Properties, RemoteConfig } from '../types' -import '../entrypoints/external-scripts-loader' -import { assignableWindow } from '../utils/globals' - -const expectDecodedSendRequest = ( - send_request: PostHog['_send_request'], - data: Record, - noCompression: boolean, - posthog: PostHog -) => { - const lastCall = jest.mocked(send_request).mock.calls[jest.mocked(send_request).mock.calls.length - 1] - - const decoded = lastCall[0].data - // Helper to give us more accurate error messages - expect(decoded).toEqual(data) - - expect(posthog._send_request).toHaveBeenCalledWith({ - url: 'https://test.com/decide/?v=3', - data, - method: 'POST', - callback: expect.any(Function), - compression: noCompression ? undefined : 'base64', - timeout: undefined, - }) -} - -describe('Decide', () => { - let posthog: PostHog - - const decide = () => new Decide(posthog) - - const defaultConfig: Partial = { - token: 'testtoken', - api_host: 'https://test.com', - persistence: 'memory', - } - - beforeEach(() => { - // clean the JSDOM to prevent interdependencies between tests - document.body.innerHTML = '' - document.head.innerHTML = '' - jest.spyOn(window.console, 'error').mockImplementation() - - posthog = { - config: defaultConfig, - persistence: new PostHogPersistence(defaultConfig as PostHogConfig), - register: (props: Properties) => posthog.persistence!.register(props), - unregister: (key: string) => posthog.persistence!.unregister(key), - get_property: (key: string) => posthog.persistence!.props[key], - capture: jest.fn(), - _addCaptureHook: jest.fn(), - _onRemoteConfig: jest.fn(), - get_distinct_id: jest.fn().mockImplementation(() => 'distinctid'), - _send_request: jest.fn().mockImplementation(({ callback }) => callback?.({ config: {} })), - featureFlags: { - resetRequestQueue: jest.fn(), - reloadFeatureFlags: jest.fn(), - receivedFeatureFlags: jest.fn(), - setReloadingPaused: jest.fn(), - _startReloadTimer: jest.fn(), - }, - requestRouter: new RequestRouter({ config: defaultConfig } as unknown as PostHog), - _hasBootstrappedFeatureFlags: jest.fn(), - getGroups: () => ({ organization: '5' }), - } as unknown as PostHog - }) - - describe('constructor', () => { - it('should call instance._send_request on constructor', () => { - decide().call() - - expectDecodedSendRequest( - posthog._send_request, - { - token: 'testtoken', - distinct_id: 'distinctid', - groups: { organization: '5' }, - }, - false, - posthog - ) - }) - - it('should send all stored properties with decide request', () => { - posthog.register({ - $stored_person_properties: { key: 'value' }, - $stored_group_properties: { organization: { orgName: 'orgValue' } }, - }) - - decide().call() - - expectDecodedSendRequest( - posthog._send_request, - { - token: 'testtoken', - distinct_id: 'distinctid', - groups: { organization: '5' }, - person_properties: { key: 'value' }, - group_properties: { organization: { orgName: 'orgValue' } }, - }, - false, - posthog - ) - }) - - it('should send disable flags with decide request when config is set', () => { - posthog.config = { - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - advanced_disable_feature_flags: true, - } as PostHogConfig - - posthog.register({ - $stored_person_properties: { key: 'value' }, - $stored_group_properties: { organization: { orgName: 'orgValue' } }, - }) - decide().call() - - expectDecodedSendRequest( - posthog._send_request, - { - token: 'testtoken', - distinct_id: 'distinctid', - groups: { organization: '5' }, - person_properties: { key: 'value' }, - group_properties: { organization: { orgName: 'orgValue' } }, - disable_flags: true, - }, - false, - posthog - ) - }) - - it('should disable compression when config is set', () => { - posthog.config = { - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - disable_compression: true, - } as PostHogConfig - - posthog.register({ - $stored_person_properties: {}, - $stored_group_properties: {}, - }) - decide().call() - - // noCompression is true - expectDecodedSendRequest( - posthog._send_request, - { - token: 'testtoken', - distinct_id: 'distinctid', - groups: { organization: '5' }, - person_properties: {}, - group_properties: {}, - }, - true, - posthog - ) - }) - - it('should send disable flags with decide request when config for advanced_disable_feature_flags_on_first_load is set', () => { - posthog.config = { - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - advanced_disable_feature_flags_on_first_load: true, - } as PostHogConfig - - posthog.register({ - $stored_person_properties: { key: 'value' }, - $stored_group_properties: { organization: { orgName: 'orgValue' } }, - }) - - decide().call() - - expectDecodedSendRequest( - posthog._send_request, - { - token: 'testtoken', - distinct_id: 'distinctid', - groups: { organization: '5' }, - person_properties: { key: 'value' }, - group_properties: { organization: { orgName: 'orgValue' } }, - disable_flags: true, - }, - false, - posthog - ) - }) - }) - - describe('parseDecideResponse', () => { - const subject = (decideResponse: DecideResponse) => decide().parseDecideResponse(decideResponse) - - it('properly parses decide response', () => { - subject({} as DecideResponse) - - expect(posthog.featureFlags.receivedFeatureFlags).toHaveBeenCalledWith({}, false) - expect(posthog._onRemoteConfig).toHaveBeenCalledWith({}) - }) - - it('Make sure receivedFeatureFlags is called with errors if the decide response fails', () => { - ;(window as any).POSTHOG_DEBUG = true - - subject(undefined as unknown as DecideResponse) - - expect(posthog.featureFlags.receivedFeatureFlags).toHaveBeenCalledWith({}, true) - expect(console.error).toHaveBeenCalledWith( - '[PostHog.js] [Decide]', - 'Failed to fetch feature flags from PostHog.' - ) - }) - - it('Make sure receivedFeatureFlags is not called if advanced_disable_feature_flags_on_first_load is set', () => { - posthog.config = { - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - advanced_disable_feature_flags_on_first_load: true, - } as PostHogConfig - - const decideResponse = { - featureFlags: { 'test-flag': true }, - } as unknown as DecideResponse - subject(decideResponse) - - expect(posthog._onRemoteConfig).toHaveBeenCalledWith(decideResponse) - expect(posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled() - }) - - it('Make sure receivedFeatureFlags is not called if advanced_disable_feature_flags is set', () => { - posthog.config = { - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - advanced_disable_feature_flags: true, - } as PostHogConfig - - const decideResponse = { - featureFlags: { 'test-flag': true }, - } as unknown as DecideResponse - subject(decideResponse) - - expect(posthog._onRemoteConfig).toHaveBeenCalledWith(decideResponse) - expect(posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled() - }) - }) - - describe('remote config', () => { - const config = { surveys: true } as RemoteConfig - - beforeEach(() => { - posthog.config.__preview_remote_config = true - assignableWindow._POSTHOG_CONFIG = undefined - assignableWindow.POSTHOG_DEBUG = true - - assignableWindow.__PosthogExtensions__.loadExternalDependency = jest.fn( - (_ph: PostHog, _name: string, cb: (err?: any) => void) => { - assignableWindow._POSTHOG_CONFIG = config as RemoteConfig - cb() - } - ) - - posthog._send_request = jest.fn().mockImplementation(({ callback }) => callback?.({ json: config })) - }) - - it('properly pulls from the window and uses it if set', () => { - assignableWindow._POSTHOG_CONFIG = config as RemoteConfig - decide().call() - - expect(assignableWindow.__PosthogExtensions__.loadExternalDependency).not.toHaveBeenCalled() - expect(posthog._send_request).not.toHaveBeenCalled() - - expect(posthog._onRemoteConfig).toHaveBeenCalledWith(config) - }) - - it('loads the script if window config not set', () => { - decide().call() - - expect(assignableWindow.__PosthogExtensions__.loadExternalDependency).toHaveBeenCalledWith( - posthog, - 'remote-config', - expect.any(Function) - ) - expect(posthog._send_request).not.toHaveBeenCalled() - expect(posthog._onRemoteConfig).toHaveBeenCalledWith(config) - }) - - it('loads the json if window config not set and js failed', () => { - assignableWindow.__PosthogExtensions__.loadExternalDependency = jest.fn( - (_ph: PostHog, _name: string, cb: (err?: any) => void) => { - cb() - } - ) - - decide().call() - - expect(assignableWindow.__PosthogExtensions__.loadExternalDependency).toHaveBeenCalled() - expect(posthog._send_request).toHaveBeenCalledWith({ - method: 'GET', - url: 'https://test.com/array/testtoken/config', - callback: expect.any(Function), - }) - expect(posthog._onRemoteConfig).toHaveBeenCalledWith(config) - }) - - it.each([ - [true, true], - [false, false], - [undefined, true], - ])('conditionally reloads feature flags - hasFlags: %s, shouldReload: %s', (hasFeatureFlags, shouldReload) => { - assignableWindow._POSTHOG_CONFIG = { hasFeatureFlags } as RemoteConfig - decide().call() - - if (shouldReload) { - expect(posthog.featureFlags.reloadFeatureFlags).toHaveBeenCalled() - } else { - expect(posthog.featureFlags.reloadFeatureFlags).not.toHaveBeenCalled() - } - }) - }) -}) diff --git a/src/__tests__/featureflags.ts b/src/__tests__/featureflags.test.ts similarity index 92% rename from src/__tests__/featureflags.ts rename to src/__tests__/featureflags.test.ts index fd2b5792c..a54ada8fd 100644 --- a/src/__tests__/featureflags.ts +++ b/src/__tests__/featureflags.test.ts @@ -22,7 +22,7 @@ describe('featureflags', () => { beforeEach(() => { instance = { - config, + config: { ...config }, get_distinct_id: () => 'blah id', getGroups: () => {}, persistence: new PostHogPersistence(config), @@ -32,13 +32,13 @@ describe('featureflags', () => { get_property: (key) => instance.persistence.props[key], capture: () => {}, decideEndpointWasHit: false, - receivedFlagValues: false, _send_request: jest.fn().mockImplementation(({ callback }) => callback({ statusCode: 200, json: {}, }) ), + _onRemoteConfig: jest.fn(), reloadFeatureFlags: () => featureFlags.reloadFeatureFlags(), } @@ -68,7 +68,7 @@ describe('featureflags', () => { }) it('should return flags from persistence even if decide endpoint was not hit', () => { - featureFlags.instance.receivedFlagValues = false + featureFlags._hasLoadedFlags = false expect(featureFlags.getFlags()).toEqual([ 'beta-feature', @@ -81,7 +81,7 @@ describe('featureflags', () => { it('should warn if decide endpoint was not hit and no flags exist', () => { ;(window as any).POSTHOG_DEBUG = true - featureFlags.instance.receivedFlagValues = false + featureFlags._hasLoadedFlags = false instance.persistence.unregister('$enabled_feature_flags') instance.persistence.unregister('$active_feature_flags') @@ -102,7 +102,7 @@ describe('featureflags', () => { }) it('should return the right feature flag and call capture', () => { - featureFlags.instance.receivedFlagValues = false + featureFlags._hasLoadedFlags = false expect(featureFlags.getFlags()).toEqual([ 'beta-feature', @@ -133,7 +133,7 @@ describe('featureflags', () => { }) it('should call capture for every different flag response', () => { - featureFlags.instance.receivedFlagValues = true + featureFlags._hasLoadedFlags = true instance.persistence.register({ $enabled_feature_flags: { @@ -157,13 +157,13 @@ describe('featureflags', () => { instance.persistence.register({ $enabled_feature_flags: {}, }) - featureFlags.instance.receivedFlagValues = false + featureFlags._hasLoadedFlags = false expect(featureFlags.getFlagVariants()).toEqual({}) expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(undefined) // no extra capture call because flags haven't loaded yet. expect(instance.capture).toHaveBeenCalledTimes(1) - featureFlags.instance.receivedFlagValues = true + featureFlags._hasLoadedFlags = true instance.persistence.register({ $enabled_feature_flags: { x: 'y' }, }) @@ -186,7 +186,7 @@ describe('featureflags', () => { }) it('should return the right feature flag and not call capture', () => { - featureFlags.instance.receivedFlagValues = true + featureFlags._hasLoadedFlags = true expect(featureFlags.isFeatureEnabled('beta-feature', { send_event: false })).toEqual(true) expect(instance.capture).not.toHaveBeenCalled() @@ -272,6 +272,73 @@ describe('featureflags', () => { }) }) + describe('decide()', () => { + it('should not call decide if advanced_disable_decide is true', () => { + instance.config.advanced_disable_decide = true + featureFlags.decide() + + expect(instance._send_request).toHaveBeenCalledTimes(0) + }) + + it('should call decide', () => { + featureFlags.decide() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data.disable_flags).toBe(undefined) + + jest.runOnlyPendingTimers() + expect(instance._send_request).toHaveBeenCalledTimes(1) + }) + + it('should call decide with flags disabled if set', () => { + instance.config.advanced_disable_feature_flags_on_first_load = true + featureFlags.decide() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data.disable_flags).toBe(true) + }) + + it('should call decide with flags disabled if set generally', () => { + instance.config.advanced_disable_feature_flags = true + featureFlags.decide() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data.disable_flags).toBe(true) + }) + + it('should call decide once even if reload called before', () => { + featureFlags.reloadFeatureFlags() + featureFlags.decide() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data.disable_flags).toBe(undefined) + + jest.runOnlyPendingTimers() + expect(instance._send_request).toHaveBeenCalledTimes(1) + }) + + it('should not disable flags if reload was called on decide', () => { + instance.config.advanced_disable_feature_flags_on_first_load = true + featureFlags.reloadFeatureFlags() + featureFlags.decide() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data.disable_flags).toBe(undefined) + + jest.runOnlyPendingTimers() + expect(instance._send_request).toHaveBeenCalledTimes(1) + }) + + it('should always disable flags if set', () => { + instance.config.advanced_disable_feature_flags = true + featureFlags.reloadFeatureFlags() + featureFlags.decide() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data.disable_flags).toBe(true) + }) + }) + describe('onFeatureFlags', () => { beforeEach(() => { instance._send_request = jest.fn().mockImplementation(({ callback }) => @@ -316,7 +383,7 @@ describe('featureflags', () => { }) it('onFeatureFlags callback should be called immediately if feature flags were loaded', () => { - featureFlags.instance.receivedFlagValues = true + featureFlags._hasLoadedFlags = true let called = false featureFlags.onFeatureFlags(() => (called = true)) expect(called).toEqual(true) @@ -325,7 +392,7 @@ describe('featureflags', () => { }) it('onFeatureFlags should not return flags that are off', () => { - featureFlags.instance.receivedFlagValues = true + featureFlags._hasLoadedFlags = true let _flags = [] let _variants = {} featureFlags.onFeatureFlags((flags, variants) => { diff --git a/src/__tests__/helpers/posthog-instance.ts b/src/__tests__/helpers/posthog-instance.ts index 5f74a7c59..7c119f5db 100644 --- a/src/__tests__/helpers/posthog-instance.ts +++ b/src/__tests__/helpers/posthog-instance.ts @@ -13,6 +13,7 @@ export const createPosthogInstance = async ( // written, we first create an instance, then call init on it which then // creates another instance. const posthog = new PostHog() + // eslint-disable-next-line compat/compat return await new Promise((resolve) => posthog.init( diff --git a/src/__tests__/personProcessing.test.ts b/src/__tests__/personProcessing.test.ts index 058dedb42..a384029b9 100644 --- a/src/__tests__/personProcessing.test.ts +++ b/src/__tests__/personProcessing.test.ts @@ -44,6 +44,7 @@ jest.mock('../utils/globals', () => { document: { ...orig.document, createElement: (...args: any[]) => orig.document.createElement(...args), + body: {}, get referrer() { return mockReferrerGetter() }, diff --git a/src/__tests__/posthog-core.identify.test.ts b/src/__tests__/posthog-core.identify.test.ts index 96beace88..885ec25f9 100644 --- a/src/__tests__/posthog-core.identify.test.ts +++ b/src/__tests__/posthog-core.identify.test.ts @@ -3,8 +3,6 @@ import { uuidv7 } from '../uuidv7' import { createPosthogInstance, defaultPostHog } from './helpers/posthog-instance' import { PostHog } from '../posthog-core' -jest.mock('../decide') - describe('identify()', () => { let instance: PostHog let beforeSendMock: jest.Mock @@ -19,6 +17,7 @@ describe('identify()', () => { }, uuidv7() ) + instance = Object.assign(posthog, { register: jest.fn(), featureFlags: { diff --git a/src/__tests__/posthog-core.loaded.ts b/src/__tests__/posthog-core.loaded.ts index 925087ce2..3ef6c6805 100644 --- a/src/__tests__/posthog-core.loaded.ts +++ b/src/__tests__/posthog-core.loaded.ts @@ -1,42 +1,58 @@ import { createPosthogInstance } from './helpers/posthog-instance' import { uuidv7 } from '../uuidv7' import { PostHog } from '../posthog-core' +import { PostHogConfig } from '../types' jest.useFakeTimers() describe('loaded() with flags', () => { let instance: PostHog - const config = { loaded: jest.fn(), api_host: 'https://app.posthog.com' } - const overrides = { - capture: jest.fn(), - _send_request: jest.fn(({ callback }) => callback?.({ status: 200, json: {} })), - _start_queue_if_opted_in: jest.fn(), - } + const createPosthog = async (config?: Partial) => { + const posthog = await createPosthogInstance(uuidv7(), { + api_host: 'https://app.posthog.com', + disable_compression: true, + ...config, + loaded: (ph) => { + ph.capture = jest.fn() + ph._send_request = jest.fn(({ callback }) => callback?.({ status: 200, json: {} })) + ph._start_queue_if_opted_in = jest.fn() - beforeAll(() => { - jest.unmock('../decide') - }) + jest.spyOn(ph.featureFlags, 'setGroupPropertiesForFlags') + jest.spyOn(ph.featureFlags, 'setReloadingPaused') + jest.spyOn(ph.featureFlags, 'reloadFeatureFlags') + jest.spyOn(ph.featureFlags, '_callDecideEndpoint') - beforeEach(async () => { - const posthog = await createPosthogInstance(uuidv7(), config) - instance = Object.assign(posthog, { - ...overrides, - featureFlags: { - setReloadingPaused: jest.fn(), - resetRequestQueue: jest.fn(), - _startReloadTimer: jest.fn(), - receivedFeatureFlags: jest.fn(), - onFeatureFlags: jest.fn(), + config?.loaded?.(ph) }, - _send_request: jest.fn(({ callback }) => callback?.({ status: 200, json: {} })), }) - }) - describe('toggling flag reloading', () => { - beforeEach(async () => { - const posthog = await createPosthogInstance(uuidv7(), { - ...config, + return posthog + } + + describe('flag reloading', () => { + it('only calls decide once whilst loading', async () => { + instance = await createPosthog({ + loaded: (ph) => { + ph.group('org', 'bazinga', { name: 'Shelly' }) + }, + }) + + expect(instance._send_request).toHaveBeenCalledTimes(1) + + expect(instance._send_request.mock.calls[0][0]).toMatchObject({ + url: 'https://us.i.posthog.com/decide/?v=3', + data: { + groups: { org: 'bazinga' }, + }, + }) + jest.runOnlyPendingTimers() // Once for callback + jest.runOnlyPendingTimers() // Once for potential debounce + expect(instance._send_request).toHaveBeenCalledTimes(1) + }) + + it('does add follow up call due to group change', async () => { + instance = await createPosthog({ loaded: (ph) => { ph.group('org', 'bazinga', { name: 'Shelly' }) setTimeout(() => { @@ -44,43 +60,50 @@ describe('loaded() with flags', () => { }, 100) }, }) - instance = Object.assign(posthog, overrides) + expect(instance.featureFlags._callDecideEndpoint).toHaveBeenCalledTimes(1) + expect(instance._send_request).toHaveBeenCalledTimes(1) + + expect(instance._send_request.mock.calls[0][0]).toMatchObject({ + url: 'https://us.i.posthog.com/decide/?v=3', + data: { + groups: { org: 'bazinga' }, + }, + }) - jest.spyOn(instance.featureFlags, 'setGroupPropertiesForFlags') - jest.spyOn(instance.featureFlags, 'setReloadingPaused') - jest.spyOn(instance.featureFlags, '_startReloadTimer') - jest.spyOn(instance.featureFlags, 'resetRequestQueue') - jest.spyOn(instance.featureFlags, '_reloadFeatureFlagsRequest') - }) + jest.runOnlyPendingTimers() // Once for callback + jest.runOnlyPendingTimers() // Once for potential debounce - it('doesnt call flags while initial load is happening', () => { - instance._loaded() + expect(instance.featureFlags._callDecideEndpoint).toHaveBeenCalledTimes(2) + expect(instance._send_request).toHaveBeenCalledTimes(2) + + expect(instance._send_request.mock.calls[1][0]).toMatchObject({ + url: 'https://us.i.posthog.com/decide/?v=3', + data: { + groups: { org: 'bazinga2' }, + }, + }) + }) - jest.runOnlyPendingTimers() + it('does call decide with a request for flags if called directly (via groups) even if disabled for first load', async () => { + instance = await createPosthog({ + advanced_disable_feature_flags_on_first_load: true, + loaded: (ph) => { + ph.group('org', 'bazinga', { name: 'Shelly' }) + }, + }) - expect(instance.featureFlags.setGroupPropertiesForFlags).toHaveBeenCalled() // loaded ph.group() calls setGroupPropertiesForFlags - expect(instance.featureFlags.setReloadingPaused).toHaveBeenCalledWith(true) - expect(instance.featureFlags.resetRequestQueue).toHaveBeenCalledTimes(1) - expect(instance.featureFlags._startReloadTimer).toHaveBeenCalled() - expect(instance.featureFlags.setReloadingPaused).toHaveBeenCalledWith(false) + expect(instance.config.advanced_disable_feature_flags_on_first_load).toBe(true) - // we should call _reloadFeatureFlagsRequest for `group` only after the initial load - // because it ought to be paused until decide returns + expect(instance.featureFlags._callDecideEndpoint).toHaveBeenCalledTimes(1) expect(instance._send_request).toHaveBeenCalledTimes(1) - expect(instance.featureFlags._reloadFeatureFlagsRequest).toHaveBeenCalledTimes(0) - jest.runOnlyPendingTimers() - expect(instance._send_request).toHaveBeenCalledTimes(2) - expect(instance.featureFlags._reloadFeatureFlagsRequest).toHaveBeenCalledTimes(1) - }) - }) + expect(instance._send_request.mock.calls[0][0].data.disable_flags).toEqual(undefined) - it('toggles feature flags on and off', () => { - instance._loaded() + jest.runOnlyPendingTimers() // Once for callback + jest.runOnlyPendingTimers() // Once for potential debounce - expect(instance.featureFlags.setReloadingPaused).toHaveBeenCalledWith(true) - expect(instance.featureFlags.setReloadingPaused).toHaveBeenCalledWith(false) - expect(instance.featureFlags._startReloadTimer).toHaveBeenCalled() - expect(instance.featureFlags.receivedFeatureFlags).toHaveBeenCalledTimes(1) + expect(instance.featureFlags._callDecideEndpoint).toHaveBeenCalledTimes(1) + expect(instance._send_request).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/src/__tests__/posthog-core.ts b/src/__tests__/posthog-core.ts index 0579706b4..2ef22b6ee 100644 --- a/src/__tests__/posthog-core.ts +++ b/src/__tests__/posthog-core.ts @@ -12,7 +12,6 @@ import { PostHogPersistence } from '../posthog-persistence' import { SessionIdManager } from '../sessionid' import { RequestQueue } from '../request-queue' import { SessionRecording } from '../extensions/replay/sessionrecording' -import { PostHogFeatureFlags } from '../posthog-featureflags' describe('posthog core', () => { const baseUTCDateTime = new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) @@ -1088,18 +1087,7 @@ describe('posthog core', () => { describe('_loaded()', () => { it('calls loaded config option', () => { - const posthog = posthogWith( - { loaded: jest.fn() }, - { - capture: jest.fn(), - featureFlags: { - setReloadingPaused: jest.fn(), - resetRequestQueue: jest.fn(), - _startReloadTimer: jest.fn(), - } as unknown as PostHogFeatureFlags, - _start_queue_if_opted_in: jest.fn(), - } - ) + const posthog = posthogWith({ loaded: jest.fn() }) posthog._loaded() @@ -1107,22 +1095,11 @@ describe('posthog core', () => { }) it('handles loaded config option throwing gracefully', () => { - const posthog = posthogWith( - { - loaded: () => { - throw Error() - }, + const posthog = posthogWith({ + loaded: () => { + throw Error() }, - { - capture: jest.fn(), - featureFlags: { - setReloadingPaused: jest.fn(), - resetRequestQueue: jest.fn(), - _startReloadTimer: jest.fn(), - } as unknown as PostHogFeatureFlags, - _start_queue_if_opted_in: jest.fn(), - } - ) + }) posthog._loaded() @@ -1131,27 +1108,27 @@ describe('posthog core', () => { describe('/decide', () => { it('is called by default', async () => { - const instance = await createPosthogInstance(uuidv7()) - instance.featureFlags.setReloadingPaused = jest.fn() - instance._send_request = jest.fn() - instance._loaded() + const sendRequestMock = jest.fn() + await createPosthogInstance(uuidv7(), { + loaded: (ph) => { + ph._send_request = sendRequestMock + }, + }) - expect(instance._send_request.mock.calls[0][0]).toMatchObject({ + expect(sendRequestMock.mock.calls[0][0]).toMatchObject({ url: 'http://localhost/decide/?v=3', }) - expect(instance.featureFlags.setReloadingPaused).toHaveBeenCalledWith(true) }) it('does not call decide if disabled', async () => { + const sendRequestMock = jest.fn() const instance = await createPosthogInstance(uuidv7(), { advanced_disable_decide: true, + loaded: (ph) => { + ph._send_request = sendRequestMock + }, }) - instance.featureFlags.setReloadingPaused = jest.fn() - instance._send_request = jest.fn() - instance._loaded() - expect(instance._send_request).not.toHaveBeenCalled() - expect(instance.featureFlags.setReloadingPaused).not.toHaveBeenCalled() }) }) }) diff --git a/src/__tests__/remote-config.test.ts b/src/__tests__/remote-config.test.ts new file mode 100644 index 000000000..6fb265281 --- /dev/null +++ b/src/__tests__/remote-config.test.ts @@ -0,0 +1,106 @@ +import { RemoteConfigLoader } from '../remote-config' +import { RequestRouter } from '../utils/request-router' +import { PostHog } from '../posthog-core' +import { PostHogConfig, RemoteConfig } from '../types' +import '../entrypoints/external-scripts-loader' +import { assignableWindow } from '../utils/globals' + +describe('RemoteConfigLoader', () => { + let posthog: PostHog + + beforeEach(() => { + const defaultConfig: Partial = { + token: 'testtoken', + api_host: 'https://test.com', + persistence: 'memory', + } + + document.body.innerHTML = '' + document.head.innerHTML = '' + jest.spyOn(window.console, 'error').mockImplementation() + + posthog = { + config: { ...defaultConfig }, + _onRemoteConfig: jest.fn(), + _send_request: jest.fn().mockImplementation(({ callback }) => callback?.({ config: {} })), + featureFlags: { + ensureFlagsLoaded: jest.fn(), + }, + requestRouter: new RequestRouter({ config: defaultConfig } as unknown as PostHog), + } as unknown as PostHog + }) + + describe('remote config', () => { + const config = { surveys: true } as RemoteConfig + + beforeEach(() => { + posthog.config.__preview_remote_config = true + assignableWindow._POSTHOG_CONFIG = undefined + assignableWindow.POSTHOG_DEBUG = true + + assignableWindow.__PosthogExtensions__.loadExternalDependency = jest.fn( + (_ph: PostHog, _name: string, cb: (err?: any) => void) => { + assignableWindow._POSTHOG_CONFIG = config as RemoteConfig + cb() + } + ) + + posthog._send_request = jest.fn().mockImplementation(({ callback }) => callback?.({ json: config })) + }) + + it('properly pulls from the window and uses it if set', () => { + assignableWindow._POSTHOG_CONFIG = config as RemoteConfig + new RemoteConfigLoader(posthog).load() + + expect(assignableWindow.__PosthogExtensions__.loadExternalDependency).not.toHaveBeenCalled() + expect(posthog._send_request).not.toHaveBeenCalled() + + expect(posthog._onRemoteConfig).toHaveBeenCalledWith(config) + }) + + it('loads the script if window config not set', () => { + new RemoteConfigLoader(posthog).load() + + expect(assignableWindow.__PosthogExtensions__.loadExternalDependency).toHaveBeenCalledWith( + posthog, + 'remote-config', + expect.any(Function) + ) + expect(posthog._send_request).not.toHaveBeenCalled() + expect(posthog._onRemoteConfig).toHaveBeenCalledWith(config) + }) + + it('loads the json if window config not set and js failed', () => { + assignableWindow.__PosthogExtensions__.loadExternalDependency = jest.fn( + (_ph: PostHog, _name: string, cb: (err?: any) => void) => { + cb() + } + ) + + new RemoteConfigLoader(posthog).load() + + expect(assignableWindow.__PosthogExtensions__.loadExternalDependency).toHaveBeenCalled() + expect(posthog._send_request).toHaveBeenCalledWith({ + method: 'GET', + url: 'https://test.com/array/testtoken/config', + callback: expect.any(Function), + }) + expect(posthog._onRemoteConfig).toHaveBeenCalledWith(config) + }) + + it.each([ + [true, true], + [false, false], + [undefined, true], + ])('conditionally reloads feature flags - hasFlags: %s, shouldReload: %s', (hasFeatureFlags, shouldReload) => { + assignableWindow._POSTHOG_CONFIG = { hasFeatureFlags } as RemoteConfig + new RemoteConfigLoader(posthog).load() + + if (shouldReload) { + expect(posthog.featureFlags.ensureFlagsLoaded).toHaveBeenCalled() + } else { + expect(posthog.featureFlags.ensureFlagsLoaded).not.toHaveBeenCalled() + } + }) + }) +}) diff --git a/src/decide.ts b/src/decide.ts deleted file mode 100644 index 9483e4d20..000000000 --- a/src/decide.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { PostHog } from './posthog-core' -import { Compression, DecideResponse, RemoteConfig } from './types' -import { STORED_GROUP_PROPERTIES_KEY, STORED_PERSON_PROPERTIES_KEY } from './constants' - -import { createLogger } from './utils/logger' -import { assignableWindow, document } from './utils/globals' - -const logger = createLogger('[Decide]') - -export class Decide { - constructor(private readonly instance: PostHog) { - // don't need to wait for `decide` to return if flags were provided on initialisation - this.instance.receivedFlagValues = this.instance._hasBootstrappedFeatureFlags() - } - - private _loadRemoteConfigJs(cb: (config?: RemoteConfig) => void): void { - if (assignableWindow.__PosthogExtensions__?.loadExternalDependency) { - assignableWindow.__PosthogExtensions__?.loadExternalDependency?.(this.instance, 'remote-config', () => { - return cb(assignableWindow._POSTHOG_CONFIG) - }) - } else { - logger.error('PostHog Extensions not found. Cannot load remote config.') - cb() - } - } - - private _loadRemoteConfigJSON(cb: (config?: RemoteConfig) => void): void { - this.instance._send_request({ - method: 'GET', - url: this.instance.requestRouter.endpointFor('assets', `/array/${this.instance.config.token}/config`), - callback: (response) => { - cb(response.json as RemoteConfig | undefined) - }, - }) - } - - call(): void { - // Call decide to get what features are enabled and other settings. - // As a reminder, if the /decide endpoint is disabled, feature flags, toolbar, session recording, autocapture, - // and compression will not be available. - const disableRemoteCalls = !!this.instance.config.advanced_disable_decide - - if (!disableRemoteCalls) { - // TRICKY: Reset any decide reloads queued during config.loaded because they'll be - // covered by the decide call right above. - this.instance.featureFlags.resetRequestQueue() - } - - if (this.instance.config.__preview_remote_config) { - // Attempt 1 - use the pre-loaded config if it came as part of the token-specific array.js - if (assignableWindow._POSTHOG_CONFIG) { - logger.info('Using preloaded remote config', assignableWindow._POSTHOG_CONFIG) - this.onRemoteConfig(assignableWindow._POSTHOG_CONFIG) - return - } - - if (disableRemoteCalls) { - logger.warn('Remote config is disabled. Falling back to local config.') - return - } - - // Attempt 2 - if we have the external deps loader then lets load the script version of the config that includes site apps - this._loadRemoteConfigJs((config) => { - if (!config) { - logger.info('No config found after loading remote JS config. Falling back to JSON.') - // Attempt 3 Load the config json instead of the script - we won't get site apps etc. but we will get the config - this._loadRemoteConfigJSON((config) => { - this.onRemoteConfig(config) - }) - return - } - - this.onRemoteConfig(config) - }) - - return - } - - if (disableRemoteCalls) { - return - } - - /* - Calls /decide endpoint to fetch options for autocapture, session recording, feature flags & compression. - */ - const data = { - token: this.instance.config.token, - distinct_id: this.instance.get_distinct_id(), - groups: this.instance.getGroups(), - person_properties: this.instance.get_property(STORED_PERSON_PROPERTIES_KEY), - group_properties: this.instance.get_property(STORED_GROUP_PROPERTIES_KEY), - disable_flags: - this.instance.config.advanced_disable_feature_flags || - this.instance.config.advanced_disable_feature_flags_on_first_load || - undefined, - } - - this.instance._send_request({ - method: 'POST', - url: this.instance.requestRouter.endpointFor('api', '/decide/?v=3'), - data, - compression: this.instance.config.disable_compression ? undefined : Compression.Base64, - timeout: this.instance.config.feature_flag_request_timeout_ms, - callback: (response) => this.parseDecideResponse(response.json as DecideResponse | undefined), - }) - } - - parseDecideResponse(response?: DecideResponse): void { - this.instance.featureFlags.setReloadingPaused(false) - // :TRICKY: Reload - start another request if queued! - this.instance.featureFlags._startReloadTimer() - - const errorsLoading = !response - - if ( - !this.instance.config.advanced_disable_feature_flags_on_first_load && - !this.instance.config.advanced_disable_feature_flags - ) { - this.instance.featureFlags.receivedFeatureFlags(response ?? {}, errorsLoading) - } - - this.instance.decideEndpointWasHit = !errorsLoading - - if (errorsLoading) { - logger.error('Failed to fetch feature flags from PostHog.') - return - } - if (!(document && document.body)) { - logger.info('document not ready yet, trying again in 500 milliseconds...') - setTimeout(() => { - this.parseDecideResponse(response) - }, 500) - return - } - - this.instance._onRemoteConfig(response) - } - - private onRemoteConfig(config?: RemoteConfig): void { - // NOTE: Once this is rolled out we will remove the "decide" related code above. Until then the code duplication is fine. - if (!config) { - logger.error('Failed to fetch remote config from PostHog.') - return - } - if (!(document && document.body)) { - logger.info('document not ready yet, trying again in 500 milliseconds...') - setTimeout(() => { - this.onRemoteConfig(config) - }, 500) - return - } - - this.instance._onRemoteConfig(config) - - if (config.hasFeatureFlags !== false) { - // TRICKY: This is set in the parent for some reason... - this.instance.featureFlags.setReloadingPaused(false) - // If the config has feature flags, we need to call decide to get the feature flags - // This completely separates it from the config logic which is good in terms of separation of concerns - this.instance.featureFlags.reloadFeatureFlags() - } - } -} diff --git a/src/posthog-core.ts b/src/posthog-core.ts index f7225bb48..9be84c556 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -21,7 +21,7 @@ import { ENABLE_PERSON_PROCESSING, } from './constants' import { SessionRecording } from './extensions/replay/sessionrecording' -import { Decide } from './decide' +import { RemoteConfigLoader } from './remote-config' import { Toolbar } from './extensions/toolbar' import { localStore } from './storage' import { RequestQueue } from './request-queue' @@ -275,8 +275,6 @@ export class PostHog { _triggered_notifs: any compression?: Compression __request_queue: QueuedRequestOptions[] - decideEndpointWasHit: boolean - receivedFlagValues: boolean analyticsDefaultEndpoint: string version = Config.LIB_VERSION _initialPersonProfilesConfig: 'always' | 'never' | 'identified_only' | null @@ -286,6 +284,11 @@ export class PostHog { private _internalEventEmitter = new SimpleEventEmitter() + // Legacy property to support existing usage - this isn't technically correct but it's what it has always been - a proxy for flags being loaded + public get decideEndpointWasHit(): boolean { + return this.featureFlags.hasLoadedFlags + } + /** DEPRECATED: We keep this to support existing usage but now one should just call .setPersonProperties */ people: { set: (prop: string | Properties, to?: string, callback?: RequestCallback) => void @@ -295,8 +298,6 @@ export class PostHog { constructor() { this.config = defaultConfig() - this.decideEndpointWasHit = false - this.receivedFlagValues = false this.SentryIntegration = SentryIntegration this.sentryIntegration = (options?: SentryIntegrationOptions) => sentryIntegration(this, options) this.__request_queue = [] @@ -547,6 +548,14 @@ export class PostHog { } _onRemoteConfig(config: RemoteConfig) { + if (!(document && document.body)) { + logger.info('document not ready yet, trying again in 500 milliseconds...') + setTimeout(() => { + this._onRemoteConfig(config) + }, 500) + return + } + this.compression = undefined if (config.supportedCompression && !this.config.disable_compression) { this.compression = includes(config['supportedCompression'], Compression.GZipJS) @@ -579,13 +588,6 @@ export class PostHog { } _loaded(): void { - // Pause `reloadFeatureFlags` calls in config.loaded callback. - // These feature flags are loaded in the decide call made right after - const disableDecide = this.config.advanced_disable_decide - if (!disableDecide) { - this.featureFlags.setReloadingPaused(true) - } - try { this.config.loaded(this) } catch (err) { @@ -605,7 +607,8 @@ export class PostHog { }, 1) } - new Decide(this).call() + new RemoteConfigLoader(this).load() + this.featureFlags.decide() } _start_queue_if_opted_in(): void { diff --git a/src/posthog-featureflags.ts b/src/posthog-featureflags.ts index 5adc3315c..e8d2e9c3e 100644 --- a/src/posthog-featureflags.ts +++ b/src/posthog-featureflags.ts @@ -82,20 +82,41 @@ export const parseFeatureFlagDecideResponse = ( } export class PostHogFeatureFlags { - instance: PostHog - _override_warning: boolean + _override_warning: boolean = false featureFlagEventHandlers: FeatureFlagsCallback[] - reloadFeatureFlagsQueued: boolean - reloadFeatureFlagsInAction: boolean $anon_distinct_id: string | undefined - - constructor(instance: PostHog) { - this.instance = instance - this._override_warning = false + private _hasLoadedFlags: boolean = false + private _requestInFlight: boolean = false + private _reloadingDisabled: boolean = false + private _additionalReloadRequested: boolean = false + private _reloadDebouncer?: any + private _decideCalled: boolean = false + private _flagsLoadedFromRemote: boolean = false + + constructor(private instance: PostHog) { this.featureFlagEventHandlers = [] + } + + decide(): void { + if (this.instance.config.__preview_remote_config) { + // If remote config is enabled we don't call decide and we mark it as called so that we don't simulate it + this._decideCalled = true + return + } + + // TRICKY: We want to disable flags if we don't have a queued reload, and one of the settings exist for disabling on first load + const disableFlags = + !this._reloadDebouncer && + (this.instance.config.advanced_disable_feature_flags || + this.instance.config.advanced_disable_feature_flags_on_first_load) - this.reloadFeatureFlagsQueued = false - this.reloadFeatureFlagsInAction = false + this._callDecideEndpoint({ + disableFlags, + }) + } + + get hasLoadedFlags(): boolean { + return this._hasLoadedFlags } getFlags(): string[] { @@ -137,66 +158,84 @@ export class PostHogFeatureFlags { * * 1. Avoid parallel requests * 2. Delay a few milliseconds after each reloadFeatureFlags call to batch subsequent changes together - * 3. Don't call this during initial load (as /decide will be called instead), see posthog-core.js */ reloadFeatureFlags(): void { - if (!this.reloadFeatureFlagsQueued) { - this.reloadFeatureFlagsQueued = true - this._startReloadTimer() + if (this._reloadingDisabled || this.instance.config.advanced_disable_feature_flags) { + // If reloading has been explicitly disabled then we don't want to do anything + // Or if feature flags are disabled + return } + + if (this._reloadDebouncer) { + // If we're already in a debounce then we don't want to do anything + return + } + + // Debounce multiple calls on the same tick + this._reloadDebouncer = setTimeout(() => { + this._callDecideEndpoint() + }, 5) } - setAnonymousDistinctId(anon_distinct_id: string): void { - this.$anon_distinct_id = anon_distinct_id + private clearDebouncer(): void { + clearTimeout(this._reloadDebouncer) + this._reloadDebouncer = undefined } - setReloadingPaused(isPaused: boolean): void { - this.reloadFeatureFlagsInAction = isPaused + ensureFlagsLoaded(): void { + if (this._hasLoadedFlags || this._requestInFlight || this._reloadDebouncer) { + // If we are or have already loaded the flags then we don't want to do anything + return + } + + this.reloadFeatureFlags() } - resetRequestQueue(): void { - this.reloadFeatureFlagsQueued = false + setAnonymousDistinctId(anon_distinct_id: string): void { + this.$anon_distinct_id = anon_distinct_id } - _startReloadTimer(): void { - if (this.reloadFeatureFlagsQueued && !this.reloadFeatureFlagsInAction) { - setTimeout(() => { - if (!this.reloadFeatureFlagsInAction && this.reloadFeatureFlagsQueued) { - this.reloadFeatureFlagsQueued = false - this._reloadFeatureFlagsRequest() - } - }, 5) - } + setReloadingPaused(isPaused: boolean): void { + this._reloadingDisabled = isPaused } - _reloadFeatureFlagsRequest(): void { - if (this.instance.config.advanced_disable_feature_flags) { + /** + * NOTE: This is used both for flags and remote config. Once the RemoteConfig is fully released this will essentially only + * be for flags and can eventually be replaced with the new flags endpoint + */ + _callDecideEndpoint(options?: { disableFlags?: boolean }): void { + // Ensure we don't have double queued decide requests + this.clearDebouncer() + if (this.instance.config.advanced_disable_decide) { + // The way this is documented is essentially used to refuse to ever call the decide endpoint. + return + } + if (this._requestInFlight) { + this._additionalReloadRequested = true return } - - this.setReloadingPaused(true) const token = this.instance.config.token - const personProperties = this.instance.get_property(STORED_PERSON_PROPERTIES_KEY) - const groupProperties = this.instance.get_property(STORED_GROUP_PROPERTIES_KEY) - const json_data = { + const data: Record = { token: token, distinct_id: this.instance.get_distinct_id(), groups: this.instance.getGroups(), $anon_distinct_id: this.$anon_distinct_id, - person_properties: personProperties, - group_properties: groupProperties, - disable_flags: this.instance.config.advanced_disable_feature_flags || undefined, + person_properties: this.instance.get_property(STORED_PERSON_PROPERTIES_KEY), + group_properties: this.instance.get_property(STORED_GROUP_PROPERTIES_KEY), + } + + if (options?.disableFlags || this.instance.config.advanced_disable_feature_flags) { + data.disable_flags = true } + this._requestInFlight = true this.instance._send_request({ method: 'POST', url: this.instance.requestRouter.endpointFor('api', '/decide/?v=3'), - data: json_data, + data, compression: this.instance.config.disable_compression ? undefined : Compression.Base64, timeout: this.instance.config.feature_flag_request_timeout_ms, callback: (response) => { - this.setReloadingPaused(false) - let errorsLoading = true if (response.statusCode === 200) { @@ -206,15 +245,26 @@ export class PostHogFeatureFlags { this.$anon_distinct_id = undefined errorsLoading = false } - // :TRICKY: We want to fire the callback even if the request fails - // and return existing flags if they exist - // This is because we don't want to block clients waiting for flags to load. - // It's possible they're waiting for the callback to render the UI, but it never occurs. + + this._requestInFlight = false + + if (!this._decideCalled) { + this._decideCalled = true + this.instance._onRemoteConfig(response.json ?? {}) + } + + if (data.disable_flags) { + // If flags are disabled then there is no need to call decide again (flags are the only thing that may change) + return + } + + this._flagsLoadedFromRemote = !errorsLoading this.receivedFeatureFlags(response.json ?? {}, errorsLoading) - this.instance.decideEndpointWasHit = !errorsLoading - // :TRICKY: Reload - start another request if queued! - this._startReloadTimer() + if (this._additionalReloadRequested) { + this._additionalReloadRequested = false + this._callDecideEndpoint() + } }, }) } @@ -230,7 +280,7 @@ export class PostHogFeatureFlags { * @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_call event to PostHog. */ getFeatureFlag(key: string, options: { send_event?: boolean } = {}): boolean | string | undefined { - if (!this.instance.receivedFlagValues && !(this.getFlags() && this.getFlags().length > 0)) { + if (!this._hasLoadedFlags && !(this.getFlags() && this.getFlags().length > 0)) { logger.warn('getFeatureFlag for key "' + key + '" failed. Feature flags didn\'t load in time.') return undefined } @@ -255,7 +305,7 @@ export class PostHogFeatureFlags { $feature_flag_bootstrapped_payload: this.instance.config.bootstrap?.featureFlagPayloads?.[key] || null, // If we haven't yet received a response from the /decide endpoint, we must have used the bootstrapped value - $used_bootstrap_value: !this.instance.decideEndpointWasHit, + $used_bootstrap_value: !this._flagsLoadedFromRemote, }) } } @@ -278,7 +328,7 @@ export class PostHogFeatureFlags { * @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_call event to PostHog. */ isFeatureEnabled(key: string, options: { send_event?: boolean } = {}): boolean | undefined { - if (!this.instance.receivedFlagValues && !(this.getFlags() && this.getFlags().length > 0)) { + if (!this._hasLoadedFlags && !(this.getFlags() && this.getFlags().length > 0)) { logger.warn('isFeatureEnabled for key "' + key + '" failed. Feature flags didn\'t load in time.') return undefined } @@ -297,7 +347,8 @@ export class PostHogFeatureFlags { if (!this.instance.persistence) { return } - this.instance.receivedFlagValues = true + this._hasLoadedFlags = true + const currentFlags = this.getFlagVariants() const currentFlagPayloads = this.getFlagPayloads() parseFeatureFlagDecideResponse(response, this.instance.persistence, currentFlags, currentFlagPayloads) @@ -351,7 +402,7 @@ export class PostHogFeatureFlags { */ onFeatureFlags(callback: FeatureFlagsCallback): () => void { this.addFeatureFlagsHandler(callback) - if (this.instance.receivedFlagValues) { + if (this._hasLoadedFlags) { const { flags, flagVariants } = this._prepareFeatureFlagsForCallbacks() callback(flags, flagVariants) } diff --git a/src/remote-config.ts b/src/remote-config.ts new file mode 100644 index 000000000..11c2a676e --- /dev/null +++ b/src/remote-config.ts @@ -0,0 +1,85 @@ +import { PostHog } from './posthog-core' +import { RemoteConfig } from './types' + +import { createLogger } from './utils/logger' +import { assignableWindow } from './utils/globals' + +const logger = createLogger('[Decide]') + +export class RemoteConfigLoader { + constructor(private readonly instance: PostHog) {} + + private _loadRemoteConfigJs(cb: (config?: RemoteConfig) => void): void { + if (assignableWindow.__PosthogExtensions__?.loadExternalDependency) { + assignableWindow.__PosthogExtensions__?.loadExternalDependency?.(this.instance, 'remote-config', () => { + return cb(assignableWindow._POSTHOG_CONFIG) + }) + } else { + logger.error('PostHog Extensions not found. Cannot load remote config.') + cb() + } + } + + private _loadRemoteConfigJSON(cb: (config?: RemoteConfig) => void): void { + this.instance._send_request({ + method: 'GET', + url: this.instance.requestRouter.endpointFor('assets', `/array/${this.instance.config.token}/config`), + callback: (response) => { + cb(response.json as RemoteConfig | undefined) + }, + }) + } + + load(): void { + // Call decide to get what features are enabled and other settings. + // As a reminder, if the /decide endpoint is disabled, feature flags, toolbar, session recording, autocapture, + // and compression will not be available. + + if (!this.instance.config.__preview_remote_config) { + return + } + + // Attempt 1 - use the pre-loaded config if it came as part of the token-specific array.js + if (assignableWindow._POSTHOG_CONFIG) { + logger.info('Using preloaded remote config', assignableWindow._POSTHOG_CONFIG) + this.onRemoteConfig(assignableWindow._POSTHOG_CONFIG) + return + } + + if (this.instance.config.advanced_disable_decide) { + // This setting is essentially saying "dont call external APIs" hence we respect it here + logger.warn('Remote config is disabled. Falling back to local config.') + return + } + + // Attempt 2 - if we have the external deps loader then lets load the script version of the config that includes site apps + this._loadRemoteConfigJs((config) => { + if (!config) { + logger.info('No config found after loading remote JS config. Falling back to JSON.') + // Attempt 3 Load the config json instead of the script - we won't get site apps etc. but we will get the config + this._loadRemoteConfigJSON((config) => { + this.onRemoteConfig(config) + }) + return + } + + this.onRemoteConfig(config) + }) + } + + private onRemoteConfig(config?: RemoteConfig): void { + // NOTE: Once this is rolled out we will remove the "decide" related code above. Until then the code duplication is fine. + if (!config) { + logger.error('Failed to fetch remote config from PostHog.') + return + } + this.instance._onRemoteConfig(config) + + // We only need to reload if we haven't already loaded the flags or if the request is in flight + if (config.hasFeatureFlags !== false) { + // If the config has feature flags, we need to call decide to get the feature flags + // This completely separates it from the config logic which is good in terms of separation of concerns + this.instance.featureFlags.ensureFlagsLoaded() + } + } +}