From b16c6c8234353ce2a7225a087cb310b8f69fe168 Mon Sep 17 00:00:00 2001 From: Ben White Date: Mon, 9 Dec 2024 09:36:50 +0100 Subject: [PATCH] feat: Load site functions via RemoteConfig (#1580) --- playground/nextjs/src/posthog.ts | 4 +- src/__tests__/site-apps.ts | 338 +++++++++++++++++++------------ src/site-apps.ts | 192 ++++++++++++------ src/types.ts | 30 +++ src/utils/globals.ts | 11 +- 5 files changed, 377 insertions(+), 198 deletions(-) diff --git a/playground/nextjs/src/posthog.ts b/playground/nextjs/src/posthog.ts index 27ca2c9d6..89de3feb1 100644 --- a/playground/nextjs/src/posthog.ts +++ b/playground/nextjs/src/posthog.ts @@ -62,10 +62,12 @@ 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`, + opt_in_site_apps: true, __preview_remote_config: true, ...configForConsent(), }) - // Help with debugging(window as any).posthog = posthog + // Help with debugging + ;(window as any).posthog = posthog } export const posthogHelpers = { diff --git a/src/__tests__/site-apps.ts b/src/__tests__/site-apps.ts index a1f1d6bd1..9a2ae8d57 100644 --- a/src/__tests__/site-apps.ts +++ b/src/__tests__/site-apps.ts @@ -4,7 +4,7 @@ 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 { PostHogConfig, Properties, CaptureResult, RemoteConfig } from '../types' import { assignableWindow } from '../utils/globals' import '../entrypoints/external-scripts-loader' import { isFunction } from '../utils/type-utils' @@ -12,6 +12,8 @@ import { isFunction } from '../utils/type-utils' describe('SiteApps', () => { let posthog: PostHog let siteAppsInstance: SiteApps + let emitCaptureEvent: ((eventName: string, eventPayload: CaptureResult) => void) | undefined + let removeCaptureHook = jest.fn() const defaultConfig: Partial = { token: 'testtoken', @@ -23,7 +25,6 @@ describe('SiteApps', () => { // 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__ = { @@ -39,14 +40,22 @@ describe('SiteApps', () => { }), } + delete assignableWindow._POSTHOG_JS_APPS + delete assignableWindow.POSTHOG_DEBUG + + removeCaptureHook = jest.fn() + posthog = { - config: { ...defaultConfig }, + config: { ...defaultConfig, opt_in_site_apps: true }, 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(), + _addCaptureHook: jest.fn((cb) => { + emitCaptureEvent = cb + return removeCaptureHook + }), _afterDecideResponse: jest.fn(), get_distinct_id: jest.fn().mockImplementation(() => 'distinctid'), _send_request: jest.fn().mockImplementation(({ callback }) => callback?.({ config: {} })), @@ -58,6 +67,7 @@ describe('SiteApps', () => { requestRouter: new RequestRouter({ config: defaultConfig } as unknown as PostHog), _hasBootstrappedFeatureFlags: jest.fn(), getGroups: () => ({ organization: '5' }), + on: jest.fn(), } as unknown as PostHog siteAppsInstance = new SiteApps(posthog) @@ -68,94 +78,86 @@ describe('SiteApps', () => { }) describe('constructor', () => { - it('sets enabled to true when opt_in_site_apps is true and advanced_disable_decide is false', () => { + it('sets enabled to true when opt_in_site_apps is true', () => { posthog.config = { ...defaultConfig, opt_in_site_apps: true, - advanced_disable_decide: false, } as PostHogConfig - siteAppsInstance = new SiteApps(posthog) - - expect(siteAppsInstance.enabled).toBe(true) + expect(siteAppsInstance.isEnabled).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) + expect(siteAppsInstance.isEnabled).toBe(false) }) it('initializes missedInvocations, loaded, appsLoading correctly', () => { - expect(siteAppsInstance.missedInvocations).toEqual([]) - expect(siteAppsInstance.loaded).toBe(false) - expect(siteAppsInstance.appsLoading).toEqual(new Set()) + expect(siteAppsInstance['bufferedInvocations']).toEqual([]) + expect(siteAppsInstance.apps).toEqual({}) }) }) describe('init', () => { it('adds eventCollector as a capture hook', () => { + expect(siteAppsInstance['stopBuffering']).toBeUndefined() siteAppsInstance.init() expect(posthog._addCaptureHook).toHaveBeenCalledWith(expect.any(Function)) + expect(siteAppsInstance['stopBuffering']).toEqual(expect.any(Function)) }) - }) - describe('eventCollector', () => { - it('does nothing if enabled is false', () => { - siteAppsInstance.enabled = false - siteAppsInstance.eventCollector('event_name', {} as CaptureResult) + it('does not add eventCollector as a capture hook if disabled', () => { + posthog.config.opt_in_site_apps = false + siteAppsInstance.init() - expect(siteAppsInstance.missedInvocations.length).toBe(0) + expect(posthog._addCaptureHook).not.toHaveBeenCalled() + expect(siteAppsInstance['stopBuffering']).toBeUndefined() }) + }) - 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) + describe('eventCollector', () => { + beforeEach(() => { + siteAppsInstance.init() + }) - expect(siteAppsInstance.globalsForEvent).toHaveBeenCalledWith(eventPayload) - expect(siteAppsInstance.missedInvocations).toEqual([{ some: 'globals' }]) + it('collects events if enabled after init', () => { + emitCaptureEvent?.('test_event', { event: 'test_event', properties: { prop1: 'value1' } } as any) + + expect(siteAppsInstance['bufferedInvocations']).toMatchInlineSnapshot(` + Array [ + Object { + "event": Object { + "distinct_id": undefined, + "elements_chain": "", + "event": "test_event", + "properties": Object { + "prop1": "value1", + }, + }, + "groups": Object {}, + "person": Object { + "properties": undefined, + }, + }, + ] + `) }) 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['bufferedInvocations'] = new Array(1000).fill({}) - siteAppsInstance.eventCollector('test_event', eventPayload) + emitCaptureEvent?.('test_event', { event: 'test_event', properties: { prop1: 'value1' } } as any) - expect(siteAppsInstance.missedInvocations.length).toBe(991) - expect(siteAppsInstance.missedInvocations[0]).toEqual({}) - expect(siteAppsInstance.missedInvocations[990]).toEqual({ some: 'globals' }) + expect(siteAppsInstance['bufferedInvocations'].length).toBe(991) + expect(siteAppsInstance['bufferedInvocations'][0]).toEqual({}) + expect(siteAppsInstance['bufferedInvocations'][990]).toMatchObject({ event: { event: 'test_event' } }) }) }) @@ -217,115 +219,185 @@ describe('SiteApps', () => { }) }) - describe('afterDecideResponse', () => { - it('sets loaded to true and enabled to false when response is undefined', () => { - siteAppsInstance.onRemoteConfig(undefined) - - expect(siteAppsInstance.loaded).toBe(true) - expect(siteAppsInstance.enabled).toBe(false) + describe('legacy site apps loading', () => { + beforeEach(() => { + posthog.config.opt_in_site_apps = true + siteAppsInstance.init() }) - it('loads site apps when enabled and opt_in_site_apps is true', (done) => { + it('loads stops buffering if no site apps', () => { posthog.config.opt_in_site_apps = true - siteAppsInstance.enabled = true - const response = { + siteAppsInstance.onRemoteConfig({} as RemoteConfig) + + expect(removeCaptureHook).toHaveBeenCalled() + expect(siteAppsInstance['stopBuffering']).toBeUndefined() + expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).not.toHaveBeenCalled() + }) + + it('does not loads site apps if disabled', () => { + posthog.config.opt_in_site_apps = false + siteAppsInstance.onRemoteConfig({ siteApps: [ { id: '1', url: '/site_app/1' }, { id: '2', url: '/site_app/2' }, ], - } as DecideResponse + } as RemoteConfig) - siteAppsInstance.onRemoteConfig(response) + expect(removeCaptureHook).toHaveBeenCalled() + expect(siteAppsInstance['stopBuffering']).toBeUndefined() + expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).not.toHaveBeenCalled() + }) - expect(siteAppsInstance.appsLoading.size).toBe(2) - expect(siteAppsInstance.loaded).toBe(false) + it('does not load site apps if new global loader exists', () => { + assignableWindow._POSTHOG_JS_APPS = [] + siteAppsInstance.onRemoteConfig({ + siteApps: [{ id: '1', url: '/site_app/1' }], + } as RemoteConfig) - // 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) + expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).not.toHaveBeenCalled() }) - 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 + it('loads site apps if new global loader is not available', () => { + siteAppsInstance.onRemoteConfig({ + siteApps: [ + { id: '1', url: '/site_app/1' }, + { id: '2', url: '/site_app/2' }, + ], + } as RemoteConfig) - siteAppsInstance.onRemoteConfig(response) + expect(removeCaptureHook).toHaveBeenCalled() + expect(siteAppsInstance['stopBuffering']).toBeUndefined() + expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).toHaveBeenCalledTimes(2) + expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).toHaveBeenCalledWith( + posthog, + '/site_app/1', + expect.any(Function) + ) + expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).toHaveBeenCalledWith( + posthog, + '/site_app/2', + expect.any(Function) + ) + }) + }) - expect(siteAppsInstance.loaded).toBe(true) - expect(siteAppsInstance.enabled).toBe(false) - expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).not.toHaveBeenCalled() + describe('onRemoteConfig', () => { + let appConfigs: { + posthog: PostHog + callback: (success: boolean) => void + }[] = [] + + beforeEach(() => { + appConfigs = [] + assignableWindow._POSTHOG_JS_APPS = [ + { + id: '1', + init: jest.fn((config) => { + appConfigs.push(config) + return { + processEvent: jest.fn(), + } + }), + }, + { + id: '2', + init: jest.fn((config) => { + appConfigs.push(config) + return { + processEvent: jest.fn(), + } + }), + }, + ] + + siteAppsInstance.init() }) - 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 + it('sets up the eventCaptured listener if site apps', () => { + siteAppsInstance.onRemoteConfig({} as RemoteConfig) + expect(posthog.on).toHaveBeenCalledWith('eventCaptured', expect.any(Function)) + }) + + it('does not sets up the eventCaptured listener if no site apps', () => { + assignableWindow._POSTHOG_JS_APPS = [] + siteAppsInstance.onRemoteConfig({} as RemoteConfig) + expect(posthog.on).not.toHaveBeenCalled() + }) + + it('loads site apps via the window object if defined', () => { + siteAppsInstance.onRemoteConfig({} as RemoteConfig) + expect(appConfigs[0]).toBeDefined() + expect(siteAppsInstance.apps['1']).toEqual({ + errored: false, + loaded: false, + id: '1', + processEvent: expect.any(Function), + }) - siteAppsInstance.onRemoteConfig(response) + appConfigs[0].callback(true) - // Wait for the simulated async loading to complete - setTimeout(() => { - expect(siteAppsInstance.loaded).toBe(true) - expect(siteAppsInstance.missedInvocations).toEqual([]) - done() - }, 10) + expect(siteAppsInstance.apps['1']).toEqual({ + errored: false, + loaded: true, + id: '1', + processEvent: expect.any(Function), + }) }) - 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 + it('marks site app as errored if callback fails', () => { + siteAppsInstance.onRemoteConfig({} as RemoteConfig) + expect(appConfigs[0]).toBeDefined() + expect(siteAppsInstance.apps['1']).toMatchObject({ + errored: false, + loaded: false, + }) - siteAppsInstance.onRemoteConfig(response) + appConfigs[0].callback(false) - expect(assignableWindow['__$$ph_site_app_1']).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) + expect(siteAppsInstance.apps['1']).toMatchObject({ + errored: true, + loaded: true, + }) + }) + + it('calls the processEvent method if it exists and events are buffered', () => { + emitCaptureEvent?.('test_event1', { event: 'test_event1' } as any) + siteAppsInstance.onRemoteConfig({} as RemoteConfig) + emitCaptureEvent?.('test_event2', { event: 'test_event2' } as any) + expect(siteAppsInstance['bufferedInvocations'].length).toBe(2) + appConfigs[0].callback(true) + + expect(siteAppsInstance.apps['1'].processEvent).toHaveBeenCalledTimes(2) + expect(siteAppsInstance.apps['1'].processEvent).toHaveBeenCalledWith( + siteAppsInstance.globalsForEvent({ event: 'test_event1' } as any) + ) + expect(siteAppsInstance.apps['1'].processEvent).toHaveBeenCalledWith( + siteAppsInstance.globalsForEvent({ event: 'test_event2' } as any) ) }) + it('clears the buffer after all apps are loaded', () => { + emitCaptureEvent?.('test_event1', { event: 'test_event1' } as any) + emitCaptureEvent?.('test_event2', { event: 'test_event2' } as any) + expect(siteAppsInstance['bufferedInvocations'].length).toBe(2) + + siteAppsInstance.onRemoteConfig({} as RemoteConfig) + appConfigs[0].callback(true) + expect(siteAppsInstance['bufferedInvocations'].length).toBe(2) + appConfigs[1].callback(false) + expect(siteAppsInstance['bufferedInvocations'].length).toBe(0) + }) + 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 + assignableWindow.POSTHOG_DEBUG = true - siteAppsInstance.onRemoteConfig(response) + siteAppsInstance.onRemoteConfig({} as RemoteConfig) expect(mockLogger.error).toHaveBeenCalledWith( - 'Site apps exist but "opt_in_site_apps" is not set so they are not loaded.' + '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.onRemoteConfig(response) - - expect(siteAppsInstance.loaded).toBe(true) - expect(siteAppsInstance.enabled).toBe(false) + expect(siteAppsInstance.apps).toEqual({}) }) }) }) diff --git a/src/site-apps.ts b/src/site-apps.ts index 94c8726ae..c77fa54bd 100644 --- a/src/site-apps.ts +++ b/src/site-apps.ts @@ -1,5 +1,5 @@ import { PostHog } from './posthog-core' -import { CaptureResult, RemoteConfig } from './types' +import { CaptureResult, Properties, RemoteConfig, SiteApp, SiteAppGlobals, SiteAppLoader } from './types' import { assignableWindow } from './utils/globals' import { createLogger } from './utils/logger' import { isArray } from './utils/type-utils' @@ -7,47 +7,50 @@ import { isArray } from './utils/type-utils' const logger = createLogger('[SiteApps]') export class SiteApps { - instance: PostHog - enabled: boolean - missedInvocations: Record[] - loaded: boolean - appsLoading: Set - - constructor(instance: PostHog) { - this.instance = instance - // can't use if site apps are disabled, or if we're not asking /decide for site apps - this.enabled = !!this.instance.config.opt_in_site_apps && !this.instance.config.advanced_disable_decide + apps: Record + + private stopBuffering?: () => void + private bufferedInvocations: SiteAppGlobals[] + + constructor(private 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() + this.bufferedInvocations = [] + this.apps = {} + } + + public get isEnabled(): boolean { + return !!this.instance.config.opt_in_site_apps } - eventCollector(_eventName: string, eventPayload?: CaptureResult | undefined) { - if (!this.enabled) { + private eventCollector(_eventName: string, eventPayload?: CaptureResult | undefined) { + if (!eventPayload) { 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) - } + const globals = this.globalsForEvent(eventPayload) + this.bufferedInvocations.push(globals) + if (this.bufferedInvocations.length > 1000) { + this.bufferedInvocations = this.bufferedInvocations.slice(10) } } init() { - this.instance?._addCaptureHook(this.eventCollector.bind(this)) + if (this.isEnabled) { + const stop = this.instance._addCaptureHook(this.eventCollector.bind(this)) + this.stopBuffering = () => { + stop() + this.bufferedInvocations = [] + this.stopBuffering = undefined + } + } } - globalsForEvent(event: CaptureResult): Record { + globalsForEvent(event: CaptureResult): SiteAppGlobals { if (!event) { throw new Error('Event payload is required') } - const groups: Record> = {} + const groups: SiteAppGlobals['groups'] = {} const groupIds = this.instance.get_property('$groups') || [] - const groupProperties = this.instance.get_property('$stored_group_properties') || {} + const groupProperties: Record = this.instance.get_property('$stored_group_properties') || {} for (const [type, properties] of Object.entries(groupProperties)) { groups[type] = { id: groupIds[type], type, properties } } @@ -76,44 +79,109 @@ export class SiteApps { return globals } - onRemoteConfig(response?: RemoteConfig): void { - if (isArray(response?.siteApps) && response.siteApps.length > 0) { - if (this.enabled && this.instance.config.opt_in_site_apps) { - const checkIfAllLoaded = () => { - // Stop collecting events once all site apps are loaded - if (this.appsLoading.size === 0) { - this.loaded = true - this.missedInvocations = [] - } - } - for (const { id, url } of response['siteApps']) { - // TODO: if we have opted out and "type" is "site_destination", ignore it... but do include "site_app" types - this.appsLoading.add(id) - assignableWindow[`__$$ph_site_app_${id}`] = 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) - } - }) + setupSiteApp(loader: SiteAppLoader) { + const app: SiteApp = { + id: loader.id, + loaded: false, + errored: false, + } + this.apps[loader.id] = app + + const onLoaded = (success: boolean) => { + this.apps[loader.id].errored = !success + this.apps[loader.id].loaded = true + + logger.info(`Site app with id ${loader.id} ${success ? 'loaded' : 'errored'}`) + + if (success && this.bufferedInvocations.length) { + logger.info(`Processing ${this.bufferedInvocations.length} events for site app with id ${loader.id}`) + this.bufferedInvocations.forEach((globals) => app.processEvent?.(globals)) + } + + for (const app of Object.values(this.apps)) { + if (!app.loaded) { + // If any other apps are not loaded, we don't want to stop buffering + return } - } else if (response['siteApps'].length > 0) { - logger.error('Site apps exist but "opt_in_site_apps" is not set so they are not loaded.') - this.loaded = true - } else { - this.loaded = true } - } else { - this.loaded = true - this.enabled = false + + this.stopBuffering?.() + } + + try { + const { processEvent } = loader.init({ + posthog: this.instance, + callback: (success) => { + onLoaded(success) + }, + }) + + if (processEvent) { + app.processEvent = processEvent + } + } catch (e) { + logger.error(`Error while initializing PostHog app with config id ${loader.id}`, e) + onLoaded(false) + } + } + + private onCapturedEvent(event: CaptureResult) { + if (Object.keys(this.apps).length === 0) { + return + } + + const globals = this.globalsForEvent(event) + + for (const app of Object.values(this.apps)) { + try { + app.processEvent?.(globals) + } catch (e) { + logger.error(`Error while processing event ${event.event} for site app ${app.id}`, e) + } } } - // TODO: opting out of stuff should disable this + onRemoteConfig(response: RemoteConfig): void { + if (isArray(assignableWindow._POSTHOG_JS_APPS)) { + if (!this.isEnabled) { + logger.error(`PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.`) + return + } + + for (const app of assignableWindow._POSTHOG_JS_APPS) { + this.setupSiteApp(app) + } + + if (!assignableWindow._POSTHOG_JS_APPS.length) { + this.stopBuffering?.() + } else { + // NOTE: We could improve this to only fire if we actually have listeners for the event + this.instance.on('eventCaptured', (event) => this.onCapturedEvent(event)) + } + + return + } + + // NOTE: Below his is now only the fallback for legacy site app support. Once we have fully removed to the remote config loader we can get rid of this + + this.stopBuffering?.() + + if (!response['siteApps']?.length) { + return + } + + if (!this.isEnabled) { + logger.error(`PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.`) + return + } + + 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) + } + }) + } + } } diff --git a/src/types.ts b/src/types.ts index e821a2a8b..303b501b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -341,6 +341,7 @@ export interface PostHogConfig { /** * PREVIEW - MAY CHANGE WITHOUT WARNING - DO NOT USE IN PRODUCTION + * enables the new RemoteConfig approach to loading config instead of decide * */ __preview_remote_config?: boolean } @@ -539,6 +540,35 @@ export interface DecideResponse extends RemoteConfig { errorsWhileComputingFlags: boolean } +export type SiteAppGlobals = { + event: { + uuid: string + event: EventName + properties: Properties + timestamp?: Date + elements_chain?: string + distinct_id?: string + } + person: { + properties: Properties + } + groups: Record +} + +export type SiteAppLoader = { + id: string + init: (config: { posthog: PostHog; callback: (success: boolean) => void }) => { + processEvent?: (globals: SiteAppGlobals) => void + } +} + +export type SiteApp = { + id: string + loaded: boolean + errored: boolean + processEvent?: (globals: SiteAppGlobals) => void +} + export type FeatureFlagsCallback = ( flags: string[], variants: Record, diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 23ca7a1ba..a2e2479fa 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -1,7 +1,14 @@ import { ErrorProperties } from '../extensions/exception-autocapture/error-conversion' import type { PostHog } from '../posthog-core' import { SessionIdManager } from '../sessionid' -import { DeadClicksAutoCaptureConfig, ErrorEventArgs, ErrorMetadata, Properties, RemoteConfig } from '../types' +import { + DeadClicksAutoCaptureConfig, + ErrorEventArgs, + ErrorMetadata, + Properties, + RemoteConfig, + SiteAppLoader, +} from '../types' /* * Global helpers to protect access to browser globals in a way that is safer for different targets @@ -21,7 +28,7 @@ export type AssignableWindow = Window & Record & { __PosthogExtensions__?: PostHogExtensions _POSTHOG_CONFIG?: RemoteConfig - _POSTHOG_SITE_APPS?: { token: string; load: (posthog: PostHog) => void }[] + _POSTHOG_JS_APPS?: SiteAppLoader[] } /**