Skip to content

feat: push replay and person links into external integrations #1980

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 20 commits into from
Closed
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
2 changes: 1 addition & 1 deletion eslint-rules/no-external-replay-imports.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions playground/nextjs/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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};
`}
/>
</Head>
Expand Down
42 changes: 42 additions & 0 deletions playground/nextjs/pages/external_chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Script from 'next/script'

export default function ExternalChat() {
return (
<div>
<h1>ExternalChat</h1>
<p>This is a page for testing external chat widgets</p>

{/* Intercom Settings Script */}
<Script id="intercom-settings" strategy="beforeInteractive">
{`
window.intercomSettings = {
api_base: "https://api-iam.intercom.io",
app_id: "cviln1h1",
};
`}
</Script>

{/* Intercom Widget Script */}
<Script id="intercom-widget" strategy="afterInteractive">
{`
(function(){var w=window;var ic=w.Intercom;if(typeof ic==="function"){ic('reattach_activator');ic('update',w.intercomSettings);}else{var d=document;var i=function(){i.c(arguments);};i.q=[];i.c=function(args){i.q.push(args);};w.Intercom=i;var l=function(){var s=d.createElement('script');s.type='text/javascript';s.async=true;s.src='https://widget.intercom.io/widget/cviln1h1';var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s,x);};if(document.readyState==='complete'){l();}else if(w.attachEvent){w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}}})();
`}
</Script>

{/* Crisp chat Script */}
<Script id="crisp-chat" strategy="afterInteractive">
{`
window.$crisp=[];
window.CRISP_WEBSITE_ID="8a81621c-0ed1-4d1f-b552-9404db8effd5";
(function(){
d = document;
s = d.createElement("script");
s.src = "https://client.crisp.chat/l.js";
s.async = 1;
d.getElementsByTagName("head")[0].appendChild(s);
})();
`}
</Script>
</div>
)
}
1 change: 1 addition & 0 deletions playground/nextjs/src/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const PageHeader = () => {
<Link href="/replay-examples/long">Long</Link>
<Link href="/ecommerce">E-commerce</Link>
<Link href="/toolbar-tests">Toolbar Tests</Link>
<Link href="/external_chat">External Chat</Link>
</div>

<div>
Expand Down
4 changes: 4 additions & 0 deletions playground/nextjs/src/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/__snapshots__/config-snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,10 @@ exports[`config snapshot for PostHogConfig 1`] = `
]
}
],
\\"integrations\\": [
\\"undefined\\",
\\"Record<ExternalIntegrationKind, boolean>\\"
],
\\"__add_tracing_headers\\": [
\\"undefined\\",
\\"false\\",
Expand Down
3 changes: 2 additions & 1 deletion src/entrypoints/array.full.es5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
53 changes: 53 additions & 0 deletions src/entrypoints/crisp-chat-integration.ts
Original file line number Diff line number Diff line change
@@ -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<string>()

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')
},
}
48 changes: 48 additions & 0 deletions src/entrypoints/intercom-integration.ts
Original file line number Diff line number Diff line change
@@ -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<string>()

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')
},
}
36 changes: 36 additions & 0 deletions src/extensions/external-integrations.ts
Original file line number Diff line number Diff line change
@@ -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<ExternalIntegrationKind, ExternalExtensionKind> = {
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 ?? {})) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ie11 giving

JavaScript error details:
TypeError: Object doesn't support property or method 'entries'
at t.startIfEnabledOrStop

but we polyfill it https://github.com/PostHog/posthog-js/pull/1980/files#diff-001e332a80712cf09955389a80c7dd2eaf800699391a5145792eb41337dd18eeR8

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! This is Object.entries not Object.fromEntries

dangnammit

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, before this polyfill we had

dist/array.full.es5.js | 300 kB | +914 B (+0.31%)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dist/array.full.es5.js | 301 kB | +1.73 kB (+0.58%)

not too bad... assuming it works

if (value && !assignableWindow.__PosthogExtensions__?.integrations?.[key as ExternalIntegrationKind]) {
this._loadScript(MAPPED_INTEGRATIONS[key as ExternalIntegrationKind], () => {
assignableWindow.__PosthogExtensions__?.integrations?.[key as ExternalIntegrationKind]?.start(
this._instance
)
})
}
}
}
}
4 changes: 4 additions & 0 deletions src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -295,6 +296,7 @@ export class PostHog {
_requestQueue?: RequestQueue
_retryQueue?: RetryQueue
sessionRecording?: SessionRecording
externalIntegrations?: ExternalIntegrations
webPerformance = new DeprecatedWebPerformanceObserver()

_initialPageviewCaptured: boolean
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -1903,6 +1906,7 @@ export class PostHog {
this.heatmaps?.startIfEnabled()
this.surveys.loadIfEnabled()
this._sync_opt_out_with_persistence()
this.externalIntegrations?.startIfEnabledOrStop()
}
}

Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ExternalIntegrationKind, boolean>

// ------- PREVIEW CONFIGS -------

/**
Expand Down
9 changes: 7 additions & 2 deletions src/utils/globals.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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'
Expand All @@ -58,6 +59,7 @@ export type PostHogExtensionKind =
| 'surveys'
| 'dead-clicks-autocapture'
| 'remote-config'
| ExternalExtensionKind

export interface LazyLoadedDeadClicksAutocaptureInterface {
start: (observerTarget: Node) => void
Expand Down Expand Up @@ -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
Expand Down