posthog-node 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1,2 @@
1
1
  export * from './src/posthog-node';
2
+ export * from './src/extensions/sentry-integration';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file Adapted from [posthog-js](https://github.com/PostHog/posthog-js/blob/8157df935a4d0e71d2fefef7127aa85ee51c82d1/src/extensions/sentry-integration.ts) with modifications for the Node SDK.
3
+ */
4
+ import { type PostHog } from '../posthog-node';
5
+ declare type _SentryEventProcessor = any;
6
+ declare type _SentryHub = any;
7
+ interface _SentryIntegration {
8
+ name: string;
9
+ setupOnce(addGlobalEventProcessor: (callback: _SentryEventProcessor) => void, getCurrentHub: () => _SentryHub): void;
10
+ }
11
+ /**
12
+ * Integrate Sentry with PostHog. This will add a direct link to the person in Sentry, and an $exception event in PostHog.
13
+ *
14
+ * ### Usage
15
+ *
16
+ * Sentry.init({
17
+ * dsn: 'https://example',
18
+ * integrations: [
19
+ * new PostHogSentryIntegration(posthog)
20
+ * ]
21
+ * })
22
+ *
23
+ * Sentry.setTag(PostHogSentryIntegration.POSTHOG_ID_TAG, 'some distinct id');
24
+ *
25
+ * @param {Object} [posthog] The posthog object
26
+ * @param {string} [organization] Optional: The Sentry organization, used to send a direct link from PostHog to Sentry
27
+ * @param {Number} [projectId] Optional: The Sentry project id, used to send a direct link from PostHog to Sentry
28
+ * @param {string} [prefix] Optional: Url of a self-hosted sentry instance (default: https://sentry.io/organizations/)
29
+ */
30
+ export declare class PostHogSentryIntegration implements _SentryIntegration {
31
+ private readonly posthog;
32
+ private readonly posthogHost?;
33
+ private readonly organization?;
34
+ private readonly prefix?;
35
+ readonly name = "posthog-node";
36
+ static readonly POSTHOG_ID_TAG = "posthog_distinct_id";
37
+ constructor(posthog: PostHog, posthogHost?: string | undefined, organization?: string | undefined, prefix?: string | undefined);
38
+ setupOnce(addGlobalEventProcessor: (callback: _SentryEventProcessor) => void, getCurrentHub: () => _SentryHub): void;
39
+ }
40
+ export {};
@@ -14,6 +14,7 @@ declare type FeatureFlagsPollerOptions = {
14
14
  pollingInterval: number;
15
15
  timeout?: number;
16
16
  fetch?: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>;
17
+ onError?: (error: Error) => void;
17
18
  };
18
19
  declare class FeatureFlagsPoller {
19
20
  pollingInterval: number;
@@ -29,6 +30,7 @@ declare class FeatureFlagsPoller {
29
30
  poller?: NodeJS.Timeout;
30
31
  fetch: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>;
31
32
  debugMode: boolean;
33
+ onError?: (error: Error) => void;
32
34
  constructor({ pollingInterval, personalApiKey, projectApiKey, timeout, host, ...options }: FeatureFlagsPollerOptions);
33
35
  debug(enabled?: boolean): void;
34
36
  getFeatureFlag(key: string, distinctId: string, groups?: Record<string, string>, personProperties?: Record<string, string>, groupProperties?: Record<string, Record<string, string>>): Promise<string | boolean | undefined>;
@@ -12,7 +12,7 @@ export declare class PostHog extends PostHogCoreStateless implements PostHogNode
12
12
  private _memoryStorage;
13
13
  private featureFlagsPoller?;
14
14
  private maxCacheSize;
15
- private options;
15
+ readonly options: PostHogOptions;
16
16
  distinctIdHasSentFlagCalls: Record<string, string[]>;
17
17
  constructor(apiKey: string, options?: PostHogOptions);
18
18
  getPersistedProperty(key: PostHogPersistedProperty): any | undefined;
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "posthog-node",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "PostHog Node.js integration",
5
- "repository": "PostHog/posthog-node",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/PostHog/posthog-js-lite.git",
8
+ "directory": "posthog-node"
9
+ },
6
10
  "scripts": {
7
11
  "prepublishOnly": "cd .. && yarn build"
8
12
  },
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file Adapted from [posthog-js](https://github.com/PostHog/posthog-js/blob/8157df935a4d0e71d2fefef7127aa85ee51c82d1/src/extensions/sentry-integration.ts) with modifications for the Node SDK.
3
+ */
4
+ import { type PostHog } from '../posthog-node'
5
+
6
+ // NOTE - we can't import from @sentry/types because it changes frequently and causes clashes
7
+ // We only use a small subset of the types, so we can just define the integration overall and use any for the rest
8
+
9
+ // import {
10
+ // Event as _SentryEvent,
11
+ // EventProcessor as _SentryEventProcessor,
12
+ // Exception as _SentryException,
13
+ // Hub as _SentryHub,
14
+ // Integration as _SentryIntegration,
15
+ // Primitive as _SentryPrimitive,
16
+ // } from '@sentry/types'
17
+
18
+ // Uncomment the above and comment the below to get type checking for development
19
+
20
+ type _SentryEvent = any
21
+ type _SentryEventProcessor = any
22
+ type _SentryHub = any
23
+ type _SentryException = any
24
+ type _SentryPrimitive = any
25
+
26
+ interface _SentryIntegration {
27
+ name: string
28
+ setupOnce(addGlobalEventProcessor: (callback: _SentryEventProcessor) => void, getCurrentHub: () => _SentryHub): void
29
+ }
30
+
31
+ interface PostHogSentryExceptionProperties {
32
+ $sentry_event_id?: string
33
+ $sentry_exception?: { values?: _SentryException[] }
34
+ $sentry_exception_message?: string
35
+ $sentry_exception_type?: string
36
+ $sentry_tags: { [key: string]: _SentryPrimitive }
37
+ $sentry_url?: string
38
+ $exception_type?: string
39
+ $exception_message?: string
40
+ $exception_personURL?: string
41
+ }
42
+
43
+ /**
44
+ * Integrate Sentry with PostHog. This will add a direct link to the person in Sentry, and an $exception event in PostHog.
45
+ *
46
+ * ### Usage
47
+ *
48
+ * Sentry.init({
49
+ * dsn: 'https://example',
50
+ * integrations: [
51
+ * new PostHogSentryIntegration(posthog)
52
+ * ]
53
+ * })
54
+ *
55
+ * Sentry.setTag(PostHogSentryIntegration.POSTHOG_ID_TAG, 'some distinct id');
56
+ *
57
+ * @param {Object} [posthog] The posthog object
58
+ * @param {string} [organization] Optional: The Sentry organization, used to send a direct link from PostHog to Sentry
59
+ * @param {Number} [projectId] Optional: The Sentry project id, used to send a direct link from PostHog to Sentry
60
+ * @param {string} [prefix] Optional: Url of a self-hosted sentry instance (default: https://sentry.io/organizations/)
61
+ */
62
+ export class PostHogSentryIntegration implements _SentryIntegration {
63
+ public readonly name = 'posthog-node'
64
+
65
+ public static readonly POSTHOG_ID_TAG = 'posthog_distinct_id'
66
+
67
+ public constructor(
68
+ private readonly posthog: PostHog,
69
+ private readonly posthogHost?: string,
70
+ private readonly organization?: string,
71
+ private readonly prefix?: string
72
+ ) {
73
+ this.posthogHost = posthog.options.host ?? 'https://app.posthog.com'
74
+ }
75
+
76
+ public setupOnce(
77
+ addGlobalEventProcessor: (callback: _SentryEventProcessor) => void,
78
+ getCurrentHub: () => _SentryHub
79
+ ): void {
80
+ addGlobalEventProcessor((event: _SentryEvent): _SentryEvent => {
81
+ if (event.exception?.values === undefined || event.exception.values.length === 0) {
82
+ return event
83
+ }
84
+
85
+ if (!event.tags) {
86
+ event.tags = {}
87
+ }
88
+
89
+ const sentry = getCurrentHub()
90
+
91
+ // Get the PostHog user ID from a specific tag, which users can set on their Sentry scope as they need.
92
+ const userId = event.tags[PostHogSentryIntegration.POSTHOG_ID_TAG]
93
+ if (userId === undefined) {
94
+ // 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.
95
+ return event
96
+ }
97
+
98
+ event.tags['PostHog Person URL'] = new URL(`/person/${userId}`, this.posthogHost).toString()
99
+
100
+ const properties: PostHogSentryExceptionProperties = {
101
+ // PostHog Exception Properties
102
+ $exception_message: event.exception.values[0]?.value,
103
+ $exception_type: event.exception.values[0]?.type,
104
+ $exception_personURL: event.tags['PostHog Person URL'],
105
+ // Sentry Exception Properties
106
+ $sentry_event_id: event.event_id,
107
+ $sentry_exception: event.exception,
108
+ $sentry_exception_message: event.exception.values[0]?.value,
109
+ $sentry_exception_type: event.exception.values[0]?.type,
110
+ $sentry_tags: event.tags,
111
+ }
112
+
113
+ const projectId = sentry.getClient()?.getDsn()?.projectId
114
+ if (this.organization !== undefined && projectId !== undefined && event.event_id !== undefined) {
115
+ properties.$sentry_url = `${this.prefix ?? 'https://sentry.io/organizations'}/${
116
+ this.organization
117
+ }/issues/?project=${projectId}&query=${event.event_id}`
118
+ }
119
+
120
+ this.posthog.capture({ event: '$exception', distinctId: userId, properties })
121
+
122
+ return event
123
+ })
124
+ }
125
+ }
@@ -37,6 +37,7 @@ type FeatureFlagsPollerOptions = {
37
37
  pollingInterval: number
38
38
  timeout?: number
39
39
  fetch?: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>
40
+ onError?: (error: Error) => void
40
41
  }
41
42
 
42
43
  class FeatureFlagsPoller {
@@ -53,6 +54,7 @@ class FeatureFlagsPoller {
53
54
  poller?: NodeJS.Timeout
54
55
  fetch: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>
55
56
  debugMode: boolean = false
57
+ onError?: (error: Error) => void
56
58
 
57
59
  constructor({
58
60
  pollingInterval,
@@ -75,6 +77,7 @@ class FeatureFlagsPoller {
75
77
  this.poller = undefined
76
78
  // NOTE: as any is required here as the AbortSignal typing is slightly misaligned but works just fine
77
79
  this.fetch = options.fetch || fetch
80
+ this.onError = options.onError
78
81
 
79
82
  void this.loadFeatureFlags()
80
83
  }
@@ -177,7 +180,7 @@ class FeatureFlagsPoller {
177
180
  if (e instanceof InconclusiveMatchError) {
178
181
  // do nothing
179
182
  } else if (e instanceof Error) {
180
- console.error(`Error computing flag locally: ${flag.key}: ${e}`)
183
+ this.onError?.(new Error(`Error computing flag locally: ${flag.key}: ${e}`))
181
184
  }
182
185
  fallbackToDecide = true
183
186
  }
@@ -395,7 +398,7 @@ class FeatureFlagsPoller {
395
398
  // if an error that is not an instance of ClientError is thrown
396
399
  // we silently ignore the error when reloading feature flags
397
400
  if (err instanceof ClientError) {
398
- throw err
401
+ this.onError?.(err)
399
402
  }
400
403
  }
401
404
  }
@@ -35,7 +35,7 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
35
35
 
36
36
  private featureFlagsPoller?: FeatureFlagsPoller
37
37
  private maxCacheSize: number
38
- private options: PostHogOptions
38
+ public readonly options: PostHogOptions
39
39
 
40
40
  distinctIdHasSentFlagCalls: Record<string, string[]>
41
41
 
@@ -56,6 +56,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
56
56
  timeout: options.requestTimeout ?? 10000, // 10 seconds
57
57
  host: this.host,
58
58
  fetch: options.fetch,
59
+ onError: (err: Error) => {
60
+ this._events.emit('error', err)
61
+ },
59
62
  })
60
63
  }
61
64
  this.distinctIdHasSentFlagCalls = {}
@@ -0,0 +1,150 @@
1
+ // import { PostHog } from '../'
2
+ import { PostHog as PostHog } from '../../src/posthog-node'
3
+ import { PostHogSentryIntegration } from '../../src/extensions/sentry-integration'
4
+ jest.mock('../../src/fetch')
5
+ import fetch from '../../src/fetch'
6
+
7
+ jest.mock('../../package.json', () => ({ version: '1.2.3' }))
8
+
9
+ const mockedFetch = jest.mocked(fetch, true)
10
+
11
+ const getLastBatchEvents = (): any[] | undefined => {
12
+ expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.objectContaining({ method: 'POST' }))
13
+
14
+ // reverse mock calls array to get the last call
15
+ const call = mockedFetch.mock.calls.reverse().find((x) => (x[0] as string).includes('/batch/'))
16
+ if (!call) {
17
+ return undefined
18
+ }
19
+ return JSON.parse((call[1] as any).body as any).batch
20
+ }
21
+
22
+ const createMockSentryException = (): any => ({
23
+ exception: {
24
+ values: [
25
+ {
26
+ type: 'Error',
27
+ value: 'example error',
28
+ stacktrace: {
29
+ frames: [],
30
+ },
31
+ mechanism: { type: 'generic', handled: true },
32
+ },
33
+ ],
34
+ },
35
+ event_id: '80a7023ac32c47f7acb0adaed600d149',
36
+ platform: 'node',
37
+ contexts: {},
38
+ server_name: 'localhost',
39
+ timestamp: 1704203482.356,
40
+ environment: 'production',
41
+ tags: { posthog_distinct_id: 'EXAMPLE_APP_GLOBAL' },
42
+ breadcrumbs: [
43
+ {
44
+ timestamp: 1704203481.422,
45
+ category: 'console',
46
+ level: 'log',
47
+ message: '⚡: Server is running at http://localhost:8010',
48
+ },
49
+ {
50
+ timestamp: 1704203481.658,
51
+ category: 'console',
52
+ level: 'log',
53
+ message:
54
+ "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]",
55
+ },
56
+ ],
57
+ sdkProcessingMetadata: {
58
+ propagationContext: { traceId: 'ea26146e5a354cb0b3b1daebb3f90e33', spanId: '8d642089c3daa272' },
59
+ },
60
+ })
61
+
62
+ describe('PostHogSentryIntegration', () => {
63
+ let posthog: PostHog
64
+ let posthogSentry: PostHogSentryIntegration
65
+
66
+ jest.useFakeTimers()
67
+
68
+ beforeEach(() => {
69
+ posthog = new PostHog('TEST_API_KEY', {
70
+ host: 'http://example.com',
71
+ fetchRetryCount: 0,
72
+ })
73
+
74
+ posthogSentry = new PostHogSentryIntegration(posthog)
75
+
76
+ mockedFetch.mockResolvedValue({
77
+ status: 200,
78
+ text: () => Promise.resolve('ok'),
79
+ json: () =>
80
+ Promise.resolve({
81
+ status: 'ok',
82
+ }),
83
+ } as any)
84
+ })
85
+
86
+ afterEach(async () => {
87
+ // ensure clean shutdown & no test interdependencies
88
+ await posthog.shutdownAsync()
89
+ })
90
+
91
+ it('should forward sentry exceptions to posthog', async () => {
92
+ expect(mockedFetch).toHaveBeenCalledTimes(0)
93
+
94
+ const mockSentry = {
95
+ getClient: () => ({
96
+ getDsn: () => ({
97
+ projectId: 123,
98
+ }),
99
+ }),
100
+ }
101
+
102
+ let processorFunction: any
103
+
104
+ posthogSentry.setupOnce(
105
+ (fn) => (processorFunction = fn),
106
+ () => mockSentry
107
+ )
108
+
109
+ processorFunction(createMockSentryException())
110
+
111
+ jest.runOnlyPendingTimers()
112
+ const batchEvents = getLastBatchEvents()
113
+
114
+ expect(batchEvents).toEqual([
115
+ {
116
+ distinct_id: 'EXAMPLE_APP_GLOBAL',
117
+ event: '$exception',
118
+ properties: {
119
+ $exception_message: 'example error',
120
+ $exception_type: 'Error',
121
+ $exception_personURL: 'http://example.com/person/EXAMPLE_APP_GLOBAL',
122
+ $sentry_event_id: '80a7023ac32c47f7acb0adaed600d149',
123
+ $sentry_exception: {
124
+ values: [
125
+ {
126
+ type: 'Error',
127
+ value: 'example error',
128
+ stacktrace: { frames: [] },
129
+ mechanism: { type: 'generic', handled: true },
130
+ },
131
+ ],
132
+ },
133
+ $sentry_exception_message: 'example error',
134
+ $sentry_exception_type: 'Error',
135
+ $sentry_tags: {
136
+ posthog_distinct_id: 'EXAMPLE_APP_GLOBAL',
137
+ 'PostHog Person URL': 'http://example.com/person/EXAMPLE_APP_GLOBAL',
138
+ },
139
+ $lib: 'posthog-node',
140
+ $lib_version: '1.2.3',
141
+ $geoip_disable: true,
142
+ },
143
+ type: 'capture',
144
+ library: 'posthog-node',
145
+ library_version: '1.2.3',
146
+ timestamp: expect.any(String),
147
+ },
148
+ ])
149
+ })
150
+ })