Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-cobras-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': patch
---

Add Extension interface for tree-shakable extensions
6 changes: 5 additions & 1 deletion packages/browser/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,11 @@ const plugins = (es5, noExternal) => [
},
]

const entrypoints = fs.readdirSync('./src/entrypoints')
const entryFilter = process.env.ENTRY
const allEntrypoints = fs.readdirSync('./src/entrypoints')
const entrypoints = entryFilter
? allEntrypoints.filter((file) => file.startsWith(entryFilter))
: allEntrypoints

const entrypointTargets = entrypoints.map((file) => {
const fileParts = file.split('.')
Expand Down
111 changes: 110 additions & 1 deletion packages/browser/src/__tests__/extension-classes.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { PostHog } from '../posthog-core'
import { PostHogConfig } from '../types'
import { PostHogConfig, RemoteConfig } from '../types'
import { AllExtensions } from '../extensions/extension-bundles'
import { Autocapture } from '../autocapture'
import { SessionRecording } from '../extensions/replay/session-recording'
Expand Down Expand Up @@ -87,3 +87,112 @@ describe('__extensionClasses enrollment', () => {
expect(posthog.siteApps).toBeDefined()
})
})

describe('extension lifecycle', () => {
let savedDefaults: PostHogConfig['__extensionClasses']

beforeEach(() => {
savedDefaults = PostHog.__defaultExtensionClasses
console.error = jest.fn()
})

afterEach(() => {
PostHog.__defaultExtensionClasses = savedDefaults
})

describe('AllExtensions covers every __extensionClasses key', () => {
it('has an entry for every key in the __extensionClasses type', () => {
// If a new key is added to __extensionClasses but not to AllExtensions,
// this test will fail because the full bundle would silently omit it.
const allKeys = Object.keys(AllExtensions).sort()
expect(allKeys).toEqual([
'autocapture',
'deadClicksAutocapture',
'exceptionObserver',
'heatmaps',
'historyAutocapture',
'productTours',
'sessionRecording',
'siteApps',
'tracingHeaders',
'webVitalsAutocapture',
])
})
})

describe('initialize() is called on extensions', () => {
it('calls initialize() on extensions that define it', async () => {
PostHog.__defaultExtensionClasses = {}

const initializeSpy = jest.fn()

class SpyExtension {
constructor() {}
initialize() {
initializeSpy()
}
}

const posthog = await createPosthogInstance(undefined, {
__preview_deferred_init_extensions: false,
__extensionClasses: { autocapture: SpyExtension as any },
capture_pageview: false,
})

expect(posthog.autocapture).toBeInstanceOf(SpyExtension)
expect(initializeSpy).toHaveBeenCalledTimes(1)
})

it('does not throw if an extension has no initialize()', async () => {
PostHog.__defaultExtensionClasses = {}

class MinimalExtension {
constructor() {}
}

const posthog = await createPosthogInstance(undefined, {
__preview_deferred_init_extensions: false,
__extensionClasses: { autocapture: MinimalExtension as any },
capture_pageview: false,
})

expect(posthog.autocapture).toBeInstanceOf(MinimalExtension)
})
})

describe('onRemoteConfig dispatching', () => {
it('calls onRemoteConfig on all extensions that define it', async () => {
PostHog.__defaultExtensionClasses = {}

const onRemoteConfigSpy = jest.fn()

class SpyExtension {
constructor() {}
onRemoteConfig(config: RemoteConfig) {
onRemoteConfigSpy(config)
}
}

// Use extension keys that don't have hardcoded method calls
// in set_config, so a spy class works without needing stubs.
const posthog = await createPosthogInstance(undefined, {
__preview_deferred_init_extensions: false,
__extensionClasses: {
historyAutocapture: SpyExtension as any,
siteApps: SpyExtension as any,
},
capture_pageview: false,
})

// Clear any calls from the init/loaded flow
onRemoteConfigSpy.mockClear()

const remoteConfig = { supportedCompression: [] } as unknown as RemoteConfig
posthog._onRemoteConfig(remoteConfig)

// Two extensions, each should get onRemoteConfig called once
expect(onRemoteConfigSpy).toHaveBeenCalledTimes(2)
expect(onRemoteConfigSpy).toHaveBeenCalledWith(remoteConfig)
})
})
})
10 changes: 5 additions & 5 deletions packages/browser/src/__tests__/site-apps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,15 @@ describe('SiteApps', () => {
describe('init', () => {
it('adds eventCollector as a capture hook', () => {
expect(siteAppsInstance['_stopBuffering']).toBeUndefined()
siteAppsInstance.init()
siteAppsInstance.initialize()

expect(posthog._addCaptureHook).toHaveBeenCalledWith(expect.any(Function))
expect(siteAppsInstance['_stopBuffering']).toEqual(expect.any(Function))
})

it('does not add eventCollector as a capture hook if disabled', () => {
posthog.config.opt_in_site_apps = false
siteAppsInstance.init()
siteAppsInstance.initialize()

expect(posthog._addCaptureHook).not.toHaveBeenCalled()
expect(siteAppsInstance['_stopBuffering']).toBeUndefined()
Expand All @@ -127,7 +127,7 @@ describe('SiteApps', () => {

describe('eventCollector', () => {
beforeEach(() => {
siteAppsInstance.init()
siteAppsInstance.initialize()
})

it('collects events if enabled after init', () => {
Expand Down Expand Up @@ -225,7 +225,7 @@ describe('SiteApps', () => {
describe('legacy site apps loading', () => {
beforeEach(() => {
posthog.config.opt_in_site_apps = true
siteAppsInstance.init()
siteAppsInstance.initialize()
})

it('loads stops buffering if no site apps', () => {
Expand Down Expand Up @@ -335,7 +335,7 @@ describe('SiteApps', () => {
},
} as any

siteAppsInstance.init()
siteAppsInstance.initialize()
}

beforeEach(() => {
Expand Down
7 changes: 6 additions & 1 deletion packages/browser/src/autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { document, window } from './utils/globals'
import { convertToURL } from './utils/request-utils'
import { isDocumentFragment, isElementNode, isTag, isTextNode } from './utils/element-utils'
import { includes } from '@posthog/core'
import type { Extension } from './extensions/types'

const COPY_AUTOCAPTURE_EVENT = '$copy_autocapture'

Expand Down Expand Up @@ -232,7 +233,7 @@ export function autocapturePropertiesForElement(
return { props }
}

export class Autocapture {
export class Autocapture implements Extension {
instance: PostHog
_initialized: boolean = false
_isDisabledServerSide: boolean | null = null
Expand All @@ -246,6 +247,10 @@ export class Autocapture {
this._elementSelectors = null
}

initialize() {
this.startIfEnabled()
}

private get _config(): AutocaptureConfig {
const config = isObject(this.instance.config.autocapture) ? this.instance.config.autocapture : {}
// precompile the regex
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/src/extensions/dead-clicks-autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { isBoolean, isObject } from '@posthog/core'
import { assignableWindow, document, LazyLoadedDeadClicksAutocaptureInterface } from '../utils/globals'
import { createLogger } from '../utils/logger'
import { DeadClicksAutoCaptureConfig, RemoteConfig } from '../types'
import type { Extension } from './types'

const logger = createLogger('[Dead Clicks]')

Expand All @@ -22,7 +23,7 @@ export const isDeadClicksEnabledForAutocapture = (instance: DeadClicksAutocaptur
return isRemoteEnabled
}

export class DeadClicksAutocapture {
export class DeadClicksAutocapture implements Extension {
get lazyLoadedDeadClicksAutocapture(): LazyLoadedDeadClicksAutocaptureInterface | undefined {
return this._lazyLoadedDeadClicksAutocapture
}
Expand Down
7 changes: 6 additions & 1 deletion packages/browser/src/extensions/history-autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { window } from '../utils/globals'
import { addEventListener } from '../utils'
import { logger } from '../utils/logger'
import { patch } from './replay/rrweb-plugins/patch'
import type { Extension } from './types'

/**
* This class is used to capture pageview events when the user navigates using the history API (pushState, replaceState)
Expand All @@ -11,7 +12,7 @@ import { patch } from './replay/rrweb-plugins/patch'
* The behavior is controlled by the `capture_pageview` configuration option:
* - When set to `'history_change'`, this class will capture pageviews on history API changes
*/
export class HistoryAutocapture {
export class HistoryAutocapture implements Extension {
private _instance: PostHog
private _popstateListener: (() => void) | undefined
private _lastPathname: string
Expand All @@ -21,6 +22,10 @@ export class HistoryAutocapture {
this._lastPathname = window?.location?.pathname || ''
}

initialize() {
this.startIfEnabled()
}

public get isEnabled(): boolean {
return this._instance.config.capture_pageview === 'history_change'
}
Expand Down
7 changes: 6 additions & 1 deletion packages/browser/src/extensions/replay/session-recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ import {
} from '../../utils/globals'
import { RECORDING_REMOTE_CONFIG_TTL_MS } from './external/lazy-loaded-session-recorder'
import { DISABLED, LAZY_LOADING, PENDING_CONFIG, SessionRecordingStatus, TriggerType } from './external/triggerMatching'
import type { Extension } from '../types'

const LOGGER_PREFIX = '[SessionRecording]'
const logger = createLogger(LOGGER_PREFIX)

export class SessionRecording {
export class SessionRecording implements Extension {
_forceAllowLocalhostNetworkCapture: boolean = false

private _receivedFlags: boolean = false
Expand Down Expand Up @@ -70,6 +71,10 @@ export class SessionRecording {
}
}

initialize() {
this.startIfEnabledOrStop()
}

private get _isRecordingEnabled() {
const enabled_server_side = !!this._instance.get_property(SESSION_RECORDING_REMOTE_CONFIG)?.enabled
const enabled_client_side = !this._instance.config.disable_session_recording
Expand Down
7 changes: 6 additions & 1 deletion packages/browser/src/extensions/tracing-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ import { PostHog } from '../posthog-core'
import { assignableWindow } from '../utils/globals'
import { createLogger } from '../utils/logger'
import { isUndefined } from '@posthog/core'
import type { Extension } from './types'

const logger = createLogger('[TracingHeaders]')

export class TracingHeaders {
export class TracingHeaders implements Extension {
private _restoreXHRPatch: (() => void) | undefined = undefined
private _restoreFetchPatch: (() => void) | undefined = undefined

constructor(private readonly _instance: PostHog) {}

initialize() {
this.startIfEnabledOrStop()
}

private _loadScript(cb: () => void): void {
if (assignableWindow.__PosthogExtensions__?.tracingHeadersPatchFns) {
// already loaded
Expand Down
9 changes: 9 additions & 0 deletions packages/browser/src/extensions/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { PostHog } from '../posthog-core'
import type { RemoteConfig } from '../types'

export type ExtensionConstructor<T extends Extension> = new (instance: PostHog, ...args: any[]) => T

export interface Extension {
initialize?(): boolean | void
onRemoteConfig?(config: RemoteConfig): void
}
7 changes: 6 additions & 1 deletion packages/browser/src/heatmaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { includes } from '@posthog/core'
import { addEventListener, extendArray } from './utils'
import { maskQueryParams } from './utils/request-utils'
import { PERSONAL_DATA_CAMPAIGN_PARAMS, MASKED } from './utils/event-utils'
import type { Extension } from './extensions/types'

const DEFAULT_FLUSH_INTERVAL = 5000

Expand Down Expand Up @@ -50,7 +51,7 @@ function shouldPoll(document: Document | undefined): boolean {
return document?.visibilityState === 'visible'
}

export class Heatmaps {
export class Heatmaps implements Extension {
instance: PostHog
rageclicks: RageClick
_enabledServerSide: boolean = false
Expand All @@ -71,6 +72,10 @@ export class Heatmaps {
this.rageclicks = new RageClick(instance.config.rageclick)
}

initialize() {
this.startIfEnabled()
}

public get flushIntervalMilliseconds(): number {
let flushInterval = DEFAULT_FLUSH_INTERVAL
if (
Expand Down
Loading
Loading