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/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
28 changes: 28 additions & 0 deletions packages/browser/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,33 @@ const entrypointTargets = entrypoints.map((file) => {
}
})

/**
* rollup-plugin-dts doesn't resolve `declare module` augmentations that target
* modules being inlined. Relative paths like `'../types'` become dangling in
* the bundled output. This plugin repoints them to the package name so
* TypeScript's module augmentation merging works correctly for consumers.
*/
function fixDtsModuleAugmentation() {
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
const pkgDir = path.resolve('.')

return {
name: 'fix-dts-module-augmentation',
generateBundle(options, bundle) {
const outputDir = options.dir ? path.resolve(options.dir) : path.dirname(path.resolve(options.file))
for (const chunk of Object.values(bundle)) {
if (!chunk.code) continue
const chunkPath = path.resolve(outputDir, chunk.fileName)
const modulePath = `${pkg.name}/${path.relative(pkgDir, chunkPath).replace(/\.d\.ts$/, '')}`
chunk.code = chunk.code.replace(
/declare module ['"]\.\.?\/[^'"]+['"]/g,
`declare module '${modulePath}'`
)
}
},
}
}

const typeTargets = entrypoints
.filter((file) => file.endsWith('.es.ts'))
.map((file) => {
Expand All @@ -383,6 +410,7 @@ const typeTargets = entrypoints
dts({
exclude: [],
}),
fixDtsModuleAugmentation(),
],
}
})
Expand Down
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
Loading