Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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/slick-parents-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': minor
---

feat: Tree-shake surveys, toolbar, exceptions, conversations, logs, experiments
100 changes: 96 additions & 4 deletions packages/browser/src/__tests__/extension-classes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ describe('__extensionClasses enrollment', () => {
expect(posthog.webVitalsAutocapture).toBeUndefined()
expect(posthog.productTours).toBeUndefined()
expect(posthog.siteApps).toBeUndefined()
expect(posthog.surveys).toBeUndefined()
expect(posthog.toolbar).toBeUndefined()
expect(posthog.exceptions).toBeUndefined()
expect(posthog.conversations).toBeUndefined()
expect(posthog.logs).toBeUndefined()
expect(posthog.experiments).toBeUndefined()
})

it('initializes no extensions when none are provided and no defaults exist', async () => {
Expand All @@ -53,6 +59,12 @@ describe('__extensionClasses enrollment', () => {
expect(posthog.webVitalsAutocapture).toBeUndefined()
expect(posthog.productTours).toBeUndefined()
expect(posthog.siteApps).toBeUndefined()
expect(posthog.surveys).toBeUndefined()
expect(posthog.toolbar).toBeUndefined()
expect(posthog.exceptions).toBeUndefined()
expect(posthog.conversations).toBeUndefined()
expect(posthog.logs).toBeUndefined()
expect(posthog.experiments).toBeUndefined()
})

it('__extensionClasses overrides __defaultExtensionClasses', async () => {
Expand All @@ -69,6 +81,32 @@ describe('__extensionClasses enrollment', () => {
expect(posthog.autocapture).toBeInstanceOf(MockAutocapture)
})

it('eagerly constructs extensions from defaults before init()', () => {
PostHog.__defaultExtensionClasses = AllExtensions

const posthog = new PostHog()

expect(posthog.toolbar).toBeDefined()
expect(posthog.surveys).toBeDefined()
expect(posthog.conversations).toBeDefined()
expect(posthog.logs).toBeDefined()
expect(posthog.experiments).toBeDefined()
expect(posthog.exceptions).toBeDefined()
})

it('does not eagerly construct extensions when no defaults exist', () => {
PostHog.__defaultExtensionClasses = {}

const posthog = new PostHog()

expect(posthog.toolbar).toBeUndefined()
expect(posthog.surveys).toBeUndefined()
expect(posthog.conversations).toBeUndefined()
expect(posthog.logs).toBeUndefined()
expect(posthog.experiments).toBeUndefined()
expect(posthog.exceptions).toBeUndefined()
})

it('default extensions are used when __extensionClasses is not provided', async () => {
PostHog.__defaultExtensionClasses = AllExtensions

Expand All @@ -85,6 +123,12 @@ describe('__extensionClasses enrollment', () => {
expect(posthog.webVitalsAutocapture).toBeDefined()
expect(posthog.productTours).toBeDefined()
expect(posthog.siteApps).toBeDefined()
expect(posthog.surveys).toBeDefined()
expect(posthog.toolbar).toBeDefined()
expect(posthog.exceptions).toBeDefined()
expect(posthog.conversations).toBeDefined()
expect(posthog.logs).toBeDefined()
expect(posthog.experiments).toBeDefined()
})
})

Expand All @@ -107,13 +151,19 @@ describe('extension lifecycle', () => {
const allKeys = Object.keys(AllExtensions).sort()
expect(allKeys).toEqual([
'autocapture',
'conversations',
'deadClicksAutocapture',
'exceptionObserver',
'exceptions',
'experiments',
'heatmaps',
'historyAutocapture',
'logs',
'productTours',
'sessionRecording',
'siteApps',
'surveys',
'toolbar',
'tracingHeaders',
'webVitalsAutocapture',
])
Expand Down Expand Up @@ -173,13 +223,11 @@ describe('extension lifecycle', () => {
}
}

// 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,
toolbar: SpyExtension as any,
conversations: SpyExtension as any,
},
capture_pageview: false,
})
Expand All @@ -195,4 +243,48 @@ describe('extension lifecycle', () => {
expect(onRemoteConfigSpy).toHaveBeenCalledWith(remoteConfig)
})
})

describe('graceful degradation without extensions (slim bundle)', () => {
it('onSurveysLoaded calls back with error when extension is not loaded', async () => {
PostHog.__defaultExtensionClasses = {}

const posthog = await createPosthogInstance(undefined, {
__preview_deferred_init_extensions: false,
capture_pageview: false,
})

const callback = jest.fn()
posthog.onSurveysLoaded(callback)

expect(callback).toHaveBeenCalledWith([], { isLoaded: false, error: 'Surveys module not available' })
})

it('getSurveys calls back with error when extension is not loaded', async () => {
PostHog.__defaultExtensionClasses = {}

const posthog = await createPosthogInstance(undefined, {
__preview_deferred_init_extensions: false,
capture_pageview: false,
})

const callback = jest.fn()
posthog.getSurveys(callback)

expect(callback).toHaveBeenCalledWith([], { isLoaded: false, error: 'Surveys module not available' })
})

it('getActiveMatchingSurveys calls back with error when extension is not loaded', async () => {
PostHog.__defaultExtensionClasses = {}

const posthog = await createPosthogInstance(undefined, {
__preview_deferred_init_extensions: false,
capture_pageview: false,
})

const callback = jest.fn()
posthog.getActiveMatchingSurveys(callback)

expect(callback).toHaveBeenCalledWith([], { isLoaded: false, error: 'Surveys module not available' })
})
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { init_as_module } from '../posthog-core'

declare module '../types' {
interface TreeShakeableConfig {
optional: true
}
}

export { PostHog } from '../posthog-core'
export * from '../types'
export * from '../posthog-surveys-types'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import { assignableWindow, LazyLoadedConversationsInterface } from '../../utils/
import { createLogger } from '../../utils/logger'
import { isNullish, isUndefined, isBoolean, isNull } from '@posthog/core'
import { isToolbarInstance } from '../../utils'
import { Extension } from '../types'

const logger = createLogger('[Conversations]')

export type ConversationsManager = LazyLoadedConversationsInterface

export class PostHogConversations {
export class PostHogConversations implements Extension {
// This is set to undefined until the remote config is loaded
// then it's set to true if conversations are enabled
// or false if conversations are disabled in the project settings
Expand All @@ -31,6 +32,10 @@ export class PostHogConversations {

constructor(private _instance: PostHog) {}

initialize() {
this.loadIfEnabled()
}

onRemoteConfig(response: RemoteConfig) {
// Don't load conversations if disabled via config
if (this._instance.config.disable_conversations) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,6 @@ export class ExceptionObserver {
return
}

this._instance.exceptions.sendExceptionEvent(errorProperties)
this._instance.exceptions?.sendExceptionEvent(errorProperties)
}
}
39 changes: 38 additions & 1 deletion packages/browser/src/extensions/extension-bundles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,16 @@ import { Heatmaps } from '../heatmaps'
import { PostHogProductTours } from '../posthog-product-tours'
import { SiteApps } from '../site-apps'
import { PostHogConfig } from '../types'
import { PostHogSurveys } from '../posthog-surveys'
import { Toolbar } from './toolbar'
import { PostHogExceptions } from '../posthog-exceptions'
import { WebExperiments } from '../web-experiments'
import { PostHogConversations } from './conversations/posthog-conversations'
import { PostHogLogs } from '../posthog-logs'

type ExtensionClasses = NonNullable<PostHogConfig['__extensionClasses']>

/** Session replay and related extensions. */
/** Session replay. */
export const SessionReplayExtensions = {
sessionRecording: SessionRecording,
} as const satisfies ExtensionClasses
Expand All @@ -52,6 +58,7 @@ export const AnalyticsExtensions = {
/** Automatic exception and error capture. */
export const ErrorTrackingExtensions = {
exceptionObserver: ExceptionObserver,
exceptions: PostHogExceptions,
} as const satisfies ExtensionClasses

/** In-app product tours. */
Expand All @@ -69,6 +76,31 @@ export const TracingExtensions = {
tracingHeaders: TracingHeaders,
} as const satisfies ExtensionClasses

/** In-app surveys. */
export const SurveysExtensions = {
surveys: PostHogSurveys,
} as const satisfies ExtensionClasses

/** PostHog toolbar for visual element inspection and action setup. */
export const ToolbarExtensions = {
toolbar: Toolbar,
} as const satisfies ExtensionClasses

/** Web experiments. */
export const ExperimentsExtensions = {
experiments: WebExperiments,
} as const satisfies ExtensionClasses

/** In-app conversations. */
export const ConversationsExtensions = {
conversations: PostHogConversations,
} as const satisfies ExtensionClasses

/** Console log capture. */
export const LogsExtensions = {
logs: PostHogLogs,
} as const satisfies ExtensionClasses

/** All extensions — equivalent to the default `posthog-js` bundle. */
export const AllExtensions = {
...SessionReplayExtensions,
Expand All @@ -77,4 +109,9 @@ export const AllExtensions = {
...ProductToursExtensions,
...SiteAppsExtensions,
...TracingExtensions,
...SurveysExtensions,
...ToolbarExtensions,
...ExperimentsExtensions,
...ConversationsExtensions,
...LogsExtensions,
} as const satisfies ExtensionClasses
2 changes: 1 addition & 1 deletion packages/browser/src/extensions/sentry-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export function createEventProcessor(
}

if (sendExceptionsToPostHog) {
_posthog.exceptions.sendExceptionEvent(data)
_posthog.exceptions?.sendExceptionEvent(data)
}

return event
Expand Down
4 changes: 2 additions & 2 deletions packages/browser/src/extensions/surveys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,7 @@ export class SurveyManager {
return true
}
const surveysActivatedByEventsOrActions: string[] | undefined =
this._posthog.surveys._surveyEventReceiver?.getSurveys()
this._posthog.surveys?._surveyEventReceiver?.getSurveys()
return !!surveysActivatedByEventsOrActions?.includes(survey.id)
}

Expand All @@ -632,7 +632,7 @@ export class SurveyManager {
}

public getActiveMatchingSurveys = (callback: SurveyCallback, forceReload = false): void => {
this._posthog?.surveys.getSurveys((surveys) => {
this._posthog?.surveys?.getSurveys((surveys) => {
const targetingMatchedSurveys = surveys.filter((survey) => {
const eligibility = this.checkSurveyEligibility(survey)
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ export const defaultSurveyAppearance = {
scrollbarTrackColor: 'var(--ph-survey-background-color)',
} as const

const BOTTOM_BORDER_SURVEY_POSITIONS: SurveyPosition[] = [
SurveyPosition.Center,
SurveyPosition.Left,
SurveyPosition.Right,
]

export const addSurveyCSSVariablesToElement = (
element: HTMLElement,
type: SurveyType,
Expand All @@ -93,7 +99,7 @@ export const addSurveyCSSVariablesToElement = (
const hostStyle = element.style

const surveyHasBottomBorder =
![SurveyPosition.Center, SurveyPosition.Left, SurveyPosition.Right].includes(effectiveAppearance.position) ||
!BOTTOM_BORDER_SURVEY_POSITIONS.includes(effectiveAppearance.position) ||
(type === SurveyType.Widget && appearance?.widgetType === SurveyWidgetType.Tab)

hostStyle.setProperty('--ph-survey-font-family', getFontFamily(effectiveAppearance.fontFamily))
Expand Down
7 changes: 6 additions & 1 deletion packages/browser/src/extensions/toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createLogger } from '../utils/logger'
import { window, document, assignableWindow } from '../utils/globals'
import { TOOLBAR_ID } from '../constants'
import { isFunction, isNullish } from '@posthog/core'
import { Extension } from './types'

// TRICKY: Many web frameworks will modify the route on load, potentially before posthog is initialized.
// To get ahead of this we grab it as soon as the posthog-js is parsed
Expand All @@ -23,7 +24,7 @@ enum ToolbarState {
LOADED = 2,
}

export class Toolbar {
export class Toolbar implements Extension {
instance: PostHog

constructor(instance: PostHog) {
Expand All @@ -39,6 +40,10 @@ export class Toolbar {
return assignableWindow['ph_toolbar_state'] ?? ToolbarState.UNINITIALIZED
}

initialize(): boolean {
return this.maybeLoadToolbar()
}

/**
* To load the toolbar, we need an access token and other state. That state comes from one of three places:
* 1. In the URL hash params
Expand Down
Loading