posthog-node 3.2.1 → 3.4.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 {};
@@ -55,4 +55,5 @@ declare class FeatureFlagsPoller {
55
55
  stopPoller(): void;
56
56
  }
57
57
  declare function matchProperty(property: FeatureFlagCondition['properties'][number], propertyValues: Record<string, any>): boolean;
58
- export { FeatureFlagsPoller, matchProperty, InconclusiveMatchError, ClientError };
58
+ declare function relativeDateParseForFeatureFlagMatching(value: string): Date | null;
59
+ export { FeatureFlagsPoller, matchProperty, relativeDateParseForFeatureFlagMatching, InconclusiveMatchError, ClientError, };
@@ -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.1",
3
+ "version": "3.4.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
+ }
@@ -464,11 +464,32 @@ function matchProperty(
464
464
 
465
465
  const overrideValue = propertyValues[key]
466
466
 
467
+ function computeExactMatch(value: any, overrideValue: any): boolean {
468
+ if (Array.isArray(value)) {
469
+ return value.map((val) => String(val).toLowerCase()).includes(String(overrideValue).toLowerCase())
470
+ }
471
+ return String(value).toLowerCase() === String(overrideValue).toLowerCase()
472
+ }
473
+
474
+ function compare(lhs: any, rhs: any, operator: string): boolean {
475
+ if (operator === 'gt') {
476
+ return lhs > rhs
477
+ } else if (operator === 'gte') {
478
+ return lhs >= rhs
479
+ } else if (operator === 'lt') {
480
+ return lhs < rhs
481
+ } else if (operator === 'lte') {
482
+ return lhs <= rhs
483
+ } else {
484
+ throw new Error(`Invalid operator: ${operator}`)
485
+ }
486
+ }
487
+
467
488
  switch (operator) {
468
489
  case 'exact':
469
- return Array.isArray(value) ? value.indexOf(overrideValue) !== -1 : value === overrideValue
490
+ return computeExactMatch(value, overrideValue)
470
491
  case 'is_not':
471
- return Array.isArray(value) ? value.indexOf(overrideValue) === -1 : value !== overrideValue
492
+ return !computeExactMatch(value, overrideValue)
472
493
  case 'is_set':
473
494
  return key in propertyValues
474
495
  case 'icontains':
@@ -480,25 +501,54 @@ function matchProperty(
480
501
  case 'not_regex':
481
502
  return isValidRegex(String(value)) && String(overrideValue).match(String(value)) === null
482
503
  case 'gt':
483
- return typeof overrideValue == typeof value && overrideValue > value
484
504
  case 'gte':
485
- return typeof overrideValue == typeof value && overrideValue >= value
486
505
  case 'lt':
487
- return typeof overrideValue == typeof value && overrideValue < value
488
- case 'lte':
489
- return typeof overrideValue == typeof value && overrideValue <= value
506
+ case 'lte': {
507
+ // :TRICKY: We adjust comparison based on the override value passed in,
508
+ // to make sure we handle both numeric and string comparisons appropriately.
509
+ let parsedValue = typeof value === 'number' ? value : null
510
+
511
+ if (typeof value === 'string') {
512
+ try {
513
+ parsedValue = parseFloat(value)
514
+ } catch (err) {
515
+ // pass
516
+ }
517
+ }
518
+
519
+ if (parsedValue != null && overrideValue != null) {
520
+ // check both null and undefined
521
+ if (typeof overrideValue === 'string') {
522
+ return compare(overrideValue, String(value), operator)
523
+ } else {
524
+ return compare(overrideValue, parsedValue, operator)
525
+ }
526
+ } else {
527
+ return compare(String(overrideValue), String(value), operator)
528
+ }
529
+ }
490
530
  case 'is_date_after':
491
- case 'is_date_before': {
492
- const parsedDate = convertToDateTime(value)
531
+ case 'is_date_before':
532
+ case 'is_relative_date_before':
533
+ case 'is_relative_date_after': {
534
+ let parsedDate = null
535
+ if (['is_relative_date_before', 'is_relative_date_after'].includes(operator)) {
536
+ parsedDate = relativeDateParseForFeatureFlagMatching(String(value))
537
+ } else {
538
+ parsedDate = convertToDateTime(value)
539
+ }
540
+
541
+ if (parsedDate == null) {
542
+ throw new InconclusiveMatchError(`Invalid date: ${value}`)
543
+ }
493
544
  const overrideDate = convertToDateTime(overrideValue)
494
- if (operator === 'is_date_before') {
545
+ if (['is_date_before', 'is_relative_date_before'].includes(operator)) {
495
546
  return overrideDate < parsedDate
496
547
  }
497
548
  return overrideDate > parsedDate
498
549
  }
499
550
  default:
500
- console.error(`Unknown operator: ${operator}`)
501
- return false
551
+ throw new InconclusiveMatchError(`Unknown operator: ${operator}`)
502
552
  }
503
553
  }
504
554
 
@@ -636,4 +686,47 @@ function convertToDateTime(value: string | number | (string | number)[] | Date):
636
686
  }
637
687
  }
638
688
 
639
- export { FeatureFlagsPoller, matchProperty, InconclusiveMatchError, ClientError }
689
+ function relativeDateParseForFeatureFlagMatching(value: string): Date | null {
690
+ const regex = /^(?<number>[0-9]+)(?<interval>[a-z])$/
691
+ const match = value.match(regex)
692
+ const parsedDt = new Date(new Date().toISOString())
693
+
694
+ if (match) {
695
+ if (!match.groups) {
696
+ return null
697
+ }
698
+
699
+ const number = parseInt(match.groups['number'])
700
+
701
+ if (number >= 10000) {
702
+ // Guard against overflow, disallow numbers greater than 10_000
703
+ return null
704
+ }
705
+ const interval = match.groups['interval']
706
+ if (interval == 'h') {
707
+ parsedDt.setUTCHours(parsedDt.getUTCHours() - number)
708
+ } else if (interval == 'd') {
709
+ parsedDt.setUTCDate(parsedDt.getUTCDate() - number)
710
+ } else if (interval == 'w') {
711
+ parsedDt.setUTCDate(parsedDt.getUTCDate() - number * 7)
712
+ } else if (interval == 'm') {
713
+ parsedDt.setUTCMonth(parsedDt.getUTCMonth() - number)
714
+ } else if (interval == 'y') {
715
+ parsedDt.setUTCFullYear(parsedDt.getUTCFullYear() - number)
716
+ } else {
717
+ return null
718
+ }
719
+
720
+ return parsedDt
721
+ } else {
722
+ return null
723
+ }
724
+ }
725
+
726
+ export {
727
+ FeatureFlagsPoller,
728
+ matchProperty,
729
+ relativeDateParseForFeatureFlagMatching,
730
+ InconclusiveMatchError,
731
+ ClientError,
732
+ }
@@ -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
 
@@ -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
+ })