diff --git a/playground/nextjs/src/posthog.ts b/playground/nextjs/src/posthog.ts index 6a4062a34..342904b9a 100644 --- a/playground/nextjs/src/posthog.ts +++ b/playground/nextjs/src/posthog.ts @@ -28,6 +28,7 @@ export const configForConsent = (): Partial => { return { persistence: consentGiven ? 'localStorage+cookie' : 'memory', disable_surveys: !consentGiven, + disable_site_apps_destinations: !consentGiven, autocapture: consentGiven, disable_session_recording: !consentGiven, } @@ -49,6 +50,7 @@ if (typeof window !== 'undefined') { session_recording: { recordCrossOriginIframes: true, }, + opt_in_site_apps: true, debug: true, disable_web_experiments: false, scroll_root_selector: ['#scroll_element', 'html'], diff --git a/src/__tests__/decide.ts b/src/__tests__/decide.ts index 534d64c65..026f6d0b5 100644 --- a/src/__tests__/decide.ts +++ b/src/__tests__/decide.ts @@ -1,7 +1,6 @@ import { Decide } from '../decide' import { PostHogPersistence } from '../posthog-persistence' import { RequestRouter } from '../utils/request-router' -import { expectScriptToExist, expectScriptToNotExist } from './helpers/script-utils' import { PostHog } from '../posthog-core' import { DecideResponse, PostHogConfig, Properties } from '../types' import '../entrypoints/external-scripts-loader' @@ -246,35 +245,5 @@ describe('Decide', () => { expect(posthog._afterDecideResponse).toHaveBeenCalledWith(decideResponse) expect(posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled() }) - - it('runs site apps if opted in', () => { - posthog.config = { - api_host: 'https://test.com', - opt_in_site_apps: true, - persistence: 'memory', - } as PostHogConfig - - subject({ siteApps: [{ id: 1, url: '/site_app/1/tokentoken/hash/' }] } as DecideResponse) - - expectScriptToExist('https://test.com/site_app/1/tokentoken/hash/') - }) - - it('does not run site apps code if not opted in', () => { - ;(window as any).POSTHOG_DEBUG = true - // don't technically need to run this but this test assumes opt_in_site_apps is false, let's make that explicit - posthog.config = { - api_host: 'https://test.com', - opt_in_site_apps: false, - persistence: 'memory', - } as unknown as PostHogConfig - - subject({ siteApps: [{ id: 1, url: '/site_app/1/tokentoken/hash/' }] } as DecideResponse) - - expect(console.error).toHaveBeenCalledWith( - '[PostHog.js]', - 'PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.' - ) - expectScriptToNotExist('https://test.com/site_app/1/tokentoken/hash/') - }) }) }) diff --git a/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts b/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts index b206acd62..07a51086c 100644 --- a/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts +++ b/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts @@ -228,6 +228,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { $dead_click_selection_changed_timeout: true, $ce_version: 1, $el_text: 'text', + $elements_chain: 'body:text="text"nth-child="2"nth-of-type="1"', $elements: [ { $el_text: 'text', @@ -270,6 +271,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { $dead_click_selection_changed_timeout: false, $ce_version: 1, $el_text: 'text', + $elements_chain: 'body:text="text"nth-child="2"nth-of-type="1"', $elements: [ { $el_text: 'text', @@ -312,6 +314,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { $dead_click_selection_changed_timeout: false, $ce_version: 1, $el_text: 'text', + $elements_chain: 'body:text="text"nth-child="2"nth-of-type="1"', $elements: [ { $el_text: 'text', @@ -354,6 +357,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { $dead_click_selection_changed_timeout: false, $ce_version: 1, $el_text: 'text', + $elements_chain: 'body:text="text"nth-child="2"nth-of-type="1"', $elements: [ { $el_text: 'text', diff --git a/src/__tests__/posthog-persistence.test.ts b/src/__tests__/posthog-persistence.test.ts index 086246e24..d837b8a2c 100644 --- a/src/__tests__/posthog-persistence.test.ts +++ b/src/__tests__/posthog-persistence.test.ts @@ -120,13 +120,13 @@ describe('persistence', () => { it('should migrate data from cookies to localStorage', () => { const lib = new PostHogPersistence(makePostHogConfig('bla', 'cookie')) lib.register_once({ distinct_id: 'testy', test_prop: 'test_value' }, undefined, undefined) - expect(document.cookie).toEqual( + expect(document.cookie).toContain( 'ph__posthog=%7B%22distinct_id%22%3A%22testy%22%2C%22test_prop%22%3A%22test_value%22%7D' ) const lib2 = new PostHogPersistence(makePostHogConfig('bla', 'localStorage+cookie')) - expect(document.cookie).toEqual('ph__posthog=%7B%22distinct_id%22%3A%22testy%22%7D') + expect(document.cookie).toContain('ph__posthog=%7B%22distinct_id%22%3A%22testy%22%7D') lib2.register({ test_prop2: 'test_val', distinct_id: 'test2' }) - expect(document.cookie).toEqual('ph__posthog=%7B%22distinct_id%22%3A%22test2%22%7D') + expect(document.cookie).toContain('ph__posthog=%7B%22distinct_id%22%3A%22test2%22%7D') expect(lib2.props).toEqual({ distinct_id: 'test2', test_prop: 'test_value', test_prop2: 'test_val' }) lib2.remove() expect(localStorage.getItem('ph__posthog')).toEqual(null) diff --git a/src/__tests__/site-apps.ts b/src/__tests__/site-apps.ts new file mode 100644 index 000000000..ec13259a1 --- /dev/null +++ b/src/__tests__/site-apps.ts @@ -0,0 +1,338 @@ +// __tests__/site-apps.ts + +import { SiteApps } from '../site-apps' +import { PostHogPersistence } from '../posthog-persistence' +import { RequestRouter } from '../utils/request-router' +import { PostHog } from '../posthog-core' +import { DecideResponse, PostHogConfig, Properties, CaptureResult } from '../types' +import { assignableWindow } from '../utils/globals' +import { logger } from '../utils/logger' +import '../entrypoints/external-scripts-loader' +import { isFunction } from '../utils/type-utils' + +jest.mock('../utils/logger', () => ({ + logger: { + error: jest.fn(), + }, +})) + +describe('SiteApps', () => { + let posthog: PostHog + let siteAppsInstance: SiteApps + + 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() + + // Reset assignableWindow properties + assignableWindow.__PosthogExtensions__ = { + loadSiteApp: jest.fn().mockImplementation((_instance, _url, callback) => { + // Simulate async loading + setTimeout(() => { + const id = _url.split('/').pop() + if (isFunction(assignableWindow[`__$$ph_site_app_${id}_callback`])) { + assignableWindow[`__$$ph_site_app_${id}_callback`]() + } + callback() + }, 0) + }), + } + + 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(), + _afterDecideResponse: jest.fn(), + get_distinct_id: jest.fn().mockImplementation(() => 'distinctid'), + _send_request: jest.fn().mockImplementation(({ callback }) => callback?.({ config: {} })), + featureFlags: { + 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 + + siteAppsInstance = new SiteApps(posthog) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('constructor', () => { + it('sets enabled to true when opt_in_site_apps is true and advanced_disable_decide is false', () => { + posthog.config = { + ...defaultConfig, + opt_in_site_apps: true, + advanced_disable_decide: false, + } as PostHogConfig + + siteAppsInstance = new SiteApps(posthog) + + expect(siteAppsInstance.enabled).toBe(true) + }) + + it('sets enabled to false when opt_in_site_apps is false', () => { + posthog.config = { + ...defaultConfig, + opt_in_site_apps: false, + advanced_disable_decide: false, + } as PostHogConfig + + siteAppsInstance = new SiteApps(posthog) + + expect(siteAppsInstance.enabled).toBe(false) + }) + + it('sets enabled to false when advanced_disable_decide is true', () => { + posthog.config = { + ...defaultConfig, + opt_in_site_apps: true, + advanced_disable_decide: true, + } as PostHogConfig + + siteAppsInstance = new SiteApps(posthog) + + expect(siteAppsInstance.enabled).toBe(false) + }) + + it('initializes missedInvocations, loaded, appsLoading correctly', () => { + expect(siteAppsInstance.missedInvocations).toEqual([]) + expect(siteAppsInstance.loaded).toBe(false) + expect(siteAppsInstance.appsLoading).toEqual(new Set()) + }) + }) + + describe('init', () => { + it('adds eventCollector as a capture hook', () => { + siteAppsInstance.init() + + expect(posthog._addCaptureHook).toHaveBeenCalledWith(expect.any(Function)) + }) + }) + + describe('eventCollector', () => { + it('does nothing if enabled is false', () => { + siteAppsInstance.enabled = false + siteAppsInstance.eventCollector('event_name', {} as CaptureResult) + + expect(siteAppsInstance.missedInvocations.length).toBe(0) + }) + + it('collects event if enabled and loaded is false', () => { + siteAppsInstance.enabled = true + siteAppsInstance.loaded = false + + const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as CaptureResult + + jest.spyOn(siteAppsInstance, 'globalsForEvent').mockReturnValue({ some: 'globals' }) + + siteAppsInstance.eventCollector('test_event', eventPayload) + + expect(siteAppsInstance.globalsForEvent).toHaveBeenCalledWith(eventPayload) + expect(siteAppsInstance.missedInvocations).toEqual([{ some: 'globals' }]) + }) + + it('trims missedInvocations to last 990 when exceeding 1000', () => { + siteAppsInstance.enabled = true + siteAppsInstance.loaded = false + + siteAppsInstance.missedInvocations = new Array(1000).fill({}) + + const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as CaptureResult + + jest.spyOn(siteAppsInstance, 'globalsForEvent').mockReturnValue({ some: 'globals' }) + + siteAppsInstance.eventCollector('test_event', eventPayload) + + expect(siteAppsInstance.missedInvocations.length).toBe(991) + expect(siteAppsInstance.missedInvocations[0]).toEqual({}) + expect(siteAppsInstance.missedInvocations[990]).toEqual({ some: 'globals' }) + }) + }) + + describe('globalsForEvent', () => { + it('throws an error if event is undefined', () => { + expect(() => siteAppsInstance.globalsForEvent(undefined as any)).toThrow('Event payload is required') + }) + + it('constructs globals object correctly', () => { + jest.spyOn(posthog, 'get_property').mockImplementation((key) => { + if (key === '$groups') { + return { groupType: 'groupId' } + } else if (key === '$stored_group_properties') { + return { groupType: { prop1: 'value1' } } + } else if (key === '$stored_person_properties') { + return { personProp: 'personValue' } + } + }) + + const eventPayload = { + uuid: 'test_uuid', + event: 'test_event', + properties: { + prop1: 'value1', + distinct_id: 'test_distinct_id', + $elements_chain: 'elements_chain_value', + }, + $set: { setProp: 'setValue' }, + $set_once: { setOnceProp: 'setOnceValue' }, + } as CaptureResult + + const globals = siteAppsInstance.globalsForEvent(eventPayload) + + expect(globals).toEqual({ + event: { + uuid: 'test_uuid', + event: 'test_event', + properties: { + $elements_chain: 'elements_chain_value', + prop1: 'value1', + distinct_id: 'test_distinct_id', + $set: { setProp: 'setValue' }, + $set_once: { setOnceProp: 'setOnceValue' }, + }, + elements_chain: 'elements_chain_value', + distinct_id: 'test_distinct_id', + }, + person: { + properties: { personProp: 'personValue' }, + }, + groups: { + groupType: { + id: 'groupId', + type: 'groupType', + properties: { prop1: 'value1' }, + }, + }, + }) + }) + }) + + describe('afterDecideResponse', () => { + it('sets loaded to true and enabled to false when response is undefined', () => { + siteAppsInstance.afterDecideResponse(undefined) + + expect(siteAppsInstance.loaded).toBe(true) + expect(siteAppsInstance.enabled).toBe(false) + }) + + it('loads site apps when enabled and opt_in_site_apps is true', (done) => { + posthog.config.opt_in_site_apps = true + siteAppsInstance.enabled = true + const response = { + siteApps: [ + { id: '1', url: '/site_app/1' }, + { id: '2', url: '/site_app/2' }, + ], + } as DecideResponse + + siteAppsInstance.afterDecideResponse(response) + + expect(siteAppsInstance.appsLoading.size).toBe(2) + expect(siteAppsInstance.loaded).toBe(false) + + // Wait for the simulated async loading to complete + setTimeout(() => { + expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).toHaveBeenCalledTimes(2) + expect(siteAppsInstance.appsLoading.size).toBe(0) + expect(siteAppsInstance.loaded).toBe(true) + done() + }, 10) + }) + + it('does not load site apps when enabled is false', () => { + siteAppsInstance.enabled = false + posthog.config.opt_in_site_apps = false + const response = { + siteApps: [{ id: '1', url: '/site_app/1' }], + } as DecideResponse + + siteAppsInstance.afterDecideResponse(response) + + expect(siteAppsInstance.loaded).toBe(true) + expect(siteAppsInstance.enabled).toBe(false) + expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).not.toHaveBeenCalled() + }) + + it('clears missedInvocations when all apps are loaded', (done) => { + posthog.config.opt_in_site_apps = true + siteAppsInstance.enabled = true + siteAppsInstance.missedInvocations = [{ some: 'data' }] + const response = { + siteApps: [{ id: '1', url: '/site_app/1' }], + } as DecideResponse + + siteAppsInstance.afterDecideResponse(response) + + // Wait for the simulated async loading to complete + setTimeout(() => { + expect(siteAppsInstance.loaded).toBe(true) + expect(siteAppsInstance.missedInvocations).toEqual([]) + done() + }, 10) + }) + + it('sets assignableWindow properties for each site app', () => { + posthog.config.opt_in_site_apps = true + siteAppsInstance.enabled = true + const response = { + siteApps: [{ id: '1', url: '/site_app/1' }], + } as DecideResponse + + siteAppsInstance.afterDecideResponse(response) + + expect(assignableWindow['__$$ph_site_app_1_posthog']).toBe(posthog) + expect(typeof assignableWindow['__$$ph_site_app_1_missed_invocations']).toBe('function') + expect(typeof assignableWindow['__$$ph_site_app_1_callback']).toBe('function') + expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).toHaveBeenCalledWith( + posthog, + '/site_app/1', + expect.any(Function) + ) + }) + + it('logs error if site apps are disabled but response contains site apps', () => { + posthog.config.opt_in_site_apps = false + siteAppsInstance.enabled = false + const response = { + siteApps: [{ id: '1', url: '/site_app/1' }], + } as DecideResponse + + siteAppsInstance.afterDecideResponse(response) + + expect(logger.error).toHaveBeenCalledWith( + 'PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.' + ) + expect(siteAppsInstance.loaded).toBe(true) + }) + + it('sets loaded to true if response.siteApps is empty', () => { + siteAppsInstance.enabled = true + posthog.config.opt_in_site_apps = true + const response = { + siteApps: [], + } as DecideResponse + + siteAppsInstance.afterDecideResponse(response) + + expect(siteAppsInstance.loaded).toBe(true) + expect(siteAppsInstance.enabled).toBe(false) + }) + }) +}) diff --git a/src/autocapture.ts b/src/autocapture.ts index cf9289e3a..a4cb4078f 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -213,13 +213,10 @@ export function autocapturePropertiesForElement( const props = extend( getDefaultProperties(e.type), - elementsChainAsString - ? { - $elements_chain: getElementsChainString(elementsJson), - } - : { - $elements: elementsJson, - }, + // Sending "$elements" is deprecated. Only one client on US cloud uses this. + !elementsChainAsString ? { $elements: elementsJson } : {}, + // Always send $elements_chain, as it's needed downstream in site app filtering + { $elements_chain: getElementsChainString(elementsJson) }, elementsJson[0]?.['$el_text'] ? { $el_text: elementsJson[0]?.['$el_text'] } : {}, externalHref && e.type === 'click' ? { $external_click_url: externalHref } : {}, autocaptureAugmentProperties diff --git a/src/decide.ts b/src/decide.ts index 9ccb59819..88c7ab92d 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -3,7 +3,7 @@ import { Compression, DecideResponse } from './types' import { STORED_GROUP_PROPERTIES_KEY, STORED_PERSON_PROPERTIES_KEY } from './constants' import { logger } from './utils/logger' -import { document, assignableWindow } from './utils/globals' +import { document } from './utils/globals' export class Decide { constructor(private readonly instance: PostHog) { @@ -64,20 +64,5 @@ export class Decide { } this.instance._afterDecideResponse(response) - - if (response['siteApps']) { - if (this.instance.config.opt_in_site_apps) { - for (const { id, url } of response['siteApps']) { - assignableWindow[`__$$ph_site_app_${id}`] = this.instance - assignableWindow.__PosthogExtensions__?.loadSiteApp?.(this.instance, url, (err) => { - if (err) { - return logger.error(`Error while initializing PostHog app with config id ${id}`, err) - } - }) - } - } else if (response['siteApps'].length > 0) { - logger.error('PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.') - } - } } } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index db362d2af..1e2617f63 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -80,6 +80,7 @@ import { ExceptionObserver } from './extensions/exception-autocapture' import { WebVitalsAutocapture } from './extensions/web-vitals' import { WebExperiments } from './web-experiments' import { PostHogExceptions } from './posthog-exceptions' +import { SiteApps } from './site-apps' import { DeadClicksAutocapture, isDeadClicksEnabledForAutocapture } from './extensions/dead-clicks-autocapture' /* @@ -144,6 +145,7 @@ export const defaultConfig = (): PostHogConfig => ({ disable_persistence: false, disable_web_experiments: true, // disabled in beta. disable_surveys: false, + disable_site_apps_destinations: false, enable_recording_console_log: undefined, // When undefined, it falls back to the server-side setting secure_cookie: window?.location?.protocol === 'https:', ip: true, @@ -258,6 +260,7 @@ export class PostHog { sessionManager?: SessionIdManager sessionPropsManager?: SessionPropsManager requestRouter: RequestRouter + siteApps?: SiteApps autocapture?: Autocapture heatmaps?: Heatmaps webVitalsAutocapture?: WebVitalsAutocapture @@ -306,6 +309,7 @@ export class PostHog { this.scrollManager = new ScrollManager(this) this.pageViewManager = new PageViewManager(this) this.surveys = new PostHogSurveys(this) + this.siteApps = new SiteApps(this) this.experiments = new WebExperiments(this) this.exceptions = new PostHogExceptions(this) this.rateLimiter = new RateLimiter(this) @@ -432,6 +436,8 @@ export class PostHog { new TracingHeaders(this).startIfEnabledOrStop() + this.siteApps?.init() + this.sessionRecording = new SessionRecording(this) this.sessionRecording.startIfEnabledOrStop() @@ -562,6 +568,7 @@ export class PostHog { : 'always', }) + this.siteApps?.afterDecideResponse(response) this.sessionRecording?.afterDecideResponse(response) this.autocapture?.afterDecideResponse(response) this.heatmaps?.afterDecideResponse(response) @@ -1822,6 +1829,7 @@ export class PostHog { this.autocapture?.startIfEnabled() this.heatmaps?.startIfEnabled() this.surveys.loadIfEnabled() + this.siteApps?.loadIfEnabled() this._sync_opt_out_with_persistence() } } diff --git a/src/site-apps.ts b/src/site-apps.ts new file mode 100644 index 000000000..394cccf65 --- /dev/null +++ b/src/site-apps.ts @@ -0,0 +1,126 @@ +import { PostHog } from './posthog-core' +import { CaptureResult, DecideResponse } from './types' +import { assignableWindow } from './utils/globals' +import { logger } from './utils/logger' +import { isArray } from './utils/type-utils' + +export class SiteApps { + private _decideServerResponse?: DecideResponse['siteApps'] + missedInvocations: Record[] + loaded: boolean + appsLoading: Set + + constructor(private readonly instance: PostHog) { + // events captured between loading posthog-js and the site app; up to 1000 events + this.missedInvocations = [] + // capture events until loaded + this.loaded = false + this.appsLoading = new Set() + } + + eventCollector(_eventName: string, eventPayload?: CaptureResult | undefined) { + // can't use if site apps are disabled, or if we're not asking /decide for site apps + const enabled = this.instance.config.opt_in_site_apps && !this.instance.config.advanced_disable_decide + if (!enabled) { + return + } + if (!this.loaded && eventPayload) { + const globals = this.globalsForEvent(eventPayload) + this.missedInvocations.push(globals) + if (this.missedInvocations.length > 1000) { + this.missedInvocations = this.missedInvocations.slice(10) + } + } + } + + init() { + this.instance?._addCaptureHook(this.eventCollector.bind(this)) + } + + globalsForEvent(event: CaptureResult): Record { + if (!event) { + throw new Error('Event payload is required') + } + const groups: Record> = {} + const groupIds = this.instance.get_property('$groups') || [] + const groupProperties = this.instance.get_property('$stored_group_properties') || {} + for (const [type, properties] of Object.entries(groupProperties)) { + groups[type] = { id: groupIds[type], type, properties } + } + const { $set_once, $set, ..._event } = event + const globals = { + event: { + ..._event, + properties: { + ...event.properties, + ...($set ? { $set: { ...(event.properties?.$set ?? {}), ...$set } } : {}), + ...($set_once ? { $set_once: { ...(event.properties?.$set_once ?? {}), ...$set_once } } : {}), + }, + elements_chain: event.properties?.['$elements_chain'] ?? '', + // TODO: + // - elements_chain_href: '', + // - elements_chain_texts: [] as string[], + // - elements_chain_ids: [] as string[], + // - elements_chain_elements: [] as string[], + distinct_id: event.properties?.['distinct_id'], + }, + person: { + properties: this.instance.get_property('$stored_person_properties'), + }, + groups, + } + return globals + } + + loadIfEnabled() { + if ( + this._decideServerResponse && + isArray(this._decideServerResponse) && + this._decideServerResponse.length > 0 + ) { + // can't use if site apps are disabled, or if we're not asking /decide for site apps + const enabled = this.instance.config.opt_in_site_apps && !this.instance.config.advanced_disable_decide + if (enabled) { + const checkIfAllLoaded = () => { + // Stop collecting events once all site apps are loaded + if (this.appsLoading.size === 0) { + this.loaded = true + this.missedInvocations = [] + } + } + for (const { id, type, url } of this._decideServerResponse) { + if (this.instance.config.disable_site_apps_destinations && type === 'site_destination') continue + // if the site app is already loaded, skip it + // eslint-disable-next-line no-restricted-globals + if (`__$$ph_site_app_${id}_posthog` in window) continue + this.appsLoading.add(id) + assignableWindow[`__$$ph_site_app_${id}_posthog`] = this.instance + assignableWindow[`__$$ph_site_app_${id}_missed_invocations`] = () => this.missedInvocations + assignableWindow[`__$$ph_site_app_${id}_callback`] = () => { + this.appsLoading.delete(id) + checkIfAllLoaded() + } + assignableWindow.__PosthogExtensions__?.loadSiteApp?.(this.instance, url, (err) => { + if (err) { + this.appsLoading.delete(id) + checkIfAllLoaded() + return logger.error(`Error while initializing PostHog app with config id ${id}`, err) + } + }) + } + } else if (this._decideServerResponse.length > 0) { + logger.error('PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.') + this.loaded = true + } else { + this.loaded = true + } + } else { + this.loaded = true + } + } + + afterDecideResponse(response: DecideResponse): void { + this._decideServerResponse = response['siteApps'] + this.loadIfEnabled() + } +} diff --git a/src/types.ts b/src/types.ts index d7994968a..24a379791 100644 --- a/src/types.ts +++ b/src/types.ts @@ -248,6 +248,7 @@ export interface PostHogConfig { /** @deprecated - use `disable_persistence` instead */ disable_cookie?: boolean disable_surveys: boolean + disable_site_apps_destinations: boolean disable_web_experiments: boolean /** If set, posthog-js will never load external scripts such as those needed for Session Replay or Surveys. */ disable_external_dependency_loading?: boolean @@ -523,7 +524,7 @@ export interface DecideResponse { editorParams?: ToolbarParams /** @deprecated, renamed to toolbarParams, still present on older API responses */ toolbarVersion: 'toolbar' /** @deprecated, moved to toolbarParams */ isAuthenticated: boolean - siteApps: { id: number; url: string }[] + siteApps: { id: string; type: string; url: string }[] heatmaps?: boolean defaultIdentifiedOnly?: boolean captureDeadClicks?: boolean