From cca013af1a4d77ea82c4e65cbe62bd0e879570fa Mon Sep 17 00:00:00 2001 From: Ben White Date: Tue, 2 Jan 2024 15:52:38 +0100 Subject: [PATCH] feat: Sentry integration (#141) --- examples/example-node/package.json | 1 + examples/example-node/server.ts | 18 ++- examples/example-node/yarn.lock | 69 +++++++- posthog-node/CHANGELOG.md | 4 + posthog-node/index.ts | 1 + posthog-node/package.json | 6 +- .../src/extensions/sentry-integration.ts | 125 +++++++++++++++ posthog-node/src/posthog-node.ts | 2 +- .../extensions/sentry-integration.spec.ts | 150 ++++++++++++++++++ 9 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 posthog-node/src/extensions/sentry-integration.ts create mode 100644 posthog-node/test/extensions/sentry-integration.spec.ts diff --git a/examples/example-node/package.json b/examples/example-node/package.json index 737bb0b5..13af4c11 100644 --- a/examples/example-node/package.json +++ b/examples/example-node/package.json @@ -13,6 +13,7 @@ "example": "ts-node example.ts" }, "dependencies": { + "@sentry/node": "^7.91.0", "express": "^4.18.1", "posthog-node": "file:.yalc/posthog-node", "undici": "^5.8.0" diff --git a/examples/example-node/server.ts b/examples/example-node/server.ts index 0b7452fc..c4946490 100644 --- a/examples/example-node/server.ts +++ b/examples/example-node/server.ts @@ -1,7 +1,9 @@ import express from 'express' -import { PostHog } from 'posthog-node' +import { PostHog, PostHogSentryIntegration } from 'posthog-node' import undici from 'undici' +import * as Sentry from '@sentry/node' + const app = express() const { @@ -23,11 +25,25 @@ const posthog = new PostHog(PH_API_KEY, { posthog.debug() +Sentry.init({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + integrations: [new PostHogSentryIntegration(posthog)], +}) + app.get('/', (req, res) => { posthog.capture({ distinctId: 'EXAMPLE_APP_GLOBAL', event: 'legacy capture' }) res.send({ hello: 'world' }) }) +app.get('/error', (req, res) => { + Sentry.captureException(new Error('example error'), { + tags: { + [PostHogSentryIntegration.POSTHOG_ID_TAG]: 'EXAMPLE_APP_GLOBAL', + }, + }) + res.send({ status: 'error!!' }) +}) + app.get('/user/:userId/action', (req, res) => { posthog.capture({ distinctId: req.params.userId, event: 'user did action', properties: req.params }) diff --git a/examples/example-node/yarn.lock b/examples/example-node/yarn.lock index d99eb152..b75e8835 100644 --- a/examples/example-node/yarn.lock +++ b/examples/example-node/yarn.lock @@ -109,6 +109,46 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@sentry-internal/tracing@7.91.0": + version "7.91.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.91.0.tgz#fbb6e1e3383e1eeee08633384e004da73ac1c37d" + integrity sha512-JH5y6gs6BS0its7WF2DhySu7nkhPDfZcdpAXldxzIlJpqFkuwQKLU5nkYJpiIyZz1NHYYtW5aum2bV2oCOdDRA== + dependencies: + "@sentry/core" "7.91.0" + "@sentry/types" "7.91.0" + "@sentry/utils" "7.91.0" + +"@sentry/core@7.91.0": + version "7.91.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.91.0.tgz#229334d7f03dd5d90a17495e61ce4215ab730b2a" + integrity sha512-tu+gYq4JrTdrR+YSh5IVHF0fJi/Pi9y0HZ5H9HnYy+UMcXIotxf6hIEaC6ZKGeLWkGXffz2gKpQLe/g6vy/lPA== + dependencies: + "@sentry/types" "7.91.0" + "@sentry/utils" "7.91.0" + +"@sentry/node@^7.91.0": + version "7.91.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.91.0.tgz#26bf13c3daf988f9725afd1a3cc38ba2ff90d62a" + integrity sha512-hTIfSQxD7L+AKIqyjoq8CWBRkEQrrMZmA3GSZgPI5JFWBHgO0HBo5TH/8TU81oEJh6kqqHAl2ObMhmcnaFqlzg== + dependencies: + "@sentry-internal/tracing" "7.91.0" + "@sentry/core" "7.91.0" + "@sentry/types" "7.91.0" + "@sentry/utils" "7.91.0" + https-proxy-agent "^5.0.0" + +"@sentry/types@7.91.0": + version "7.91.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.91.0.tgz#5b68954e08986fecb0d4bef168df58eef62c32c7" + integrity sha512-bcQnb7J3P3equbCUc+sPuHog2Y47yGD2sCkzmnZBjvBT0Z1B4f36fI/5WjyZhTjLSiOdg3F2otwvikbMjmBDew== + +"@sentry/utils@7.91.0": + version "7.91.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.91.0.tgz#3b1a94c053c885877908cd3e1365e3d23e21a73f" + integrity sha512-fvxjrEbk6T6Otu++Ax9ntlQ0sGRiwSC179w68aC3u26Wr30FAIRKqHTCCdc2jyWk7Gd9uWRT/cq+g8NG/8BfSg== + dependencies: + "@sentry/types" "7.91.0" + "@tsconfig/node10@^1.0.7": version "1.0.9" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" @@ -248,6 +288,13 @@ acorn@^8.4.1, acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.2.tgz#ca0d78b51895be5390a5903c5b3bdcdaf78ae40b" integrity sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -290,10 +337,10 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -axios@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102" - integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg== +axios@^1.6.2: + version "1.6.3" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.3.tgz#7f50f23b3aa246eff43c54834272346c396613f4" + integrity sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww== dependencies: follow-redirects "^1.15.0" form-data "^4.0.0" @@ -424,7 +471,7 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@^4.1.1, debug@^4.3.2: +debug@4, debug@^4.1.1, debug@^4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -810,6 +857,14 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -1071,9 +1126,9 @@ path-to-regexp@0.1.7: integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== "posthog-node@file:.yalc/posthog-node": - version "3.1.2" + version "3.2.1" dependencies: - axios "^1.6.0" + axios "^1.6.2" rusha "^0.8.14" prelude-ls@^1.2.1: diff --git a/posthog-node/CHANGELOG.md b/posthog-node/CHANGELOG.md index b6bff7ee..2040bd87 100644 --- a/posthog-node/CHANGELOG.md +++ b/posthog-node/CHANGELOG.md @@ -1,3 +1,7 @@ +# 3.3.0 - 2024-01-02 + +1. Adds PostHogSentryIntegration to allow automatic capturing of exceptions reported via the @sentry/node package + # 3.2.1 - 2023-12-15 1. Fixes issue where a background refresh of feature flags could throw an unhandled error. It now emits to be detected by `.on('error', ...)` diff --git a/posthog-node/index.ts b/posthog-node/index.ts index 04ba3961..537c2396 100644 --- a/posthog-node/index.ts +++ b/posthog-node/index.ts @@ -1 +1,2 @@ export * from './src/posthog-node' +export * from './src/extensions/sentry-integration' diff --git a/posthog-node/package.json b/posthog-node/package.json index d0098e1d..558ebb0c 100644 --- a/posthog-node/package.json +++ b/posthog-node/package.json @@ -1,10 +1,10 @@ { "name": "posthog-node", - "version": "3.2.1", + "version": "3.3.0", "description": "PostHog Node.js integration", "repository": { - "type" : "git", - "url" : "https://github.com/PostHog/posthog-js-lite.git", + "type": "git", + "url": "https://github.com/PostHog/posthog-js-lite.git", "directory": "posthog-node" }, "scripts": { diff --git a/posthog-node/src/extensions/sentry-integration.ts b/posthog-node/src/extensions/sentry-integration.ts new file mode 100644 index 00000000..2364c2ce --- /dev/null +++ b/posthog-node/src/extensions/sentry-integration.ts @@ -0,0 +1,125 @@ +/** + * @file Adapted from [posthog-js](https://github.com/PostHog/posthog-js/blob/8157df935a4d0e71d2fefef7127aa85ee51c82d1/src/extensions/sentry-integration.ts) with modifications for the Node SDK. + */ +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 +} + +interface PostHogSentryExceptionProperties { + $sentry_event_id?: string + $sentry_exception?: { values?: _SentryException[] } + $sentry_exception_message?: string + $sentry_exception_type?: string + $sentry_tags: { [key: string]: _SentryPrimitive } + $sentry_url?: string + $exception_type?: string + $exception_message?: string + $exception_personURL?: string +} + +/** + * 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: [ + * new PostHogSentryIntegration(posthog) + * ] + * }) + * + * Sentry.setTag(PostHogSentryIntegration.POSTHOG_ID_TAG, 'some distinct id'); + * + * @param {Object} [posthog] The posthog object + * @param {string} [organization] Optional: The Sentry organization, used to send a direct link from PostHog to Sentry + * @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 { + public readonly name = 'posthog-node' + + public static readonly POSTHOG_ID_TAG = 'posthog_distinct_id' + + public constructor( + private readonly posthog: PostHog, + private readonly posthogHost?: string, + private readonly organization?: string, + private readonly prefix?: string + ) { + this.posthogHost = posthog.options.host ?? 'https://app.posthog.com' + } + + public setupOnce( + addGlobalEventProcessor: (callback: _SentryEventProcessor) => void, + getCurrentHub: () => _SentryHub + ): 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 }) + + return event + }) + } +} diff --git a/posthog-node/src/posthog-node.ts b/posthog-node/src/posthog-node.ts index a6a5520e..8e7caac6 100644 --- a/posthog-node/src/posthog-node.ts +++ b/posthog-node/src/posthog-node.ts @@ -35,7 +35,7 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 { private featureFlagsPoller?: FeatureFlagsPoller private maxCacheSize: number - private options: PostHogOptions + public readonly options: PostHogOptions distinctIdHasSentFlagCalls: Record diff --git a/posthog-node/test/extensions/sentry-integration.spec.ts b/posthog-node/test/extensions/sentry-integration.spec.ts new file mode 100644 index 00000000..3c4f86a2 --- /dev/null +++ b/posthog-node/test/extensions/sentry-integration.spec.ts @@ -0,0 +1,150 @@ +// import { PostHog } from '../' +import { PostHog as PostHog } from '../../src/posthog-node' +import { PostHogSentryIntegration } from '../../src/extensions/sentry-integration' +jest.mock('../../src/fetch') +import fetch from '../../src/fetch' + +jest.mock('../../package.json', () => ({ version: '1.2.3' })) + +const mockedFetch = jest.mocked(fetch, true) + +const getLastBatchEvents = (): any[] | undefined => { + expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.objectContaining({ method: 'POST' })) + + // reverse mock calls array to get the last call + const call = mockedFetch.mock.calls.reverse().find((x) => (x[0] as string).includes('/batch/')) + if (!call) { + return undefined + } + return JSON.parse((call[1] as any).body as any).batch +} + +const createMockSentryException = (): any => ({ + exception: { + values: [ + { + type: 'Error', + value: 'example error', + stacktrace: { + frames: [], + }, + mechanism: { type: 'generic', handled: true }, + }, + ], + }, + event_id: '80a7023ac32c47f7acb0adaed600d149', + platform: 'node', + contexts: {}, + server_name: 'localhost', + timestamp: 1704203482.356, + environment: 'production', + tags: { posthog_distinct_id: 'EXAMPLE_APP_GLOBAL' }, + breadcrumbs: [ + { + timestamp: 1704203481.422, + category: 'console', + level: 'log', + message: '⚡: Server is running at http://localhost:8010', + }, + { + timestamp: 1704203481.658, + category: 'console', + level: 'log', + message: + "PostHog Debug error [ClientError: Your personalApiKey is invalid. Are you sure you're not using your Project API key? More information: https://posthog.com/docs/api/overview]", + }, + ], + sdkProcessingMetadata: { + propagationContext: { traceId: 'ea26146e5a354cb0b3b1daebb3f90e33', spanId: '8d642089c3daa272' }, + }, +}) + +describe('PostHogSentryIntegration', () => { + let posthog: PostHog + let posthogSentry: PostHogSentryIntegration + + jest.useFakeTimers() + + beforeEach(() => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + fetchRetryCount: 0, + }) + + posthogSentry = new PostHogSentryIntegration(posthog) + + mockedFetch.mockResolvedValue({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + status: 'ok', + }), + } as any) + }) + + afterEach(async () => { + // ensure clean shutdown & no test interdependencies + await posthog.shutdownAsync() + }) + + it('should forward sentry exceptions to posthog', async () => { + expect(mockedFetch).toHaveBeenCalledTimes(0) + + const mockSentry = { + getClient: () => ({ + getDsn: () => ({ + projectId: 123, + }), + }), + } + + let processorFunction: any + + posthogSentry.setupOnce( + (fn) => (processorFunction = fn), + () => mockSentry + ) + + processorFunction(createMockSentryException()) + + jest.runOnlyPendingTimers() + const batchEvents = getLastBatchEvents() + + expect(batchEvents).toEqual([ + { + distinct_id: 'EXAMPLE_APP_GLOBAL', + event: '$exception', + properties: { + $exception_message: 'example error', + $exception_type: 'Error', + $exception_personURL: 'http://example.com/person/EXAMPLE_APP_GLOBAL', + $sentry_event_id: '80a7023ac32c47f7acb0adaed600d149', + $sentry_exception: { + values: [ + { + type: 'Error', + value: 'example error', + stacktrace: { frames: [] }, + mechanism: { type: 'generic', handled: true }, + }, + ], + }, + $sentry_exception_message: 'example error', + $sentry_exception_type: 'Error', + $sentry_tags: { + posthog_distinct_id: 'EXAMPLE_APP_GLOBAL', + 'PostHog Person URL': 'http://example.com/person/EXAMPLE_APP_GLOBAL', + }, + $lib: 'posthog-node', + $lib_version: '1.2.3', + $geoip_disable: true, + }, + type: 'capture', + library: 'posthog-node', + library_version: '1.2.3', + timestamp: expect.any(String), + }, + ]) + }) +})