diff --git a/eslint-rules/no-external-replay-imports.js b/eslint-rules/no-external-replay-imports.js index e43d95637..18c206a4a 100644 --- a/eslint-rules/no-external-replay-imports.js +++ b/eslint-rules/no-external-replay-imports.js @@ -16,7 +16,7 @@ module.exports = { if (importPath.startsWith('./') || importPath.startsWith('../')) { // For relative paths, check if they contain 'external' // This matches the test case behavior - return importPath.includes('external') + return importPath.includes('replay/external') } return false diff --git a/playground/nextjs/pages/_app.tsx b/playground/nextjs/pages/_app.tsx index 79ebc8c73..03c596845 100644 --- a/playground/nextjs/pages/_app.tsx +++ b/playground/nextjs/pages/_app.tsx @@ -11,6 +11,14 @@ import Head from 'next/head' import { PostHogProvider } from 'posthog-js/react' const CDP_DOMAINS = ['https://*.redditstatic.com', 'https://*.reddit.com'].join(' ') +const CHAT_DOMAINS = [ + 'https://*.intercom.io', + 'https://*.intercomcdn.com', + 'wss://*.intercom.io', + 'https://static.intercomassets.com', + 'https://*.crisp.chat', + 'wss://*.relay.crisp.chat', +].join(' ') export default function App({ Component, pageProps }: AppProps) { const user = useUser() @@ -43,11 +51,14 @@ export default function App({ Component, pageProps }: AppProps) { httpEquiv="Content-Security-Policy" content={` default-src 'self'; - connect-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host ${CDP_DOMAINS}; - script-src 'self' 'unsafe-eval' 'unsafe-inline' ${localhostDomain} https://*.posthog.com ${CDP_DOMAINS}; - style-src 'self' 'unsafe-inline' ${localhostDomain} https://*.posthog.com; - img-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host https://cataas.com ${CDP_DOMAINS}; - worker-src 'self' blob:; + connect-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host ${CDP_DOMAINS} ${CHAT_DOMAINS}; + script-src 'self' 'unsafe-eval' 'unsafe-inline' ${localhostDomain} https://*.posthog.com ${CDP_DOMAINS} ${CHAT_DOMAINS}; + style-src 'self' 'unsafe-inline' ${localhostDomain} https://*.posthog.com ${CHAT_DOMAINS}; + img-src 'self' data: blob: ${localhostDomain} https://*.posthog.com https://lottie.host https://cataas.com ${CDP_DOMAINS} ${CHAT_DOMAINS}; + worker-src 'self' blob: ${CHAT_DOMAINS}; + font-src 'self' ${localhostDomain} https://*.posthog.com ${CHAT_DOMAINS}; + media-src 'self' ${localhostDomain} https://*.posthog.com ${CHAT_DOMAINS}; + frame-src 'self' ${localhostDomain} https://*.posthog.com ${CHAT_DOMAINS}; `} /> diff --git a/playground/nextjs/pages/external_chat.tsx b/playground/nextjs/pages/external_chat.tsx new file mode 100644 index 000000000..b1ad86b92 --- /dev/null +++ b/playground/nextjs/pages/external_chat.tsx @@ -0,0 +1,42 @@ +import Script from 'next/script' + +export default function ExternalChat() { + return ( +
+

ExternalChat

+

This is a page for testing external chat widgets

+ + {/* Intercom Settings Script */} + + + {/* Intercom Widget Script */} + + + {/* Crisp chat Script */} + +
+ ) +} diff --git a/playground/nextjs/src/Header.tsx b/playground/nextjs/src/Header.tsx index 687e9a57b..59575577d 100644 --- a/playground/nextjs/src/Header.tsx +++ b/playground/nextjs/src/Header.tsx @@ -27,6 +27,7 @@ export const PageHeader = () => { Long E-commerce Toolbar Tests + External Chat
diff --git a/playground/nextjs/src/posthog.ts b/playground/nextjs/src/posthog.ts index fd7a51fa1..a68329a40 100644 --- a/playground/nextjs/src/posthog.ts +++ b/playground/nextjs/src/posthog.ts @@ -66,6 +66,10 @@ if (typeof window !== 'undefined') { 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, + integrations: { + intercom: true, + crispChat: true, + }, __preview_remote_config: true, __preview_experimental_cookieless_mode: false, __preview_flags_v2: true, diff --git a/src/__tests__/__snapshots__/config-snapshot.test.ts.snap b/src/__tests__/__snapshots__/config-snapshot.test.ts.snap index 33a6f37fe..324969299 100644 --- a/src/__tests__/__snapshots__/config-snapshot.test.ts.snap +++ b/src/__tests__/__snapshots__/config-snapshot.test.ts.snap @@ -507,6 +507,10 @@ exports[`config snapshot for PostHogConfig 1`] = ` ] } ], + \\"integrations\\": [ + \\"undefined\\", + \\"Record\\" + ], \\"__add_tracing_headers\\": [ \\"undefined\\", \\"false\\", diff --git a/src/entrypoints/array.full.es5.ts b/src/entrypoints/array.full.es5.ts index 28c8c2d92..c79ffdc1b 100644 --- a/src/entrypoints/array.full.es5.ts +++ b/src/entrypoints/array.full.es5.ts @@ -3,8 +3,9 @@ // to allow es5/IE11 support // it doesn't include recorder which doesn't support IE11, -// and it doesn't include web-vitals which doesn't support IE11 +// and it doesn't include "web-vitals" that doesn't support IE11 +import 'core-js/features/object/entries' import 'core-js/features/object/from-entries' import './surveys' diff --git a/src/entrypoints/crisp-chat-integration.ts b/src/entrypoints/crisp-chat-integration.ts new file mode 100644 index 000000000..2c3dbd20d --- /dev/null +++ b/src/entrypoints/crisp-chat-integration.ts @@ -0,0 +1,53 @@ +import { PostHog } from '../posthog-core' +import { assignableWindow } from '../utils/globals' +import { createLogger } from '../utils/logger' + +const logger = createLogger('[PostHog Crisp Chat]') + +const reportedSessionIds = new Set() + +assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {} +assignableWindow.__PosthogExtensions__.integrations = assignableWindow.__PosthogExtensions__.integrations || {} +assignableWindow.__PosthogExtensions__.integrations.crispChat = { + start: (posthog: PostHog) => { + if (!posthog.config.integrations?.crispChat) { + return + } + + const crispChat = (assignableWindow as any).$crisp + if (!crispChat) { + logger.warn(' Crisp Chat not found while initializing the integration') + return + } + + const updateCrispChat = () => { + const replayUrl = posthog.get_session_replay_url() + const personUrl = posthog.requestRouter.endpointFor( + 'ui', + `/project/${posthog.config.token}/person/${posthog.get_distinct_id()}` + ) + + crispChat.push([ + 'set', + 'session:data', + [ + [ + ['posthogSessionURL', replayUrl], + ['posthogPersonURL', personUrl], + ], + ], + ]) + } + + // this is called immediately if there's a session id + // and then again whenever the session id changes + posthog.onSessionId((sessionId) => { + if (!reportedSessionIds.has(sessionId)) { + updateCrispChat() + reportedSessionIds.add(sessionId) + } + }) + + logger.info('integration started') + }, +} diff --git a/src/entrypoints/intercom-integration.ts b/src/entrypoints/intercom-integration.ts new file mode 100644 index 000000000..3193f378f --- /dev/null +++ b/src/entrypoints/intercom-integration.ts @@ -0,0 +1,48 @@ +import { PostHog } from '../posthog-core' +import { assignableWindow } from '../utils/globals' +import { createLogger } from '../utils/logger' + +const logger = createLogger('[PostHog Intercom integration]') + +const reportedSessionIds = new Set() + +assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {} +assignableWindow.__PosthogExtensions__.integrations = assignableWindow.__PosthogExtensions__.integrations || {} +assignableWindow.__PosthogExtensions__.integrations.intercom = { + start: (posthog: PostHog) => { + if (!posthog.config.integrations?.intercom) { + return + } + + const intercom = (assignableWindow as any).Intercom + if (!intercom) { + logger.warn(' Intercom not found while initializing the integration') + return + } + + const updateIntercom = () => { + const replayUrl = posthog.get_session_replay_url() + const personUrl = posthog.requestRouter.endpointFor( + 'ui', + `/project/${posthog.config.token}/person/${posthog.get_distinct_id()}` + ) + + intercom('update', { + latestPosthogReplayURL: replayUrl, + latestPosthogPersonURL: personUrl, + }) + intercom('trackEvent', 'posthog:sessionInfo', { replayUrl, personUrl }) + } + + // this is called immediately if there's a session id + // and then again whenever the session id changes + posthog.onSessionId((sessionId) => { + if (!reportedSessionIds.has(sessionId)) { + updateIntercom() + reportedSessionIds.add(sessionId) + } + }) + + logger.info('integration started') + }, +} diff --git a/src/extensions/external-integrations.ts b/src/extensions/external-integrations.ts new file mode 100644 index 000000000..9cda16001 --- /dev/null +++ b/src/extensions/external-integrations.ts @@ -0,0 +1,36 @@ +import { PostHog } from '../posthog-core' +import { ExternalIntegrationKind } from '../types' +import { assignableWindow, ExternalExtensionKind } from '../utils/globals' +import { createLogger } from '../utils/logger' + +const logger = createLogger('[PostHog ExternalIntegrations]') + +const MAPPED_INTEGRATIONS: Record = { + intercom: 'intercom-integration', + crispChat: 'crisp-chat-integration', +} + +export class ExternalIntegrations { + constructor(private readonly _instance: PostHog) {} + + private _loadScript(name: ExternalExtensionKind, cb: () => void): void { + assignableWindow.__PosthogExtensions__?.loadExternalDependency?.(this._instance, name, (err) => { + if (err) { + return logger.error('failed to load script', err) + } + cb() + }) + } + + public startIfEnabledOrStop() { + for (const [key, value] of Object.entries(this._instance.config.integrations ?? {})) { + if (value && !assignableWindow.__PosthogExtensions__?.integrations?.[key as ExternalIntegrationKind]) { + this._loadScript(MAPPED_INTEGRATIONS[key as ExternalIntegrationKind], () => { + assignableWindow.__PosthogExtensions__?.integrations?.[key as ExternalIntegrationKind]?.start( + this._instance + ) + }) + } + } + } +} diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 93fcf9122..947307660 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -14,6 +14,7 @@ import { import { DeadClicksAutocapture, isDeadClicksEnabledForAutocapture } from './extensions/dead-clicks-autocapture' import { ExceptionObserver } from './extensions/exception-autocapture' import { errorToProperties } from './extensions/exception-autocapture/error-conversion' +import { ExternalIntegrations } from './extensions/external-integrations' import { HistoryAutocapture } from './extensions/history-autocapture' import { SessionRecording } from './extensions/replay/sessionrecording' import { setupSegmentIntegration } from './extensions/segment-integration' @@ -295,6 +296,7 @@ export class PostHog { _requestQueue?: RequestQueue _retryQueue?: RetryQueue sessionRecording?: SessionRecording + externalIntegrations?: ExternalIntegrations webPerformance = new DeprecatedWebPerformanceObserver() _initialPageviewCaptured: boolean @@ -346,6 +348,7 @@ export class PostHog { this.rateLimiter = new RateLimiter(this) this.requestRouter = new RequestRouter(this) this.consent = new ConsentManager(this) + this.externalIntegrations = new ExternalIntegrations(this) // NOTE: See the property definition for deprecation notice this.people = { set: (prop: string | Properties, to?: string, callback?: RequestCallback) => { @@ -1903,6 +1906,7 @@ export class PostHog { this.heatmaps?.startIfEnabled() this.surveys.loadIfEnabled() this._sync_opt_out_with_persistence() + this.externalIntegrations?.startIfEnabledOrStop() } } diff --git a/src/types.ts b/src/types.ts index 2cec4a708..e68a3e82d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -277,6 +277,8 @@ export type BeforeSendFn = (cr: CaptureResult | null) => CaptureResult | null export type ConfigDefaults = '2025-05-24' | 'unset' +export type ExternalIntegrationKind = 'intercom' | 'crispChat' + /** * Configuration options for the PostHog JavaScript SDK. * @see https://posthog.com/docs/libraries/js#config @@ -930,6 +932,11 @@ export interface PostHogConfig { */ request_queue_config?: RequestQueueConfig + /** + * Used to set-up external integrations with PostHog data - such as session replays, distinct id, etc. + */ + integrations?: Record + // ------- PREVIEW CONFIGS ------- /** diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 9ea25d0cd..3681c42e9 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -1,11 +1,11 @@ import { ErrorProperties } from '../extensions/exception-autocapture/error-conversion' import type { PostHog } from '../posthog-core' import { SessionIdManager } from '../sessionid' -import { DeadClicksAutoCaptureConfig, RemoteConfig, SiteAppLoader } from '../types' +import { DeadClicksAutoCaptureConfig, ExternalIntegrationKind, RemoteConfig, SiteAppLoader } from '../types' /* * Global helpers to protect access to browser globals in a way that is safer for different targets - * like DOM, SSR, Web workers etc. + * like DOM, SSR, Web workers, etc. * * NOTE: Typically we want the "window" but globalThis works for both the typical browser context as * well as other contexts such as the web worker context. Window is still exported for any bits that explicitly require it. @@ -49,6 +49,7 @@ export type AssignableWindow = Window & * changes to this interface can be breaking changes for users of the SDK */ +export type ExternalExtensionKind = 'intercom-integration' | 'crisp-chat-integration' export type PostHogExtensionKind = | 'toolbar' | 'exception-autocapture' @@ -58,6 +59,7 @@ export type PostHogExtensionKind = | 'surveys' | 'dead-clicks-autocapture' | 'remote-config' + | ExternalExtensionKind export interface LazyLoadedDeadClicksAutocaptureInterface { start: (observerTarget: Node) => void @@ -95,6 +97,9 @@ interface PostHogExtensions { ph: PostHog, config: DeadClicksAutoCaptureConfig ) => LazyLoadedDeadClicksAutocaptureInterface + integrations?: { + [K in ExternalIntegrationKind]?: { start: (posthog: PostHog) => void } + } } const global: typeof globalThis | undefined = typeof globalThis !== 'undefined' ? globalThis : win