Skip to content
This repository was archived by the owner on Jul 29, 2025. It is now read-only.
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: 2 additions & 0 deletions posthog-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
},
"devDependencies": {
"@types/node": "^18.0.0",
"@sentry/types": "^8.17.0",
"@sentry/types-v7": "npm:@sentry/types@^7.119.2",
"commander": "^9.3.0"
},
"keywords": [
Expand Down
195 changes: 121 additions & 74 deletions posthog-node/src/extensions/sentry-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,70 @@
*/
import { type PostHog } from '../posthog-node'

// NOTE - we can't import from @sentry/types because it changes frequently and causes clashes
// We only use a small subset of the types, so we can just define the integration overall and use any for the rest

// import {
// Event as _SentryEvent,
// EventProcessor as _SentryEventProcessor,
// Exception as _SentryException,
// Hub as _SentryHub,
// Integration as _SentryIntegration,
// Primitive as _SentryPrimitive,
// } from '@sentry/types'

// Uncomment the above and comment the below to get type checking for development

type _SentryEvent = any
type _SentryEventProcessor = any
type _SentryHub = any
type _SentryException = any
type _SentryPrimitive = any

interface _SentryIntegration {
name: string
setupOnce(addGlobalEventProcessor: (callback: _SentryEventProcessor) => void, getCurrentHub: () => _SentryHub): void
}
import * as SentryTypesV7 from '@sentry/types-v7'
import * as SentryTypesV8 from '@sentry/types'

interface PostHogSentryExceptionProperties {
$sentry_event_id?: string
$sentry_exception?: { values?: _SentryException[] }
$sentry_exception?: { values?: SentryTypesV7.Exception[] }
$sentry_exception_message?: string
$sentry_exception_type?: string
$sentry_tags: { [key: string]: _SentryPrimitive }
$sentry_tags: { [key: string]: SentryTypesV7.Primitive }
$sentry_url?: string
$exception_type?: string
$exception_message?: string
$exception_personURL?: string
}

const NAME = 'posthog-node'

/**
* This is for Sentry v8 only.
*
* Integrate Sentry with PostHog. This will add a direct link to the person in Sentry, and an $exception event in PostHog.
*
* ### Usage
*
* Sentry.init({
* dsn: 'https://example',
* integrations: [
* posthogSentryIntegration(posthog)
* ]
* })
*
* Sentry.setTag(PostHogSentryIntegration.POSTHOG_ID_TAG, 'some distinct id');
*
* @param {Object} [posthog] The posthog object
* @param {string} [posthogHost] Optional: The PostHog host (default: `posthog.com`)
* @param {string} [organization] Optional: The Sentry organization, used to send a direct link from PostHog to Sentry
* @param {string} [prefix] Optional: Url of a self-hosted sentry instance (default: https://sentry.io/organizations/)
*/
export function posthogSentryIntegration(
posthog: PostHog,
posthogHost?: string,
organization?: string,
prefix?: string
): SentryTypesV8.Integration {
return {
name: NAME,
setup(client) {
const projectId = client?.getDsn()?.projectId
client.addEventProcessor(
// @ts-expect-error - EventProcessor in v7 and v8 actually share the same type
createEventProcessor(posthog, {
projectId,
posthogHost,
organization,
prefix,
})
)
},
}
}

/**
* This is for Sentry v7 only.
*
* Integrate Sentry with PostHog. This will add a direct link to the person in Sentry, and an $exception event in PostHog.
*
* ### Usage
Expand All @@ -59,7 +85,7 @@ interface PostHogSentryExceptionProperties {
* @param {Number} [projectId] Optional: The Sentry project id, used to send a direct link from PostHog to Sentry
* @param {string} [prefix] Optional: Url of a self-hosted sentry instance (default: https://sentry.io/organizations/)
*/
export class PostHogSentryIntegration implements _SentryIntegration {
export class PostHogSentryIntegration implements SentryTypesV7.Integration {
public readonly name = 'posthog-node'

public static readonly POSTHOG_ID_TAG = 'posthog_distinct_id'
Expand All @@ -69,57 +95,78 @@ export class PostHogSentryIntegration implements _SentryIntegration {
private readonly posthogHost?: string,
private readonly organization?: string,
private readonly prefix?: string
) {
this.posthogHost = posthog.options.host ?? 'https://us.i.posthog.com'
}
) {}

public setupOnce(
addGlobalEventProcessor: (callback: _SentryEventProcessor) => void,
getCurrentHub: () => _SentryHub
addGlobalEventProcessor: (callback: SentryTypesV7.EventProcessor) => void,
getCurrentHub: () => SentryTypesV7.Hub
): void {
addGlobalEventProcessor((event: _SentryEvent): _SentryEvent => {
if (event.exception?.values === undefined || event.exception.values.length === 0) {
return event
}

if (!event.tags) {
event.tags = {}
}

const sentry = getCurrentHub()

// Get the PostHog user ID from a specific tag, which users can set on their Sentry scope as they need.
const userId = event.tags[PostHogSentryIntegration.POSTHOG_ID_TAG]
if (userId === undefined) {
// If we can't find a user ID, don't bother linking the event. We won't be able to send anything meaningful to PostHog without it.
return event
}

event.tags['PostHog Person URL'] = new URL(`/person/${userId}`, this.posthogHost).toString()

const properties: PostHogSentryExceptionProperties = {
// PostHog Exception Properties
$exception_message: event.exception.values[0]?.value,
$exception_type: event.exception.values[0]?.type,
$exception_personURL: event.tags['PostHog Person URL'],
// Sentry Exception Properties
$sentry_event_id: event.event_id,
$sentry_exception: event.exception,
$sentry_exception_message: event.exception.values[0]?.value,
$sentry_exception_type: event.exception.values[0]?.type,
$sentry_tags: event.tags,
}

const projectId = sentry.getClient()?.getDsn()?.projectId
if (this.organization !== undefined && projectId !== undefined && event.event_id !== undefined) {
properties.$sentry_url = `${this.prefix ?? 'https://sentry.io/organizations'}/${
this.organization
}/issues/?project=${projectId}&query=${event.event_id}`
}

this.posthog.capture({ event: '$exception', distinctId: userId, properties })
const projectId = getCurrentHub().getClient()?.getDsn()?.projectId
addGlobalEventProcessor(
createEventProcessor(this.posthog, {
projectId,
posthogHost: this.posthogHost,
organization: this.organization,
prefix: this.prefix,
})
)
}
}

function createEventProcessor(
posthog: PostHog,
{
projectId,
posthogHost,
organization,
prefix,
}: {
projectId?: string
posthogHost?: string
organization?: string
prefix?: string
}
): SentryTypesV7.EventProcessor {
posthogHost = posthogHost ?? posthog.options.host ?? 'https://us.i.posthog.com'
return (event) => {
if (event.exception?.values === undefined || event.exception.values.length === 0) {
return event
}

if (!event.tags) {
event.tags = {}
}

// Get the PostHog user ID from a specific tag, which users can set on their Sentry scope as they need.
const userId = event.tags[PostHogSentryIntegration.POSTHOG_ID_TAG]?.toString()
if (userId === undefined) {
// If we can't find a user ID, don't bother linking the event. We won't be able to send anything meaningful to PostHog without it.
return event
})
}

event.tags['PostHog Person URL'] = new URL(`/person/${userId}`, posthogHost).toString()

const properties: PostHogSentryExceptionProperties = {
// PostHog Exception Properties
$exception_message: event.exception.values[0]?.value,
$exception_type: event.exception.values[0]?.type,
$exception_personURL: event.tags['PostHog Person URL'],
// Sentry Exception Properties
$sentry_event_id: event.event_id,
$sentry_exception: event.exception,
$sentry_exception_message: event.exception.values[0]?.value,
$sentry_exception_type: event.exception.values[0]?.type,
$sentry_tags: event.tags,
}

if (organization !== undefined && projectId !== undefined && event.event_id !== undefined) {
properties.$sentry_url = `${
prefix ?? 'https://sentry.io/organizations'
}/${organization}/issues/?project=${projectId}&query=${event.event_id}`
}

posthog.capture({ event: '$exception', distinctId: userId, properties })

return event
}
}
29 changes: 21 additions & 8 deletions posthog-node/test/extensions/sentry-integration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// import { PostHog } from '../'
import { PostHog as PostHog } from '../../src/posthog-node'
import { PostHogSentryIntegration } from '../../src/extensions/sentry-integration'
import { posthogSentryIntegration, PostHogSentryIntegration } from '../../src/extensions/sentry-integration'
import * as SentryTypesV8 from '@sentry/types'
jest.mock('../../src/fetch')
import fetch from '../../src/fetch'
import { waitForPromises } from 'posthog-core/test/test-utils/test-utils'
Expand Down Expand Up @@ -62,7 +63,8 @@ const createMockSentryException = (): any => ({

describe('PostHogSentryIntegration', () => {
let posthog: PostHog
let posthogSentry: PostHogSentryIntegration
let posthogSentryV8: SentryTypesV8.Integration
let posthogSentryV7: PostHogSentryIntegration

jest.useFakeTimers()

Expand All @@ -72,7 +74,8 @@ describe('PostHogSentryIntegration', () => {
fetchRetryCount: 0,
})

posthogSentry = new PostHogSentryIntegration(posthog)
posthogSentryV7 = new PostHogSentryIntegration(posthog)
posthogSentryV8 = posthogSentryIntegration(posthog)

mockedFetch.mockResolvedValue({
status: 200,
Expand All @@ -92,20 +95,30 @@ describe('PostHogSentryIntegration', () => {
it('should forward sentry exceptions to posthog', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)

const mockSentry = {
let processorFunction: any
const mockSentryV7 = {
getClient: () => ({
getDsn: () => ({
projectId: 123,
}),
}),
}
const mockSentryV8 = {
getDsn: () => ({
projectId: 123,
}),
addEventProcessor: (fn: any) => {
processorFunction = fn
},
}

let processorFunction: any

posthogSentry.setupOnce(
posthogSentryV7.setupOnce(
(fn) => (processorFunction = fn),
() => mockSentry
// @ts-expect-error - we're mocking the Sentry integration
() => mockSentryV7
)
// @ts-expect-error - we're mocking the Sentry integration
posthogSentryV8.setup(mockSentryV8)

processorFunction(createMockSentryException())

Expand Down
41 changes: 13 additions & 28 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2411,6 +2411,16 @@
component-type "^1.2.1"
join-component "^1.1.0"

"@sentry/types-v7@npm:@sentry/types@^7.119.2":
version "7.119.2"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.119.2.tgz#e4c6b94ff868f45d6c0ed0f3f3e90e115d8be5cc"
integrity sha512-ydq1tWsdG7QW+yFaTp0gFaowMLNVikIqM70wxWNK+u98QzKnVY/3XTixxNLsUtnAB4Y+isAzFhrc6Vb5GFdFeg==

"@sentry/types@^8.17.0":
version "8.34.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-8.34.0.tgz#b02da72d1be67df5246aa9a97ca661ee71569372"
integrity sha512-zLRc60CzohGCo6zNsNeQ9JF3SiEeRE4aDCP9fDDdIVCOKovS+mn1rtSip0qd0Vp2fidOu0+2yY0ALCz1A3PJSQ==

"@sideway/address@^4.1.5":
version "4.1.5"
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
Expand Down Expand Up @@ -9731,16 +9741,7 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"

"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"

string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
Expand Down Expand Up @@ -9826,7 +9827,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
Expand All @@ -9840,13 +9841,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"

strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"

strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
Expand Down Expand Up @@ -10730,7 +10724,7 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==

"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
Expand All @@ -10748,15 +10742,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
Expand Down