diff --git a/playground/nextjs/pages/_app.tsx b/playground/nextjs/pages/_app.tsx index 46784b661..3d74c2fe6 100644 --- a/playground/nextjs/pages/_app.tsx +++ b/playground/nextjs/pages/_app.tsx @@ -4,10 +4,9 @@ import { useEffect } from 'react' import type { AppProps } from 'next/app' import { useRouter } from 'next/router' -import posthog from 'posthog-js' import { PostHogProvider } from 'posthog-js/react' import { CookieBanner } from '@/src/CookieBanner' -import '@/src/posthog' +import { posthog } from '@/src/posthog' import Head from 'next/head' import { PageHeader } from '@/src/Header' import { useUser } from '@/src/auth' @@ -46,10 +45,10 @@ export default function App({ Component, pageProps }: AppProps) { http-equiv="Content-Security-Policy" content={` default-src 'self'; - connect-src 'self' ${localhostDomain} https://*.posthog.com; + connect-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host; script-src 'self' 'unsafe-eval' 'unsafe-inline' ${localhostDomain} https://*.posthog.com; style-src 'self' 'unsafe-inline' ${localhostDomain} https://*.posthog.com; - img-src 'self' ${localhostDomain} https://*.posthog.com; + img-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host; `} /> diff --git a/playground/nextjs/pages/_document.tsx b/playground/nextjs/pages/_document.tsx index d586b3595..bdc7be58b 100644 --- a/playground/nextjs/pages/_document.tsx +++ b/playground/nextjs/pages/_document.tsx @@ -1,10 +1,20 @@ +import { POSTHOG_USE_SNIPPET } from '@/src/posthog' import { Html, Head, Main, NextScript } from 'next/document' +import Script from 'next/script' import React from 'react' export default function Document() { return ( - + + {POSTHOG_USE_SNIPPET ? ( + + ) : null} +
diff --git a/playground/nextjs/pages/survey.tsx b/playground/nextjs/pages/survey.tsx index 3587985e1..e7049b08d 100644 --- a/playground/nextjs/pages/survey.tsx +++ b/playground/nextjs/pages/survey.tsx @@ -1,6 +1,6 @@ import { usePostHog } from 'posthog-js/react' import { useEffect, useState } from 'react' -import { Survey } from 'posthog-js' +import type { Survey } from 'posthog-js' export default function SurveyForm() { const posthog = usePostHog() diff --git a/playground/nextjs/src/posthog.ts b/playground/nextjs/src/posthog.ts index 6a4062a34..27ca2c9d6 100644 --- a/playground/nextjs/src/posthog.ts +++ b/playground/nextjs/src/posthog.ts @@ -4,12 +4,19 @@ // import 'posthog-js/dist/exception-autocapture' // import 'posthog-js/dist/tracing-headers' -import posthog, { PostHogConfig } from 'posthog-js' +import posthogJS, { PostHogConfig } from 'posthog-js' import { User } from './auth' export const PERSON_PROCESSING_MODE: 'always' | 'identified_only' | 'never' = (process.env.NEXT_PUBLIC_POSTHOG_PERSON_PROCESSING_MODE as any) || 'identified_only' +export const POSTHOG_USE_SNIPPET: boolean = (process.env.NEXT_PUBLIC_POSTHOG_USE_SNIPPET as any) || false + +export const posthog = POSTHOG_USE_SNIPPET + ? typeof window !== 'undefined' + ? (window as any).posthog + : null + : posthogJS /** * Below is an example of a consent-driven config for PostHog * Lots of things start in a disabled state and posthog will not use cookies without consent @@ -55,9 +62,9 @@ if (typeof window !== 'undefined') { persistence: cookieConsentGiven() ? 'localStorage+cookie' : 'memory', person_profiles: PERSON_PROCESSING_MODE === 'never' ? 'identified_only' : PERSON_PROCESSING_MODE, persistence_name: `${process.env.NEXT_PUBLIC_POSTHOG_KEY}_nextjs`, + __preview_remote_config: true, ...configForConsent(), }) - // Help with debugging(window as any).posthog = posthog } diff --git a/src/__tests__/autocapture.test.ts b/src/__tests__/autocapture.test.ts index 1cf4ebb2d..a2e8039f7 100644 --- a/src/__tests__/autocapture.test.ts +++ b/src/__tests__/autocapture.test.ts @@ -380,7 +380,7 @@ describe('Autocapture system', () => { beforeEach(() => { posthog.config.rageclick = true // Trigger proper enabling - autocapture.afterDecideResponse({} as DecideResponse) + autocapture.onRemoteConfig({} as DecideResponse) }) it('should capture rageclick', () => { @@ -502,7 +502,7 @@ describe('Autocapture system', () => { it('should not capture events when config returns false, when an element matching any of the event selectors is clicked', () => { posthog.config.autocapture = false - autocapture.afterDecideResponse({} as DecideResponse) + autocapture.onRemoteConfig({} as DecideResponse) const eventElement1 = document.createElement('div') const eventElement2 = document.createElement('div') @@ -524,7 +524,7 @@ describe('Autocapture system', () => { }) it('should not capture events when config returns true but server setting is disabled', () => { - autocapture.afterDecideResponse({ + autocapture.onRemoteConfig({ autocapture_opt_out: true, } as DecideResponse) @@ -932,7 +932,7 @@ describe('Autocapture system', () => { type: 'click', } as unknown as MouseEvent - autocapture.afterDecideResponse({ + autocapture.onRemoteConfig({ elementsChainAsString: true, } as DecideResponse) @@ -1003,7 +1003,7 @@ describe('Autocapture system', () => { beforeEach(() => { document.title = 'test page' posthog.config.mask_all_element_attributes = false - autocapture.afterDecideResponse({} as DecideResponse) + autocapture.onRemoteConfig({} as DecideResponse) }) it('should capture click events', () => { @@ -1056,7 +1056,7 @@ describe('Autocapture system', () => { 'when client side config is %p and remote opt out is %p - autocapture enabled should be %p', (clientSideOptIn, serverSideOptOut, expected) => { posthog.config.autocapture = clientSideOptIn - autocapture.afterDecideResponse({ + autocapture.onRemoteConfig({ autocapture_opt_out: serverSideOptOut, } as DecideResponse) expect(autocapture.isEnabled).toBe(expected) @@ -1065,12 +1065,12 @@ describe('Autocapture system', () => { it('should call _addDomEventHandlders if autocapture is true in client config', () => { posthog.config.autocapture = true - autocapture.afterDecideResponse({} as DecideResponse) + autocapture.onRemoteConfig({} as DecideResponse) expect(autocapture['_addDomEventHandlers']).toHaveBeenCalled() }) it('should not call _addDomEventHandlders if autocapture is opted out in server config', () => { - autocapture.afterDecideResponse({ autocapture_opt_out: true } as DecideResponse) + autocapture.onRemoteConfig({ autocapture_opt_out: true } as DecideResponse) expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() }) @@ -1078,16 +1078,16 @@ describe('Autocapture system', () => { expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() posthog.config.autocapture = false - autocapture.afterDecideResponse({} as DecideResponse) + autocapture.onRemoteConfig({} as DecideResponse) expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() }) it('should NOT call _addDomEventHandlders when the token has already been initialized', () => { - autocapture.afterDecideResponse({} as DecideResponse) + autocapture.onRemoteConfig({} as DecideResponse) expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1) - autocapture.afterDecideResponse({} as DecideResponse) + autocapture.onRemoteConfig({} as DecideResponse) expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1) }) }) diff --git a/src/__tests__/decide.ts b/src/__tests__/decide.ts index 026f6d0b5..b770283ac 100644 --- a/src/__tests__/decide.ts +++ b/src/__tests__/decide.ts @@ -2,8 +2,9 @@ import { Decide } from '../decide' import { PostHogPersistence } from '../posthog-persistence' import { RequestRouter } from '../utils/request-router' import { PostHog } from '../posthog-core' -import { DecideResponse, PostHogConfig, Properties } from '../types' +import { DecideResponse, PostHogConfig, Properties, RemoteConfig } from '../types' import '../entrypoints/external-scripts-loader' +import { assignableWindow } from '../utils/globals' const expectDecodedSendRequest = ( send_request: PostHog['_send_request'], @@ -52,10 +53,12 @@ describe('Decide', () => { get_property: (key: string) => posthog.persistence!.props[key], capture: jest.fn(), _addCaptureHook: jest.fn(), - _afterDecideResponse: 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(), @@ -200,7 +203,7 @@ describe('Decide', () => { subject({} as DecideResponse) expect(posthog.featureFlags.receivedFeatureFlags).toHaveBeenCalledWith({}, false) - expect(posthog._afterDecideResponse).toHaveBeenCalledWith({}) + expect(posthog._onRemoteConfig).toHaveBeenCalledWith({}) }) it('Make sure receivedFeatureFlags is called with errors if the decide response fails', () => { @@ -225,7 +228,7 @@ describe('Decide', () => { } as unknown as DecideResponse subject(decideResponse) - expect(posthog._afterDecideResponse).toHaveBeenCalledWith(decideResponse) + expect(posthog._onRemoteConfig).toHaveBeenCalledWith(decideResponse) expect(posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled() }) @@ -242,8 +245,82 @@ describe('Decide', () => { } as unknown as DecideResponse subject(decideResponse) - expect(posthog._afterDecideResponse).toHaveBeenCalledWith(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__/extensions/exception-autocapture/exception-observer.test.ts b/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts index ad934e2b1..654a8da25 100644 --- a/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts +++ b/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts @@ -67,7 +67,7 @@ describe('Exception Observer', () => { describe('when enabled', () => { beforeEach(() => { - exceptionObserver.afterDecideResponse({ autocaptureExceptions: true } as DecideResponse) + exceptionObserver.onRemoteConfig({ autocaptureExceptions: true } as DecideResponse) }) it('should instrument handlers when started', () => { @@ -173,7 +173,7 @@ describe('Exception Observer', () => { window!.onerror = originalOnError window!.onunhandledrejection = originalOnUnhandledRejection - exceptionObserver.afterDecideResponse({ autocaptureExceptions: true } as DecideResponse) + exceptionObserver.onRemoteConfig({ autocaptureExceptions: true } as DecideResponse) }) it('should wrap original onerror handler if one was present when wrapped', () => { @@ -232,7 +232,7 @@ describe('Exception Observer', () => { describe('when disabled', () => { beforeEach(() => { - exceptionObserver.afterDecideResponse({ autocaptureExceptions: false } as DecideResponse) + exceptionObserver.onRemoteConfig({ autocaptureExceptions: false } as DecideResponse) }) it('cannot be started', () => { diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index d786d56e3..a0c676588 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -486,7 +486,7 @@ describe('SessionRecording', () => { }) it('loads script based on script config', () => { - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', scriptConfig: { script: 'experimental-recorder' } }, }) @@ -553,7 +553,7 @@ describe('SessionRecording', () => { windowId: 'windowId', }) - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: undefined })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: undefined })) expect(sessionRecording['status']).toBe('disabled') expect(sessionRecording['buffer'].data.length).toEqual(0) expect(posthog.capture).not.toHaveBeenCalled() @@ -564,7 +564,7 @@ describe('SessionRecording', () => { expect(loadScriptMock).toHaveBeenCalled() expect(sessionRecording['status']).toBe('buffering') - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) expect(sessionRecording['status']).toBe('active') }) @@ -573,14 +573,14 @@ describe('SessionRecording', () => { expect(loadScriptMock).toHaveBeenCalled() expect(sessionRecording['isSampled']).toBe(null) - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) expect(sessionRecording['isSampled']).toBe(null) }) it('stores true in persistence if recording is enabled from the server', () => { posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: undefined }) - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) expect(posthog.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE)).toBe(true) }) @@ -588,7 +588,7 @@ describe('SessionRecording', () => { it('stores true in persistence if canvas is enabled from the server', () => { posthog.persistence?.register({ [SESSION_RECORDING_CANVAS_RECORDING]: undefined }) - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', recordCanvas: true, canvasFps: 6, canvasQuality: '0.2' }, }) @@ -604,7 +604,7 @@ describe('SessionRecording', () => { it('stores false in persistence if recording is not enabled from the server', () => { posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: undefined }) - sessionRecording.afterDecideResponse(makeDecideResponse({})) + sessionRecording.onRemoteConfig(makeDecideResponse({})) expect(posthog.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE)).toBe(false) }) @@ -612,7 +612,7 @@ describe('SessionRecording', () => { it('stores sample rate', () => { posthog.persistence?.register({ SESSION_RECORDING_SAMPLE_RATE: undefined }) - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', sampleRate: '0.70' }, }) @@ -624,7 +624,7 @@ describe('SessionRecording', () => { it('starts session recording, saves setting and endpoint when enabled', () => { posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: undefined }) - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/ses/' }, }) @@ -642,7 +642,7 @@ describe('SessionRecording', () => { it('does not emit to capture if the sample rate is 0', () => { sessionRecording.startIfEnabledOrStop() - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', sampleRate: '0.00' }, }) @@ -657,7 +657,7 @@ describe('SessionRecording', () => { it('does emit to capture if the sample rate is null', () => { sessionRecording.startIfEnabledOrStop() - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', sampleRate: null }, }) @@ -669,7 +669,7 @@ describe('SessionRecording', () => { it('stores excluded session when excluded', () => { sessionRecording.startIfEnabledOrStop() - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', sampleRate: '0.00' }, }) @@ -684,7 +684,7 @@ describe('SessionRecording', () => { _emit(createIncrementalSnapshot({ data: { source: 1 } })) expect(posthog.capture).not.toHaveBeenCalled() - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', sampleRate: '1.00' }, }) @@ -704,7 +704,7 @@ describe('SessionRecording', () => { it('sets emit as expected when sample rate is 0.5', () => { sessionRecording.startIfEnabledOrStop() - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', sampleRate: '0.50' }, }) @@ -758,7 +758,7 @@ describe('SessionRecording', () => { it('skips when any config variable is missing', () => { sessionRecording.startIfEnabledOrStop() - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', recordCanvas: null, canvasFps: null, canvasQuality: null }, }) @@ -837,7 +837,7 @@ describe('SessionRecording', () => { windowId: 'windowId', }) - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) // next call to emit won't flush the buffer // the events aren't big enough @@ -869,7 +869,7 @@ describe('SessionRecording', () => { }) it('buffers emitted events', () => { - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) sessionRecording.startIfEnabledOrStop() expect(loadScriptMock).toHaveBeenCalled() @@ -904,7 +904,7 @@ describe('SessionRecording', () => { }) it('flushes buffer if the size of the buffer hits the limit', () => { - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) sessionRecording.startIfEnabledOrStop() expect(loadScriptMock).toHaveBeenCalled() const bigData = 'a'.repeat(RECORDING_MAX_EVENT_SIZE * 0.8) @@ -949,7 +949,7 @@ describe('SessionRecording', () => { }) it('flushes buffer if the session_id changes', () => { - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) sessionRecording.startIfEnabledOrStop() expect(sessionRecording['buffer'].sessionId).toEqual(sessionId) @@ -1061,7 +1061,7 @@ describe('SessionRecording', () => { it('can emit when there are circular references', () => { posthog.config.session_recording.compress_events = false - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) sessionRecording.startIfEnabledOrStop() const someObject = { emit: 1 } @@ -1362,7 +1362,7 @@ describe('SessionRecording', () => { beforeEach(() => { sessionRecording.startIfEnabledOrStop() - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) expect(sessionRecording['status']).toEqual('active') startingTimestamp = sessionRecording['_lastActivityTimestamp'] @@ -1718,7 +1718,7 @@ describe('SessionRecording', () => { expect(sessionRecording['_linkedFlag']).toEqual(null) expect(sessionRecording['_linkedFlagSeen']).toEqual(false) - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', linkedFlag: 'the-flag-key' } }) ) @@ -1741,7 +1741,7 @@ describe('SessionRecording', () => { expect(sessionRecording['_linkedFlag']).toEqual(null) expect(sessionRecording['_linkedFlagSeen']).toEqual(false) - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', linkedFlag: { flag: 'the-flag-key', variant: 'test-a' } }, }) @@ -1766,7 +1766,7 @@ describe('SessionRecording', () => { expect(sessionRecording['_linkedFlag']).toEqual(null) expect(sessionRecording['_linkedFlagSeen']).toEqual(false) - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', linkedFlag: 'the-flag-key' } }) ) @@ -1793,7 +1793,7 @@ describe('SessionRecording', () => { expect(sessionRecording['_linkedFlagSeen']).toEqual(false) expect(sessionRecording['status']).toEqual('buffering') - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', @@ -1870,7 +1870,7 @@ describe('SessionRecording', () => { }) it('can set minimum duration from decide response', () => { - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { minimumDurationMilliseconds: 1500 }, }) @@ -1879,7 +1879,7 @@ describe('SessionRecording', () => { }) it('does not flush if below the minimum duration', () => { - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { minimumDurationMilliseconds: 1500 }, }) @@ -1899,7 +1899,7 @@ describe('SessionRecording', () => { }) it('does flush if session duration is negative', () => { - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { minimumDurationMilliseconds: 1500 }, }) @@ -1924,7 +1924,7 @@ describe('SessionRecording', () => { }) it('does not stay buffering after the minimum duration', () => { - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { minimumDurationMilliseconds: 1500 }, }) @@ -1970,7 +1970,7 @@ describe('SessionRecording', () => { }) sessionRecording = new SessionRecording(posthog) - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) sessionRecording.startIfEnabledOrStop() expect(loadScriptMock).toHaveBeenCalled() @@ -2089,7 +2089,7 @@ describe('SessionRecording', () => { beforeEach(() => { posthog.config.session_recording.compress_events = true - sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) sessionRecording.startIfEnabledOrStop() }) @@ -2271,7 +2271,7 @@ describe('SessionRecording', () => { describe('URL blocking', () => { beforeEach(() => { sessionRecording.startIfEnabledOrStop() - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', @@ -2343,7 +2343,7 @@ describe('SessionRecording', () => { }) it('flushes buffer and starts when sees event', async () => { - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', @@ -2371,7 +2371,7 @@ describe('SessionRecording', () => { }) it('starts if sees an event but still waiting for a URL', async () => { - sessionRecording.afterDecideResponse( + sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { endpoint: '/s/', diff --git a/src/__tests__/extensions/web-vitals.test.ts b/src/__tests__/extensions/web-vitals.test.ts index 5cd284a13..b28aeb125 100644 --- a/src/__tests__/extensions/web-vitals.test.ts +++ b/src/__tests__/extensions/web-vitals.test.ts @@ -113,7 +113,7 @@ describe('web vitals', () => { assignableWindow.__PosthogExtensions__.loadExternalDependency = loadScriptMock // need to force this to get the web vitals script loaded - posthog.webVitalsAutocapture!.afterDecideResponse({ + posthog.webVitalsAutocapture!.onRemoteConfig({ capturePerformance: { web_vitals: true }, } as unknown as DecideResponse) @@ -244,7 +244,7 @@ describe('web vitals', () => { 'when client side config is %p and remote opt in is %p - web vitals enabled should be %p', (clientSideOptIn, serverSideOptIn, expected) => { posthog.config.capture_performance = { web_vitals: clientSideOptIn } - posthog.webVitalsAutocapture!.afterDecideResponse({ + posthog.webVitalsAutocapture!.onRemoteConfig({ capturePerformance: { web_vitals: serverSideOptIn }, } as DecideResponse) expect(posthog.webVitalsAutocapture!.isEnabled).toBe(expected) diff --git a/src/__tests__/heatmaps.test.ts b/src/__tests__/heatmaps.test.ts index 0f1588d2c..5168c8a37 100644 --- a/src/__tests__/heatmaps.test.ts +++ b/src/__tests__/heatmaps.test.ts @@ -217,7 +217,7 @@ describe('heatmaps', () => { (deprecatedclientSideOptIn, clientSideOptIn, serverSideOptIn, expected) => { posthog.config.enable_heatmaps = deprecatedclientSideOptIn posthog.config.capture_heatmaps = clientSideOptIn - posthog.heatmaps!.afterDecideResponse({ + posthog.heatmaps!.onRemoteConfig({ heatmaps: serverSideOptIn, } as DecideResponse) expect(posthog.heatmaps!.isEnabled).toBe(expected) diff --git a/src/__tests__/personProcessing.test.ts b/src/__tests__/personProcessing.test.ts index a5abee831..12abe13b3 100644 --- a/src/__tests__/personProcessing.test.ts +++ b/src/__tests__/personProcessing.test.ts @@ -2,7 +2,7 @@ import { createPosthogInstance } from './helpers/posthog-instance' import { uuidv7 } from '../uuidv7' import { logger } from '../utils/logger' import { INITIAL_CAMPAIGN_PARAMS, INITIAL_REFERRER_INFO } from '../constants' -import { DecideResponse } from '../types' +import { RemoteConfig } from '../types' jest.mock('../utils/logger') @@ -725,7 +725,7 @@ describe('person processing', () => { posthog.capture('startup page view') // act - posthog._afterDecideResponse({ defaultIdentifiedOnly: false } as DecideResponse) + posthog._onRemoteConfig({ defaultIdentifiedOnly: false } as RemoteConfig) posthog.capture('custom event') // assert @@ -740,7 +740,7 @@ describe('person processing', () => { posthog.capture('startup page view') // act - posthog._afterDecideResponse({ defaultIdentifiedOnly: false } as DecideResponse) + posthog._onRemoteConfig({ defaultIdentifiedOnly: false } as RemoteConfig) posthog.capture('custom event') // assert @@ -759,7 +759,7 @@ describe('person processing', () => { ) // act - posthog1._afterDecideResponse({ defaultIdentifiedOnly: false } as DecideResponse) + posthog1._onRemoteConfig({ defaultIdentifiedOnly: false } as RemoteConfig) posthog1.capture('custom event 1') const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = await setup( undefined, diff --git a/src/__tests__/posthog-core.loaded.ts b/src/__tests__/posthog-core.loaded.ts index 1dc667f4a..925087ce2 100644 --- a/src/__tests__/posthog-core.loaded.ts +++ b/src/__tests__/posthog-core.loaded.ts @@ -27,6 +27,7 @@ describe('loaded() with flags', () => { resetRequestQueue: jest.fn(), _startReloadTimer: jest.fn(), receivedFeatureFlags: jest.fn(), + onFeatureFlags: jest.fn(), }, _send_request: jest.fn(({ callback }) => callback?.({ status: 200, json: {} })), }) diff --git a/src/__tests__/posthog-core.ts b/src/__tests__/posthog-core.ts index b3f2c1c9c..25026ef0a 100644 --- a/src/__tests__/posthog-core.ts +++ b/src/__tests__/posthog-core.ts @@ -1,5 +1,3 @@ -import { Decide } from '../decide' - import { Info } from '../utils/event-utils' import { document, window } from '../utils/globals' import { uuidv7 } from '../uuidv7' @@ -7,7 +5,7 @@ import * as globals from '../utils/globals' import { ENABLE_PERSON_PROCESSING, USER_STATE } from '../constants' import { createPosthogInstance, defaultPostHog } from './helpers/posthog-instance' import { logger } from '../utils/logger' -import { DecideResponse, PostHogConfig } from '../types' +import { PostHogConfig, RemoteConfig } from '../types' import { PostHog } from '../posthog-core' import { PostHogPersistence } from '../posthog-persistence' import { SessionIdManager } from '../sessionid' @@ -15,8 +13,6 @@ import { RequestQueue } from '../request-queue' import { SessionRecording } from '../extensions/replay/sessionrecording' import { PostHogFeatureFlags } from '../posthog-featureflags' -jest.mock('../decide') - describe('posthog core', () => { const baseUTCDateTime = new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) const eventName = '$event' @@ -295,7 +291,7 @@ describe('posthog core', () => { it('sends payloads to alternative endpoint if given', () => { const posthog = posthogWith({ ...defaultConfig, request_batching: false }, defaultOverrides) - posthog._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } } as DecideResponse) + posthog._onRemoteConfig({ analytics: { endpoint: '/i/v0/e/' } } as RemoteConfig) posthog.capture('event-name', { foo: 'bar', length: 0 }) @@ -320,7 +316,7 @@ describe('posthog core', () => { it('sends payloads to overriden _url, even if alternative endpoint is set', () => { const posthog = posthogWith({ ...defaultConfig, request_batching: false }, defaultOverrides) - posthog._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } } as DecideResponse) + posthog._onRemoteConfig({ analytics: { endpoint: '/i/v0/e/' } } as RemoteConfig) posthog.capture('event-name', { foo: 'bar', length: 0 }, { _url: 'https://app.posthog.com/s/' }) @@ -336,29 +332,29 @@ describe('posthog core', () => { it('enables compression from decide response', () => { const posthog = posthogWith({}) - posthog._afterDecideResponse({ supportedCompression: ['gzip-js', 'base64'] } as DecideResponse) + posthog._onRemoteConfig({ supportedCompression: ['gzip-js', 'base64'] } as RemoteConfig) expect(posthog.compression).toEqual('gzip-js') }) it('uses defaultIdentifiedOnly from decide response', () => { const posthog = posthogWith({}) - posthog._afterDecideResponse({ defaultIdentifiedOnly: true } as DecideResponse) + posthog._onRemoteConfig({ defaultIdentifiedOnly: true } as RemoteConfig) expect(posthog.config.person_profiles).toEqual('identified_only') - posthog._afterDecideResponse({ defaultIdentifiedOnly: false } as DecideResponse) + posthog._onRemoteConfig({ defaultIdentifiedOnly: false } as RemoteConfig) expect(posthog.config.person_profiles).toEqual('always') }) it('defaultIdentifiedOnly does not override person_profiles if already set', () => { const posthog = posthogWith({ person_profiles: 'always' }) - posthog._afterDecideResponse({ defaultIdentifiedOnly: true } as DecideResponse) + posthog._onRemoteConfig({ defaultIdentifiedOnly: true } as RemoteConfig) expect(posthog.config.person_profiles).toEqual('always') }) it('enables compression from decide response when only one received', () => { const posthog = posthogWith({}) - posthog._afterDecideResponse({ supportedCompression: ['base64'] } as DecideResponse) + posthog._onRemoteConfig({ supportedCompression: ['base64'] } as RemoteConfig) expect(posthog.compression).toEqual('base64') }) @@ -366,7 +362,7 @@ describe('posthog core', () => { it('does not enable compression from decide response if compression is disabled', () => { const posthog = posthogWith({ disable_compression: true, persistence: 'memory' }) - posthog._afterDecideResponse({ supportedCompression: ['gzip-js', 'base64'] } as DecideResponse) + posthog._onRemoteConfig({ supportedCompression: ['gzip-js', 'base64'] } as RemoteConfig) expect(posthog.compression).toEqual(undefined) }) @@ -374,7 +370,7 @@ describe('posthog core', () => { it('defaults to /e if no endpoint is given', () => { const posthog = posthogWith({}) - posthog._afterDecideResponse({} as DecideResponse) + posthog._onRemoteConfig({} as RemoteConfig) expect(posthog.analyticsDefaultEndpoint).toEqual('/e/') }) @@ -382,7 +378,7 @@ describe('posthog core', () => { it('uses the specified analytics endpoint if given', () => { const posthog = posthogWith({}) - posthog._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } } as DecideResponse) + posthog._onRemoteConfig({ analytics: { endpoint: '/i/v0/e/' } } as RemoteConfig) expect(posthog.analyticsDefaultEndpoint).toEqual('/i/v0/e/') }) @@ -842,7 +838,7 @@ describe('posthog core', () => { expect(posthog.persistence.register).not.toHaveBeenCalled() // FFs are saved this way // Session recording - expect(posthog.sessionRecording.afterDecideResponse).not.toHaveBeenCalled() + expect(posthog.sessionRecording.onRemoteConfig).not.toHaveBeenCalled() }) describe('device id behavior', () => { @@ -1141,21 +1137,15 @@ describe('posthog core', () => { }) describe('/decide', () => { - beforeEach(() => { - const call = jest.fn() - ;(Decide as any).mockImplementation(() => ({ call })) - }) - - afterEach(() => { - ;(Decide as any).mockReset() - }) - it('is called by default', async () => { const instance = await createPosthogInstance(uuidv7()) instance.featureFlags.setReloadingPaused = jest.fn() + instance._send_request = jest.fn() instance._loaded() - expect(new Decide(instance).call).toHaveBeenCalled() + expect(instance._send_request.mock.calls[0][0]).toMatchObject({ + url: 'http://localhost/decide/?v=3', + }) expect(instance.featureFlags.setReloadingPaused).toHaveBeenCalledWith(true) }) @@ -1164,9 +1154,10 @@ describe('posthog core', () => { advanced_disable_decide: true, }) instance.featureFlags.setReloadingPaused = jest.fn() + instance._send_request = jest.fn() instance._loaded() - expect(new Decide(instance).call).not.toHaveBeenCalled() + expect(instance._send_request).not.toHaveBeenCalled() expect(instance.featureFlags.setReloadingPaused).not.toHaveBeenCalled() }) }) diff --git a/src/__tests__/site-apps.ts b/src/__tests__/site-apps.ts index 7964a7e17..aea6407a5 100644 --- a/src/__tests__/site-apps.ts +++ b/src/__tests__/site-apps.ts @@ -224,7 +224,7 @@ describe('SiteApps', () => { describe('afterDecideResponse', () => { it('sets loaded to true and enabled to false when response is undefined', () => { - siteAppsInstance.afterDecideResponse(undefined) + siteAppsInstance.onRemoteConfig(undefined) expect(siteAppsInstance.loaded).toBe(true) expect(siteAppsInstance.enabled).toBe(false) @@ -240,7 +240,7 @@ describe('SiteApps', () => { ], } as DecideResponse - siteAppsInstance.afterDecideResponse(response) + siteAppsInstance.onRemoteConfig(response) expect(siteAppsInstance.appsLoading.size).toBe(2) expect(siteAppsInstance.loaded).toBe(false) @@ -261,7 +261,7 @@ describe('SiteApps', () => { siteApps: [{ id: '1', url: '/site_app/1' }], } as DecideResponse - siteAppsInstance.afterDecideResponse(response) + siteAppsInstance.onRemoteConfig(response) expect(siteAppsInstance.loaded).toBe(true) expect(siteAppsInstance.enabled).toBe(false) @@ -276,7 +276,7 @@ describe('SiteApps', () => { siteApps: [{ id: '1', url: '/site_app/1' }], } as DecideResponse - siteAppsInstance.afterDecideResponse(response) + siteAppsInstance.onRemoteConfig(response) // Wait for the simulated async loading to complete setTimeout(() => { @@ -293,7 +293,7 @@ describe('SiteApps', () => { siteApps: [{ id: '1', url: '/site_app/1' }], } as DecideResponse - siteAppsInstance.afterDecideResponse(response) + siteAppsInstance.onRemoteConfig(response) expect(assignableWindow['__$$ph_site_app_1']).toBe(posthog) expect(typeof assignableWindow['__$$ph_site_app_1_missed_invocations']).toBe('function') @@ -312,7 +312,7 @@ describe('SiteApps', () => { siteApps: [{ id: '1', url: '/site_app/1' }], } as DecideResponse - siteAppsInstance.afterDecideResponse(response) + siteAppsInstance.onRemoteConfig(response) expect(logger.error).toHaveBeenCalledWith( 'PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.' @@ -327,7 +327,7 @@ describe('SiteApps', () => { siteApps: [], } as DecideResponse - siteAppsInstance.afterDecideResponse(response) + siteAppsInstance.onRemoteConfig(response) expect(siteAppsInstance.loaded).toBe(true) expect(siteAppsInstance.enabled).toBe(false) diff --git a/src/__tests__/web-experiments.test.ts b/src/__tests__/web-experiments.test.ts index 8e9ace341..3f504c30d 100644 --- a/src/__tests__/web-experiments.test.ts +++ b/src/__tests__/web-experiments.test.ts @@ -1,6 +1,6 @@ import { WebExperiments } from '../web-experiments' import { PostHog } from '../posthog-core' -import { DecideResponse, PostHogConfig } from '../types' +import { PostHogConfig } from '../types' import { PostHogPersistence } from '../posthog-persistence' import { WebExperiment } from '../web-experiments-types' import { RequestRouter } from '../utils/request-router' @@ -111,7 +111,10 @@ describe('Web Experimentation', () => { }, } as unknown as WebExperiment + const simulateFeatureFlags: jest.Mock = jest.fn() + beforeEach(() => { + let cachedFlags = {} persistence = { props: {}, register: jest.fn() } as unknown as PostHogPersistence posthog = makePostHog({ config: { @@ -128,6 +131,15 @@ describe('Web Experimentation', () => { .fn() .mockImplementation(({ callback }) => callback({ statusCode: 200, json: experimentsResponse })), consent: { isOptedOut: () => true } as unknown as ConsentManager, + onFeatureFlags: jest.fn(), + getFeatureFlag: (key: string) => { + return cachedFlags[key] + }, + }) + + simulateFeatureFlags.mockImplementation((flags) => { + cachedFlags = flags + webExperiment.onFeatureFlags(Object.keys(flags)) }) posthog.requestRouter = new RequestRouter(posthog) @@ -170,11 +182,10 @@ describe('Web Experimentation', () => { function assertElementChanged(variant: string, expectedProperty: string, value: string) { const elParent = createTestDocument() webExperiment = new WebExperiments(posthog) - webExperiment.afterDecideResponse({ - featureFlags: { - 'signup-button-test': variant, - }, - } as unknown as DecideResponse) + + simulateFeatureFlags({ + 'signup-button-test': variant, + }) switch (expectedProperty) { case 'css': @@ -200,11 +211,10 @@ describe('Web Experimentation', () => { webExperiment._is_bot = () => true const elParent = createTestDocument() - webExperiment.afterDecideResponse({ - featureFlags: { - 'signup-button-test': 'Sign me up', - }, - } as unknown as DecideResponse) + simulateFeatureFlags({ + 'signup-button-test': 'Sign me up', + }) + expect(elParent.innerText).toEqual('original') }) }) @@ -262,6 +272,7 @@ describe('Web Experimentation', () => { .fn() .mockImplementation(({ callback }) => callback({ statusCode: 200, json: expResponse })), consent: { isOptedOut: () => true } as unknown as ConsentManager, + onFeatureFlags: jest.fn(), }) posthog.requestRouter = new RequestRouter(disabledPostHog) diff --git a/src/autocapture.ts b/src/autocapture.ts index a4cb4078f..59feb9172 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -15,7 +15,7 @@ import { splitClassString, } from './autocapture-utils' import RageClick from './extensions/rageclick' -import { AutocaptureConfig, COPY_AUTOCAPTURE_EVENT, DecideResponse, EventName, Properties } from './types' +import { AutocaptureConfig, COPY_AUTOCAPTURE_EVENT, EventName, Properties, RemoteConfig } from './types' import { PostHog } from './posthog-core' import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from './constants' @@ -286,7 +286,7 @@ export class Autocapture { } } - public afterDecideResponse(response: DecideResponse) { + public onRemoteConfig(response: RemoteConfig) { if (response.elementsChainAsString) { this._elementsChainAsString = response.elementsChainAsString } diff --git a/src/decide.ts b/src/decide.ts index 88c7ab92d..e91c04e79 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -1,9 +1,9 @@ import { PostHog } from './posthog-core' -import { Compression, DecideResponse } from './types' +import { Compression, DecideResponse, RemoteConfig } from './types' import { STORED_GROUP_PROPERTIES_KEY, STORED_PERSON_PROPERTIES_KEY } from './constants' import { logger } from './utils/logger' -import { document } from './utils/globals' +import { assignableWindow, document } from './utils/globals' export class Decide { constructor(private readonly instance: PostHog) { @@ -11,7 +11,73 @@ export class Decide { this.instance.decideEndpointWasHit = 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. */ @@ -63,6 +129,31 @@ export class Decide { return } - this.instance._afterDecideResponse(response) + 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/entrypoints/external-scripts-loader.ts b/src/entrypoints/external-scripts-loader.ts index 084b47d82..32489b25d 100644 --- a/src/entrypoints/external-scripts-loader.ts +++ b/src/entrypoints/external-scripts-loader.ts @@ -43,6 +43,10 @@ assignableWindow.__PosthogExtensions__.loadExternalDependency = ( ): void => { let scriptUrlToLoad = `/static/${kind}.js` + `?v=${posthog.version}` + if (kind === 'remote-config') { + scriptUrlToLoad = `/array/${posthog.config.token}/config.js` + } + if (kind === 'toolbar') { // toolbar.js is served from the PostHog CDN, this has a TTL of 24 hours. // the toolbar asset includes a rotating "token" that is valid for 5 minutes. diff --git a/src/extensions/dead-clicks-autocapture.ts b/src/extensions/dead-clicks-autocapture.ts index 43aab7b46..1c4d1108f 100644 --- a/src/extensions/dead-clicks-autocapture.ts +++ b/src/extensions/dead-clicks-autocapture.ts @@ -3,7 +3,7 @@ import { DEAD_CLICKS_ENABLED_SERVER_SIDE } from '../constants' import { isBoolean, isObject } from '../utils/type-utils' import { assignableWindow, document, LazyLoadedDeadClicksAutocaptureInterface } from '../utils/globals' import { logger } from '../utils/logger' -import { DeadClicksAutoCaptureConfig, DecideResponse } from '../types' +import { DeadClicksAutoCaptureConfig, RemoteConfig } from '../types' const LOGGER_PREFIX = '[Dead Clicks]' @@ -31,7 +31,7 @@ export class DeadClicksAutocapture { this.startIfEnabled() } - public afterDecideResponse(response: DecideResponse) { + public onRemoteConfig(response: RemoteConfig) { if (this.instance.persistence) { this.instance.persistence.register({ [DEAD_CLICKS_ENABLED_SERVER_SIDE]: response?.captureDeadClicks, diff --git a/src/extensions/exception-autocapture/index.ts b/src/extensions/exception-autocapture/index.ts index 6f278dde9..71e683cb0 100644 --- a/src/extensions/exception-autocapture/index.ts +++ b/src/extensions/exception-autocapture/index.ts @@ -1,6 +1,6 @@ import { assignableWindow, window } from '../../utils/globals' import { PostHog } from '../../posthog-core' -import { DecideResponse, Properties } from '../../types' +import { Properties, RemoteConfig } from '../../types' import { logger } from '../../utils/logger' import { EXCEPTION_CAPTURE_ENABLED_SERVER_SIDE } from '../../constants' @@ -86,7 +86,7 @@ export class ExceptionObserver { this.unwrapUnhandledRejection?.() } - afterDecideResponse(response: DecideResponse) { + onRemoteConfig(response: RemoteConfig) { const autocaptureExceptionsResponse = response.autocaptureExceptions // store this in-memory in case persistence is disabled diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 195579786..f36cbfea3 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -21,11 +21,11 @@ import { import { PostHog } from '../../posthog-core' import { CaptureResult, - DecideResponse, FlagVariant, NetworkRecordOptions, NetworkRequest, Properties, + RemoteConfig, SessionRecordingUrlTrigger, } from '../../types' import { @@ -617,8 +617,8 @@ export class SessionRecording { }) } - afterDecideResponse(response: DecideResponse) { - this._persistDecideResponse(response) + onRemoteConfig(response: RemoteConfig) { + this._persistRemoteConfig(response) this._linkedFlag = response.sessionRecording?.linkedFlag || null @@ -671,7 +671,7 @@ export class SessionRecording { } } - private _persistDecideResponse(response: DecideResponse): void { + private _persistRemoteConfig(response: RemoteConfig): void { if (this.instance.persistence) { const persistence = this.instance.persistence diff --git a/src/extensions/web-vitals/index.ts b/src/extensions/web-vitals/index.ts index 7b9bf1937..ccea6a4df 100644 --- a/src/extensions/web-vitals/index.ts +++ b/src/extensions/web-vitals/index.ts @@ -1,5 +1,5 @@ import { PostHog } from '../../posthog-core' -import { DecideResponse, SupportedWebVitalsMetrics } from '../../types' +import { RemoteConfig, SupportedWebVitalsMetrics } from '../../types' import { logger } from '../../utils/logger' import { isBoolean, isNullish, isNumber, isObject, isUndefined } from '../../utils/type-utils' import { WEB_VITALS_ALLOWED_METRICS, WEB_VITALS_ENABLED_SERVER_SIDE } from '../../constants' @@ -70,7 +70,7 @@ export class WebVitalsAutocapture { } } - public afterDecideResponse(response: DecideResponse) { + public onRemoteConfig(response: RemoteConfig) { const webVitalsOptIn = isObject(response.capturePerformance) && !!response.capturePerformance.web_vitals const allowedMetrics = isObject(response.capturePerformance) diff --git a/src/heatmaps.ts b/src/heatmaps.ts index 5ed1a41ef..e94400220 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -1,6 +1,6 @@ import { includes, registerEvent } from './utils' import RageClick from './extensions/rageclick' -import { DeadClickCandidate, DecideResponse, Properties } from './types' +import { DeadClickCandidate, Properties, RemoteConfig } from './types' import { PostHog } from './posthog-core' import { document, window } from './utils/globals' @@ -99,7 +99,7 @@ export class Heatmaps { } } - public afterDecideResponse(response: DecideResponse) { + public onRemoteConfig(response: RemoteConfig) { const optIn = !!response['heatmaps'] if (this.instance.persistence) { diff --git a/src/posthog-core.ts b/src/posthog-core.ts index c7594073e..920709306 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -32,7 +32,6 @@ import { CaptureOptions, CaptureResult, Compression, - DecideResponse, EarlyAccessFeatureCallback, EventName, IsFeatureEnabledOptions, @@ -41,6 +40,7 @@ import { Properties, Property, QueuedRequestOptions, + RemoteConfig, RequestCallback, SessionIdChangedCallback, SnippetArrayItem, @@ -544,38 +544,36 @@ export class PostHog { return this } - // Private methods - _afterDecideResponse(response: DecideResponse) { + _onRemoteConfig(config: RemoteConfig) { this.compression = undefined - if (response.supportedCompression && !this.config.disable_compression) { - this.compression = includes(response['supportedCompression'], Compression.GZipJS) + if (config.supportedCompression && !this.config.disable_compression) { + this.compression = includes(config['supportedCompression'], Compression.GZipJS) ? Compression.GZipJS - : includes(response['supportedCompression'], Compression.Base64) + : includes(config['supportedCompression'], Compression.Base64) ? Compression.Base64 : undefined } - if (response.analytics?.endpoint) { - this.analyticsDefaultEndpoint = response.analytics.endpoint + if (config.analytics?.endpoint) { + this.analyticsDefaultEndpoint = config.analytics.endpoint } this.set_config({ person_profiles: this._initialPersonProfilesConfig ? this._initialPersonProfilesConfig - : response['defaultIdentifiedOnly'] + : config['defaultIdentifiedOnly'] ? 'identified_only' : 'always', }) - this.siteApps?.afterDecideResponse(response) - this.sessionRecording?.afterDecideResponse(response) - this.autocapture?.afterDecideResponse(response) - this.heatmaps?.afterDecideResponse(response) - this.experiments?.afterDecideResponse(response) - this.surveys?.afterDecideResponse(response) - this.webVitalsAutocapture?.afterDecideResponse(response) - this.exceptionObserver?.afterDecideResponse(response) - this.deadClicksAutocapture?.afterDecideResponse(response) + this.siteApps?.onRemoteConfig(config) + this.sessionRecording?.onRemoteConfig(config) + this.autocapture?.onRemoteConfig(config) + this.heatmaps?.onRemoteConfig(config) + this.surveys?.onRemoteConfig(config) + this.webVitalsAutocapture?.onRemoteConfig(config) + this.exceptionObserver?.onRemoteConfig(config) + this.deadClicksAutocapture?.onRemoteConfig(config) } _loaded(): void { @@ -605,16 +603,7 @@ export class PostHog { }, 1) } - // 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 (!disableDecide) { - new Decide(this).call() - - // TRICKY: Reset any decide reloads queued during config.loaded because they'll be - // covered by the decide call right above. - this.featureFlags.resetRequestQueue() - } + new Decide(this).call() } _start_queue_if_opted_in(): void { diff --git a/src/posthog-surveys.ts b/src/posthog-surveys.ts index 4b25ef9bb..9476bb936 100644 --- a/src/posthog-surveys.ts +++ b/src/posthog-surveys.ts @@ -10,7 +10,7 @@ import { import { isUrlMatchingRegex } from './utils/request-utils' import { SurveyEventReceiver } from './utils/survey-event-receiver' import { assignableWindow, document, window } from './utils/globals' -import { DecideResponse } from './types' +import { RemoteConfig } from './types' import { logger } from './utils/logger' import { isNullish } from './utils/type-utils' import { getSurveySeenStorageKeys } from './extensions/surveys/surveys-utils' @@ -69,7 +69,7 @@ export class PostHogSurveys { this._surveyEventReceiver = null } - afterDecideResponse(response: DecideResponse) { + onRemoteConfig(response: RemoteConfig) { this._decideServerResponse = !!response['surveys'] this.loadIfEnabled() } diff --git a/src/site-apps.ts b/src/site-apps.ts index 9bb507e34..63586b0d3 100644 --- a/src/site-apps.ts +++ b/src/site-apps.ts @@ -1,5 +1,5 @@ import { PostHog } from './posthog-core' -import { CaptureResult, DecideResponse } from './types' +import { CaptureResult, RemoteConfig } from './types' import { assignableWindow } from './utils/globals' import { logger } from './utils/logger' import { isArray } from './utils/type-utils' @@ -74,7 +74,7 @@ export class SiteApps { return globals } - afterDecideResponse(response?: DecideResponse): void { + onRemoteConfig(response?: RemoteConfig): void { if (isArray(response?.siteApps) && response.siteApps.length > 0) { if (this.enabled && this.instance.config.opt_in_site_apps) { const checkIfAllLoaded = () => { diff --git a/src/types.ts b/src/types.ts index c52b3d6e1..e821a2a8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -338,6 +338,11 @@ export interface PostHogConfig { * whether to wrap fetch and add tracing headers to the request * */ __add_tracing_headers?: boolean + + /** + * PREVIEW - MAY CHANGE WITHOUT WARNING - DO NOT USE IN PRODUCTION + * */ + __preview_remote_config?: boolean } export interface OptInOutCapturingOptions { @@ -480,11 +485,8 @@ export type SessionRecordingCanvasOptions = { canvasQuality?: string | null } -export interface DecideResponse { +export interface RemoteConfig { supportedCompression: Compression[] - featureFlags: Record - featureFlagPayloads: Record - errorsWhileComputingFlags: boolean autocapture_opt_out?: boolean /** * originally capturePerformance was replay only and so boolean true @@ -528,6 +530,13 @@ export interface DecideResponse { heatmaps?: boolean defaultIdentifiedOnly?: boolean captureDeadClicks?: boolean + hasFeatureFlags?: boolean // Indicates if the team has any flags enabled (if not we don't need to load them) +} + +export interface DecideResponse extends RemoteConfig { + featureFlags: Record + featureFlagPayloads: Record + errorsWhileComputingFlags: boolean } export type FeatureFlagsCallback = ( diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 377fe22b5..23ca7a1ba 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -1,7 +1,7 @@ import { ErrorProperties } from '../extensions/exception-autocapture/error-conversion' import type { PostHog } from '../posthog-core' import { SessionIdManager } from '../sessionid' -import { DeadClicksAutoCaptureConfig, ErrorEventArgs, ErrorMetadata, Properties } from '../types' +import { DeadClicksAutoCaptureConfig, ErrorEventArgs, ErrorMetadata, Properties, RemoteConfig } from '../types' /* * Global helpers to protect access to browser globals in a way that is safer for different targets @@ -20,6 +20,8 @@ export type AssignableWindow = Window & typeof globalThis & Record & { __PosthogExtensions__?: PostHogExtensions + _POSTHOG_CONFIG?: RemoteConfig + _POSTHOG_SITE_APPS?: { token: string; load: (posthog: PostHog) => void }[] } /** @@ -35,6 +37,7 @@ export type PostHogExtensionKind = | 'tracing-headers' | 'surveys' | 'dead-clicks-autocapture' + | 'remote-config' export interface LazyLoadedDeadClicksAutocaptureInterface { start: (observerTarget: Node) => void diff --git a/src/web-experiments.ts b/src/web-experiments.ts index 20b4bfd53..74bbc904b 100644 --- a/src/web-experiments.ts +++ b/src/web-experiments.ts @@ -1,5 +1,4 @@ import { PostHog } from './posthog-core' -import { DecideResponse } from './types' import { navigator, window } from './utils/globals' import { WebExperiment, @@ -9,7 +8,7 @@ import { WebExperimentVariant, } from './web-experiments-types' import { WEB_EXPERIMENTS } from './constants' -import { isNullish } from './utils/type-utils' +import { isNullish, isString } from './utils/type-utils' import { getQueryParam, isUrlMatchingRegex } from './utils/request-utils' import { logger } from './utils/logger' import { Info } from './utils/event-utils' @@ -30,24 +29,29 @@ export const webExperimentUrlValidationMap: Record< } export class WebExperiments { - instance: PostHog - private _featureFlags?: Record private _flagToExperiments?: Map - constructor(instance: PostHog) { - this.instance = instance - const appFeatureFLags = (flags: string[]) => { - this.applyFeatureFlagChanges(flags) + constructor(private instance: PostHog) { + this.instance.onFeatureFlags((flags: string[]) => { + this.onFeatureFlags(flags) + }) + } + + onFeatureFlags(flags: string[]) { + if (this._is_bot()) { + WebExperiments.logInfo('Refusing to render web experiment since the viewer is a likely bot') + return } - if (this.instance.onFeatureFlags) { - this.instance.onFeatureFlags(appFeatureFLags) + if (this.instance.config.disable_web_experiments) { + return } - this._flagToExperiments = new Map() - } - applyFeatureFlagChanges(flags: string[]) { - if (isNullish(this._flagToExperiments) || this.instance.config.disable_web_experiments) { + if (isNullish(this._flagToExperiments)) { + // Indicates first load so we trigger the loaders + this._flagToExperiments = new Map() + this.loadIfEnabled() + this.previewWebExperiment() return } @@ -67,17 +71,6 @@ export class WebExperiments { }) } - afterDecideResponse(response: DecideResponse) { - if (this._is_bot()) { - WebExperiments.logInfo('Refusing to render web experiment since the viewer is a likely bot') - return - } - - this._featureFlags = response.featureFlags - this.loadIfEnabled() - this.previewWebExperiment() - } - previewWebExperiment() { const location = WebExperiments.getWindowLocation() if (location?.search) { @@ -110,11 +103,7 @@ export class WebExperiments { this._flagToExperiments = new Map() webExperiments.forEach((webExperiment) => { - if ( - webExperiment.feature_flag_key && - this._featureFlags && - this._featureFlags[webExperiment.feature_flag_key] - ) { + if (webExperiment.feature_flag_key) { if (this._flagToExperiments) { WebExperiments.logInfo( `setting flag key `, @@ -125,8 +114,8 @@ export class WebExperiments { this._flagToExperiments?.set(webExperiment.feature_flag_key, webExperiment) } - const selectedVariant = this._featureFlags[webExperiment.feature_flag_key] as unknown as string - if (selectedVariant && webExperiment.variants[selectedVariant]) { + const selectedVariant = this.instance.getFeatureFlag(webExperiment.feature_flag_key) + if (isString(selectedVariant) && webExperiment.variants[selectedVariant]) { this.applyTransforms( webExperiment.name, selectedVariant,