Skip to content

feat: push replay and person data to intercom and crisp chat #2039

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ These dependencies are marked as optional to reduce installation size for users

##

## Testing
## Testing posthog-js

> [!NOTE]
> Run `pnpm build` at least once before running tests.
> Run `pnpm --filter=posthog-js build` at least once before running tests.


- Unit tests: run `pnpm test`.
- Cypress: run `pnpm start` to have a test server running and separately `pnpm cypress` to launch Cypress test engine.
- Playwright: run e.g. `pnpm exec playwright test --ui --project webkit --project firefox` to run with UI and in webkit and firefox
- Unit tests: run `pnpm --filter=posthog-js test`.

- Playwright: run `pnpm --filter=posthog-js start` to have a test server running and then run e.g. `pnpm --filter=posthog-js exec playwright test --ui --project webkit --project firefox` to run with UI and in webkit and firefox

### Running TestCafe E2E tests with BrowserStack

Expand Down Expand Up @@ -83,7 +83,7 @@ We have 2 options for linking this project to your local version: via [pnpm link
#### local paths (preferred)

- from whichever repo needs to require `posthog-js`, go to the `package.json` of that file, and replace the `posthog-js` dependency version number with `file:<relative_or_absolute_path_to_local_module>`
- e.g. from the `package.json` within `posthog`, replace `"posthog-js": "1.131.4"` with `"posthog-js": "file:../posthog-js"`
- e.g. from the `package.json` within `posthog`, replace `"posthog-js": "1.131.4"` with `"posthog-js": "file:/Users/yourhomefolder/github/posthog-js/packages/browser"`
- run `pnpm install` from the root of the project in which you just created a local path

Then, once this link has been created, any time you need to make a change to `posthog-js`, you can run `pnpm build` from the `posthog-js` root and the changes will appear in the other repo.
Expand Down
21 changes: 16 additions & 5 deletions packages/browser/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 packages/browser/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 packages/browser/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 packages/browser/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
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,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 packages/browser/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" which doesn't support IE11

import 'core-js/features/object/entries'
import 'core-js/features/object/from-entries'

import './surveys'
Expand Down
58 changes: 58 additions & 0 deletions packages/browser/src/entrypoints/crisp-chat-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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>()
let sessionIdListenerUnsubscribe: undefined | (() => void) = undefined

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
sessionIdListenerUnsubscribe = posthog.onSessionId((sessionId) => {
if (!reportedSessionIds.has(sessionId)) {
updateCrispChat()
reportedSessionIds.add(sessionId)
}
})

logger.info('integration started')
},
stop: () => {
sessionIdListenerUnsubscribe?.()
sessionIdListenerUnsubscribe = undefined
},
}
53 changes: 53 additions & 0 deletions packages/browser/src/entrypoints/intercom-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 Intercom integration]')

const reportedSessionIds = new Set<string>()
let sessionIdListenerUnsubscribe: undefined | (() => void) = undefined

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
sessionIdListenerUnsubscribe = posthog.onSessionId((sessionId) => {
if (!reportedSessionIds.has(sessionId)) {
updateIntercom()
reportedSessionIds.add(sessionId)
}
})

logger.info('integration started')
},
stop: () => {
sessionIdListenerUnsubscribe?.()
sessionIdListenerUnsubscribe = undefined
},
}
41 changes: 41 additions & 0 deletions packages/browser/src/extensions/external-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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 ?? {})) {
// if the integration is enabled, and not present, then load it
if (value && !assignableWindow.__PosthogExtensions__?.integrations?.[key as ExternalIntegrationKind]) {
this._loadScript(MAPPED_INTEGRATIONS[key as ExternalIntegrationKind], () => {
assignableWindow.__PosthogExtensions__?.integrations?.[key as ExternalIntegrationKind]?.start(
this._instance
)
})
}
// if the integration is disabled, and present, then stop it
if (!value && assignableWindow.__PosthogExtensions__?.integrations?.[key as ExternalIntegrationKind]) {
assignableWindow.__PosthogExtensions__?.integrations?.[key as ExternalIntegrationKind]?.stop()
}
}
}
}
4 changes: 4 additions & 0 deletions packages/browser/src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
} from './utils/type-utils'
import { uuidv7 } from './uuidv7'
import { WebExperiments } from './web-experiments'
import { ExternalIntegrations } from './extensions/external-integration'

/*
SIMPLE STYLE GUIDE:
Expand Down Expand Up @@ -299,6 +300,7 @@ export class PostHog {
_requestQueue?: RequestQueue
_retryQueue?: RetryQueue
sessionRecording?: SessionRecording
externalIntegrations?: ExternalIntegrations
webPerformance = new DeprecatedWebPerformanceObserver()

_initialPageviewCaptured: boolean
Expand Down Expand Up @@ -355,6 +357,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 @@ -1912,6 +1915,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 packages/browser/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 @@ -958,6 +960,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
8 changes: 7 additions & 1 deletion packages/browser/src/utils/globals.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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
Expand Down Expand Up @@ -49,6 +49,8 @@ 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 +60,7 @@ export type PostHogExtensionKind =
| 'surveys'
| 'dead-clicks-autocapture'
| 'remote-config'
| ExternalExtensionKind

export interface LazyLoadedDeadClicksAutocaptureInterface {
start: (observerTarget: Node) => void
Expand Down Expand Up @@ -95,6 +98,9 @@ interface PostHogExtensions {
ph: PostHog,
config: DeadClicksAutoCaptureConfig
) => LazyLoadedDeadClicksAutocaptureInterface
integrations?: {
[K in ExternalIntegrationKind]?: { start: (posthog: PostHog) => void; stop: () => void }
}
}

const global: typeof globalThis | undefined = typeof globalThis !== 'undefined' ? globalThis : win
Expand Down
Loading