From 4b6983da6a5aa958c02158e896d5b5a0352cfe36 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Fri, 22 Nov 2024 11:24:57 +0100 Subject: [PATCH] site functions --- src/autocapture.ts | 11 +- src/decide.ts | 17 +-- src/entrypoints/external-scripts-loader.ts | 16 +-- src/posthog-core.ts | 6 ++ src/site-apps.ts | 118 +++++++++++++++++++++ src/types.ts | 2 +- src/utils/globals.ts | 2 - 7 files changed, 135 insertions(+), 37 deletions(-) create mode 100644 src/site-apps.ts 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/entrypoints/external-scripts-loader.ts b/src/entrypoints/external-scripts-loader.ts index 084b47d82..788f64def 100644 --- a/src/entrypoints/external-scripts-loader.ts +++ b/src/entrypoints/external-scripts-loader.ts @@ -2,7 +2,11 @@ import type { PostHog } from '../posthog-core' import { assignableWindow, document, PostHogExtensionKind } from '../utils/globals' import { logger } from '../utils/logger' -const loadScript = (posthog: PostHog, url: string, callback: (error?: string | Event, event?: Event) => void) => { +export const loadScript = ( + posthog: PostHog, + url: string, + callback: (error?: string | Event, event?: Event) => void +) => { if (posthog.config.disable_external_dependency_loading) { logger.warn(`${url} was requested but loading of external scripts is disabled.`) return callback('Loading of external scripts is disabled') @@ -56,13 +60,3 @@ assignableWindow.__PosthogExtensions__.loadExternalDependency = ( loadScript(posthog, url, callback) } - -assignableWindow.__PosthogExtensions__.loadSiteApp = ( - posthog: PostHog, - url: string, - callback: (error?: string | Event, event?: Event) => void -): void => { - const scriptUrl = posthog.requestRouter.endpointFor('api', url) - - loadScript(posthog, scriptUrl, callback) -} diff --git a/src/posthog-core.ts b/src/posthog-core.ts index db362d2af..49e21a25f 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' /* @@ -258,6 +259,7 @@ export class PostHog { sessionManager?: SessionIdManager sessionPropsManager?: SessionPropsManager requestRouter: RequestRouter + siteApps?: SiteApps autocapture?: Autocapture heatmaps?: Heatmaps webVitalsAutocapture?: WebVitalsAutocapture @@ -432,6 +434,9 @@ export class PostHog { new TracingHeaders(this).startIfEnabledOrStop() + this.siteApps = new SiteApps(this) + this.siteApps?.init() + this.sessionRecording = new SessionRecording(this) this.sessionRecording.startIfEnabledOrStop() @@ -562,6 +567,7 @@ export class PostHog { : 'always', }) + this.siteApps?.afterDecideResponse(response) this.sessionRecording?.afterDecideResponse(response) this.autocapture?.afterDecideResponse(response) this.heatmaps?.afterDecideResponse(response) diff --git a/src/site-apps.ts b/src/site-apps.ts new file mode 100644 index 000000000..b0bcc65f0 --- /dev/null +++ b/src/site-apps.ts @@ -0,0 +1,118 @@ +import { loadScript } from './entrypoints/external-scripts-loader' +import { PostHog } from './posthog-core' +import { CaptureResult, DecideResponse } from './types' +import { assignableWindow } from './utils/globals' +import { logger } from './utils/logger' + +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 + // 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) { + if (!this.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(990) + } + } + } + + 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 + } + + loadSiteApp(posthog: PostHog, url: string, callback: (error?: string | Event, event?: Event) => void) { + const scriptUrl = posthog.requestRouter.endpointFor('api', url) + loadScript(posthog, scriptUrl, callback) + } + + afterDecideResponse(response?: DecideResponse): void { + if (response?.['siteApps']) { + 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']) { + 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() + } + this.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 (response['siteApps'].length > 0) { + logger.error('PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.') + } + } else { + this.loaded = true + this.enabled = false + } + } + + // TODO: opting out of stuff should disable this +} diff --git a/src/types.ts b/src/types.ts index d7994968a..3044e10df 100644 --- a/src/types.ts +++ b/src/types.ts @@ -523,7 +523,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; url: string }[] heatmaps?: boolean defaultIdentifiedOnly?: boolean captureDeadClicks?: boolean diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 377fe22b5..819a7e59a 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -48,8 +48,6 @@ interface PostHogExtensions { callback: (error?: string | Event, event?: Event) => void ) => void - loadSiteApp?: (posthog: PostHog, appUrl: string, callback: (error?: string | Event, event?: Event) => void) => void - parseErrorAsProperties?: ( [event, source, lineno, colno, error]: ErrorEventArgs, metadata?: ErrorMetadata