From cca013af1a4d77ea82c4e65cbe62bd0e879570fa Mon Sep 17 00:00:00 2001 From: Ben White Date: Tue, 2 Jan 2024 15:52:38 +0100 Subject: [PATCH 1/3] 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), + }, + ]) + }) +}) From c348110d8600319bcdeb6953a6978a032abbf2ab Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Tue, 9 Jan 2024 10:21:37 +0100 Subject: [PATCH 2/3] chore: `$device_type` is now set to `Mobile`, `Desktop` or `Web` for all events (#152) --- posthog-react-native/CHANGELOG.md | 4 ++++ posthog-react-native/src/native-deps.tsx | 10 +++++++++- posthog-react-native/src/types.ts | 2 +- posthog-react-native/test/posthog.spec.ts | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/posthog-react-native/CHANGELOG.md b/posthog-react-native/CHANGELOG.md index 164f2e78..b226f9b2 100644 --- a/posthog-react-native/CHANGELOG.md +++ b/posthog-react-native/CHANGELOG.md @@ -1,3 +1,7 @@ +# 2.10.0 - 2024-01-08 + +1. `$device_type` is now set to `Mobile`, `Desktop`, or `Web` for all events + # 2.9.2 - 2023-12-21 1. If `async-storage` or `expo-file-system` is not installed, the SDK will fallback to `persistence: memory` and log a warning diff --git a/posthog-react-native/src/native-deps.tsx b/posthog-react-native/src/native-deps.tsx index 9c8d6fe3..4707d944 100644 --- a/posthog-react-native/src/native-deps.tsx +++ b/posthog-react-native/src/native-deps.tsx @@ -8,8 +8,16 @@ import { OptionalReactNativeDeviceInfo } from './optional/OptionalReactNativeDev import { PostHogCustomAppProperties, PostHogCustomAsyncStorage } from './types' export const getAppProperties = (): PostHogCustomAppProperties => { + var deviceType = 'Mobile' + + if (Platform.OS === 'macos' || Platform.OS === 'windows') { + deviceType = 'Desktop' + } else if (Platform.OS === 'web') { + deviceType = 'Web' + } + const properties: PostHogCustomAppProperties = { - $device_type: Platform.OS, + $device_type: deviceType, } if (OptionalExpoApplication) { diff --git a/posthog-react-native/src/types.ts b/posthog-react-native/src/types.ts index c7323714..91222da3 100644 --- a/posthog-react-native/src/types.ts +++ b/posthog-react-native/src/types.ts @@ -33,7 +33,7 @@ export interface PostHogCustomAppProperties { $device_manufacturer?: string | null /** Readable model name like "iPhone 12" */ $device_name?: string | null - /** Same as Platform.OS ("android" | "ios") */ + /** Device type ("Mobile" | "Desktop" | "Web") */ $device_type?: string | null /** Operating system name like iOS or Android */ $os_name?: string | null diff --git a/posthog-react-native/test/posthog.spec.ts b/posthog-react-native/test/posthog.spec.ts index a05c3438..f7bd7117 100644 --- a/posthog-react-native/test/posthog.spec.ts +++ b/posthog-react-native/test/posthog.spec.ts @@ -108,7 +108,7 @@ describe('PostHog React Native', () => { $app_namespace: 'mock', $app_version: 'mock', $device_manufacturer: 'mock', - $device_type: 'ios', + $device_type: 'Mobile', // $device_name: 'mock', (deleted) $os_name: 'mock', $os_version: 'mock', From 8b5877eef7c7522d76fcbb9fdf8627b2ab34c2b4 Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Tue, 9 Jan 2024 12:33:35 +0000 Subject: [PATCH 3/3] feat(flags): Add relative date operators, fix numeric ops (#148) --- posthog-node/CHANGELOG.md | 5 + posthog-node/package.json | 2 +- posthog-node/src/feature-flags.ts | 119 +++++++++-- posthog-node/test/feature-flags.spec.ts | 259 +++++++++++++++++++++++- 4 files changed, 368 insertions(+), 17 deletions(-) diff --git a/posthog-node/CHANGELOG.md b/posthog-node/CHANGELOG.md index 2040bd87..1e58f225 100644 --- a/posthog-node/CHANGELOG.md +++ b/posthog-node/CHANGELOG.md @@ -1,3 +1,8 @@ +# 3.4.0 - 2024-01-09 + +1. Numeric property handling for feature flags now does the expected: When passed in a number, we do a numeric comparison. When passed in a string, we do a string comparison. Previously, we always did a string comparison. +2. Add support for relative date operators for local evaluation. + # 3.3.0 - 2024-01-02 1. Adds PostHogSentryIntegration to allow automatic capturing of exceptions reported via the @sentry/node package diff --git a/posthog-node/package.json b/posthog-node/package.json index 558ebb0c..fa5a8788 100644 --- a/posthog-node/package.json +++ b/posthog-node/package.json @@ -1,6 +1,6 @@ { "name": "posthog-node", - "version": "3.3.0", + "version": "3.4.0", "description": "PostHog Node.js integration", "repository": { "type": "git", diff --git a/posthog-node/src/feature-flags.ts b/posthog-node/src/feature-flags.ts index b8ef8dc4..8b5b67bf 100644 --- a/posthog-node/src/feature-flags.ts +++ b/posthog-node/src/feature-flags.ts @@ -464,11 +464,32 @@ function matchProperty( const overrideValue = propertyValues[key] + function computeExactMatch(value: any, overrideValue: any): boolean { + if (Array.isArray(value)) { + return value.map((val) => String(val).toLowerCase()).includes(String(overrideValue).toLowerCase()) + } + return String(value).toLowerCase() === String(overrideValue).toLowerCase() + } + + function compare(lhs: any, rhs: any, operator: string): boolean { + if (operator === 'gt') { + return lhs > rhs + } else if (operator === 'gte') { + return lhs >= rhs + } else if (operator === 'lt') { + return lhs < rhs + } else if (operator === 'lte') { + return lhs <= rhs + } else { + throw new Error(`Invalid operator: ${operator}`) + } + } + switch (operator) { case 'exact': - return Array.isArray(value) ? value.indexOf(overrideValue) !== -1 : value === overrideValue + return computeExactMatch(value, overrideValue) case 'is_not': - return Array.isArray(value) ? value.indexOf(overrideValue) === -1 : value !== overrideValue + return !computeExactMatch(value, overrideValue) case 'is_set': return key in propertyValues case 'icontains': @@ -480,25 +501,54 @@ function matchProperty( case 'not_regex': return isValidRegex(String(value)) && String(overrideValue).match(String(value)) === null case 'gt': - return typeof overrideValue == typeof value && overrideValue > value case 'gte': - return typeof overrideValue == typeof value && overrideValue >= value case 'lt': - return typeof overrideValue == typeof value && overrideValue < value - case 'lte': - return typeof overrideValue == typeof value && overrideValue <= value + case 'lte': { + // :TRICKY: We adjust comparison based on the override value passed in, + // to make sure we handle both numeric and string comparisons appropriately. + let parsedValue = typeof value === 'number' ? value : null + + if (typeof value === 'string') { + try { + parsedValue = parseFloat(value) + } catch (err) { + // pass + } + } + + if (parsedValue != null && overrideValue != null) { + // check both null and undefined + if (typeof overrideValue === 'string') { + return compare(overrideValue, String(value), operator) + } else { + return compare(overrideValue, parsedValue, operator) + } + } else { + return compare(String(overrideValue), String(value), operator) + } + } case 'is_date_after': - case 'is_date_before': { - const parsedDate = convertToDateTime(value) + case 'is_date_before': + case 'is_relative_date_before': + case 'is_relative_date_after': { + let parsedDate = null + if (['is_relative_date_before', 'is_relative_date_after'].includes(operator)) { + parsedDate = relativeDateParseForFeatureFlagMatching(String(value)) + } else { + parsedDate = convertToDateTime(value) + } + + if (parsedDate == null) { + throw new InconclusiveMatchError(`Invalid date: ${value}`) + } const overrideDate = convertToDateTime(overrideValue) - if (operator === 'is_date_before') { + if (['is_date_before', 'is_relative_date_before'].includes(operator)) { return overrideDate < parsedDate } return overrideDate > parsedDate } default: - console.error(`Unknown operator: ${operator}`) - return false + throw new InconclusiveMatchError(`Unknown operator: ${operator}`) } } @@ -636,4 +686,47 @@ function convertToDateTime(value: string | number | (string | number)[] | Date): } } -export { FeatureFlagsPoller, matchProperty, InconclusiveMatchError, ClientError } +function relativeDateParseForFeatureFlagMatching(value: string): Date | null { + const regex = /^(?[0-9]+)(?[a-z])$/ + const match = value.match(regex) + const parsedDt = new Date(new Date().toISOString()) + + if (match) { + if (!match.groups) { + return null + } + + const number = parseInt(match.groups['number']) + + if (number >= 10000) { + // Guard against overflow, disallow numbers greater than 10_000 + return null + } + const interval = match.groups['interval'] + if (interval == 'h') { + parsedDt.setUTCHours(parsedDt.getUTCHours() - number) + } else if (interval == 'd') { + parsedDt.setUTCDate(parsedDt.getUTCDate() - number) + } else if (interval == 'w') { + parsedDt.setUTCDate(parsedDt.getUTCDate() - number * 7) + } else if (interval == 'm') { + parsedDt.setUTCMonth(parsedDt.getUTCMonth() - number) + } else if (interval == 'y') { + parsedDt.setUTCFullYear(parsedDt.getUTCFullYear() - number) + } else { + return null + } + + return parsedDt + } else { + return null + } +} + +export { + FeatureFlagsPoller, + matchProperty, + relativeDateParseForFeatureFlagMatching, + InconclusiveMatchError, + ClientError, +} diff --git a/posthog-node/test/feature-flags.spec.ts b/posthog-node/test/feature-flags.spec.ts index cb3eb132..91408fab 100644 --- a/posthog-node/test/feature-flags.spec.ts +++ b/posthog-node/test/feature-flags.spec.ts @@ -1,7 +1,7 @@ // import { PostHog, PostHogOptions } from '../' // Uncomment below line while developing to not compile code everytime import { PostHog as PostHog, PostHogOptions } from '../src/posthog-node' -import { matchProperty, InconclusiveMatchError } from '../src/feature-flags' +import { matchProperty, InconclusiveMatchError, relativeDateParseForFeatureFlagMatching } from '../src/feature-flags' jest.mock('../src/fetch') import fetch from '../src/fetch' @@ -1814,6 +1814,8 @@ describe('local evaluation', () => { }) describe('match properties', () => { + jest.useFakeTimers() + it('with operator exact', () => { const property_a = { key: 'key', value: 'value' } @@ -1953,7 +1955,8 @@ describe('match properties', () => { expect(matchProperty(property_a, { key: 0 })).toBe(false) expect(matchProperty(property_a, { key: -1 })).toBe(false) - expect(matchProperty(property_a, { key: '23' })).toBe(false) + // # now we handle type mismatches so this should be true + expect(matchProperty(property_a, { key: '23' })).toBe(true) const property_b = { key: 'key', value: 1, operator: 'lt' } expect(matchProperty(property_b, { key: 0 })).toBe(true) @@ -1971,7 +1974,8 @@ describe('match properties', () => { expect(matchProperty(property_c, { key: 0 })).toBe(false) expect(matchProperty(property_c, { key: -1 })).toBe(false) expect(matchProperty(property_c, { key: -3 })).toBe(false) - expect(matchProperty(property_c, { key: '3' })).toBe(false) + // # now we handle type mismatches so this should be true + expect(matchProperty(property_c, { key: '3' })).toBe(true) const property_d = { key: 'key', value: '43', operator: 'lte' } expect(matchProperty(property_d, { key: '43' })).toBe(true) @@ -1979,6 +1983,21 @@ describe('match properties', () => { expect(matchProperty(property_d, { key: '44' })).toBe(false) expect(matchProperty(property_d, { key: 44 })).toBe(false) + expect(matchProperty(property_d, { key: 42 })).toBe(true) + + const property_e = { key: 'key', value: '30', operator: 'lt' } + expect(matchProperty(property_e, { key: '29' })).toBe(true) + + // # depending on the type of override, we adjust type comparison + expect(matchProperty(property_e, { key: '100' })).toBe(true) + expect(matchProperty(property_e, { key: 100 })).toBe(false) + + const property_f = { key: 'key', value: '123aloha', operator: 'gt' } + expect(matchProperty(property_f, { key: '123' })).toBe(false) + expect(matchProperty(property_f, { key: 122 })).toBe(false) + + // # this turns into a string comparison + expect(matchProperty(property_f, { key: 129 })).toBe(true) }) it('with date operators', () => { @@ -2016,6 +2035,240 @@ describe('match properties', () => { expect(matchProperty(property_d, { key: '2022-04-05 11:34:11 +00:00' })).toBe(true) expect(matchProperty(property_d, { key: '2022-04-05 11:34:13 +00:00' })).toBe(false) }) + + it('with relative date operators', () => { + jest.setSystemTime(new Date('2022-05-01')) + + const property_a = { key: 'key', value: '6h', operator: 'is_relative_date_before' } + expect(matchProperty(property_a, { key: '2022-03-01' })).toBe(true) + expect(matchProperty(property_a, { key: '2022-04-30' })).toBe(true) + + // :TRICKY: MonthIndex is 0 indexed, so 3 is actually the 4th month, April. + expect(matchProperty(property_a, { key: new Date(Date.UTC(2022, 3, 30, 1, 2, 3)) })).toBe(true) + // false because date comparison, instead of datetime, so reduces to same date + expect(matchProperty(property_a, { key: new Date(2022, 3, 30, 19, 2, 3) })).toBe(false) + expect(matchProperty(property_a, { key: new Date('2022-04-30T01:02:03+02:00') })).toBe(true) // europe/madrid + expect(matchProperty(property_a, { key: new Date('2022-04-30T20:02:03+02:00') })).toBe(false) // europe/madrid + expect(matchProperty(property_a, { key: new Date('2022-04-30T19:59:03+02:00') })).toBe(true) // europe/madrid + expect(matchProperty(property_a, { key: new Date('2022-04-30') })).toBe(true) + expect(matchProperty(property_a, { key: '2022-05-30' })).toBe(false) + + // # can't be an invalid string + expect(() => matchProperty(property_a, { key: 'abcdef' })).toThrow(InconclusiveMatchError) + // however js understands numbers as date offsets from utc epoch + expect(() => matchProperty(property_a, { key: 1 })).not.toThrow(InconclusiveMatchError) + + const property_b = { key: 'key', value: '1h', operator: 'is_relative_date_after' } + expect(matchProperty(property_b, { key: '2022-05-02' })).toBe(true) + expect(matchProperty(property_b, { key: '2022-05-30' })).toBe(true) + expect(matchProperty(property_b, { key: new Date(2022, 4, 30) })).toBe(true) + expect(matchProperty(property_b, { key: new Date('2022-05-30') })).toBe(true) + expect(matchProperty(property_b, { key: '2022-04-30' })).toBe(false) + + // # Invalid flag property + const property_c = { key: 'key', value: 1234, operator: 'is_relative_date_after' } + expect(() => matchProperty(property_c, { key: '2022-05-30' })).toThrow(InconclusiveMatchError) + expect(() => matchProperty(property_c, { key: 1 })).toThrow(InconclusiveMatchError) + + // # Try all possible relative dates + const property_e = { key: 'key', value: '1h', operator: 'is_relative_date_before' } + expect(matchProperty(property_e, { key: '2022-05-01 00:00:00' })).toBe(false) + expect(matchProperty(property_e, { key: '2022-04-30 22:00:00' })).toBe(true) + + const property_f = { key: 'key', value: '1d', operator: 'is_relative_date_before' } + expect(matchProperty(property_f, { key: '2022-04-29 23:59:00 GMT' })).toBe(true) + expect(matchProperty(property_f, { key: '2022-04-30 00:00:01 GMT' })).toBe(false) + + const property_g = { key: 'key', value: '1w', operator: 'is_relative_date_before' } + expect(matchProperty(property_g, { key: '2022-04-23 00:00:00 GMT' })).toBe(true) + expect(matchProperty(property_g, { key: '2022-04-24 00:00:00 GMT' })).toBe(false) + expect(matchProperty(property_g, { key: '2022-04-24 00:00:01 GMT' })).toBe(false) + + const property_h = { key: 'key', value: '1m', operator: 'is_relative_date_before' } + expect(matchProperty(property_h, { key: '2022-03-01 00:00:00 GMT' })).toBe(true) + expect(matchProperty(property_h, { key: '2022-04-05 00:00:00 GMT' })).toBe(false) + + const property_i = { key: 'key', value: '1y', operator: 'is_relative_date_before' } + expect(matchProperty(property_i, { key: '2021-04-28 00:00:00 GMT' })).toBe(true) + expect(matchProperty(property_i, { key: '2021-05-01 00:00:01 GMT' })).toBe(false) + + const property_j = { key: 'key', value: '122h', operator: 'is_relative_date_after' } + expect(matchProperty(property_j, { key: '2022-05-01 00:00:00 GMT' })).toBe(true) + expect(matchProperty(property_j, { key: '2022-04-23 01:00:00 GMT' })).toBe(false) + + const property_k = { key: 'key', value: '2d', operator: 'is_relative_date_after' } + expect(matchProperty(property_k, { key: '2022-05-01 00:00:00 GMT' })).toBe(true) + expect(matchProperty(property_k, { key: '2022-04-29 00:00:01 GMT' })).toBe(true) + expect(matchProperty(property_k, { key: '2022-04-29 00:00:00 GMT' })).toBe(false) + + const property_l = { key: 'key', value: '02w', operator: 'is_relative_date_after' } + expect(matchProperty(property_l, { key: '2022-05-01 00:00:00 GMT' })).toBe(true) + expect(matchProperty(property_l, { key: '2022-04-16 00:00:00 GMT' })).toBe(false) + + const property_m = { key: 'key', value: '1m', operator: 'is_relative_date_after' } + expect(matchProperty(property_m, { key: '2022-04-01 00:00:01 GMT' })).toBe(true) + expect(matchProperty(property_m, { key: '2022-04-01 00:00:00 GMT' })).toBe(false) + + const property_n = { key: 'key', value: '1y', operator: 'is_relative_date_after' } + expect(matchProperty(property_n, { key: '2022-05-01 00:00:00 GMT' })).toBe(true) + expect(matchProperty(property_n, { key: '2021-05-01 00:00:01 GMT' })).toBe(true) + expect(matchProperty(property_n, { key: '2021-05-01 00:00:00 GMT' })).toBe(false) + expect(matchProperty(property_n, { key: '2021-04-30 00:00:00 GMT' })).toBe(false) + expect(matchProperty(property_n, { key: '2021-03-01 12:13:00 GMT' })).toBe(false) + }) + + it('null or undefined property value', () => { + const property_a = { key: 'key', value: 'null', operator: 'is_not' } + expect(matchProperty(property_a, { key: null })).toBe(false) + expect(matchProperty(property_a, { key: undefined })).toBe(true) + expect(matchProperty(property_a, { key: 'null' })).toBe(false) + expect(matchProperty(property_a, { key: 'nul' })).toBe(true) + + const property_b = { key: 'key', value: 'null', operator: 'is_set' } + expect(matchProperty(property_b, { key: null })).toBe(true) + expect(matchProperty(property_b, { key: undefined })).toBe(true) + expect(matchProperty(property_b, { key: 'null' })).toBe(true) + + const property_c = { key: 'key', value: 'undefined', operator: 'icontains' } + expect(matchProperty(property_c, { key: null })).toBe(false) + expect(matchProperty(property_c, { key: undefined })).toBe(true) + expect(matchProperty(property_c, { key: 'lol' })).toBe(false) + + const property_d = { key: 'key', value: 'undefined', operator: 'regex' } + expect(matchProperty(property_d, { key: null })).toBe(false) + expect(matchProperty(property_d, { key: undefined })).toBe(true) + + const property_e = { key: 'key', value: 1, operator: 'gt' } + expect(matchProperty(property_e, { key: null })).toBe(true) + expect(matchProperty(property_e, { key: undefined })).toBe(true) + + const property_f = { key: 'key', value: 1, operator: 'lt' } + expect(matchProperty(property_f, { key: null })).toBe(false) + expect(matchProperty(property_f, { key: undefined })).toBe(false) + + const property_g = { key: 'key', value: 'xyz', operator: 'gte' } + expect(matchProperty(property_g, { key: null })).toBe(false) + expect(matchProperty(property_g, { key: undefined })).toBe(false) + + const property_h = { key: 'key', value: 'Oo', operator: 'lte' } + expect(matchProperty(property_h, { key: null })).toBe(false) + expect(matchProperty(property_h, { key: undefined })).toBe(false) + + const property_h_lower = { key: 'key', value: 'oo', operator: 'lte' } + expect(matchProperty(property_h_lower, { key: null })).toBe(true) + expect(matchProperty(property_h_lower, { key: undefined })).toBe(false) + + const property_i = { key: 'key', value: '2022-05-01', operator: 'is_date_before' } + expect(() => matchProperty(property_i, { key: null })).toThrow(InconclusiveMatchError) + expect(() => matchProperty(property_i, { key: undefined })).toThrow(InconclusiveMatchError) + + const property_j = { key: 'key', value: '2022-05-01', operator: 'is_date_after' } + expect(() => matchProperty(property_j, { key: null })).toThrow(InconclusiveMatchError) + + const property_k = { key: 'key', value: '2022-05-01', operator: 'is_date_before' } + expect(() => matchProperty(property_k, { key: null })).toThrow(InconclusiveMatchError) + }) + + it('with invalid operator', () => { + const property_a = { key: 'key', value: '2022-05-01', operator: 'is_unknown' } + + expect(() => matchProperty(property_a, { key: 'random' })).toThrow( + new InconclusiveMatchError('Unknown operator: is_unknown') + ) + }) +}) + +describe('relative date parsing', () => { + jest.useFakeTimers() + beforeEach(() => { + jest.setSystemTime(new Date('2020-01-01T12:01:20.134Z')) + }) + + it('invalid input', () => { + expect(relativeDateParseForFeatureFlagMatching('1')).toBe(null) + expect(relativeDateParseForFeatureFlagMatching('1x')).toBe(null) + expect(relativeDateParseForFeatureFlagMatching('1.2y')).toBe(null) + expect(relativeDateParseForFeatureFlagMatching('1z')).toBe(null) + expect(relativeDateParseForFeatureFlagMatching('1s')).toBe(null) + expect(relativeDateParseForFeatureFlagMatching('123344000.134m')).toBe(null) + expect(relativeDateParseForFeatureFlagMatching('bazinga')).toBe(null) + expect(relativeDateParseForFeatureFlagMatching('000bello')).toBe(null) + expect(relativeDateParseForFeatureFlagMatching('000hello')).toBe(null) + + expect(relativeDateParseForFeatureFlagMatching('000h')).not.toBe(null) + expect(relativeDateParseForFeatureFlagMatching('1000h')).not.toBe(null) + }) + + it('overflow', () => { + expect(relativeDateParseForFeatureFlagMatching('1000000h')).toBe(null) + expect(relativeDateParseForFeatureFlagMatching('100000000000000000y')).toBe(null) + }) + + it('hour parsing', () => { + expect(relativeDateParseForFeatureFlagMatching('1h')).toEqual(new Date('2020-01-01T11:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('2h')).toEqual(new Date('2020-01-01T10:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('24h')).toEqual(new Date('2019-12-31T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('30h')).toEqual(new Date('2019-12-31T06:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('48h')).toEqual(new Date('2019-12-30T12:01:20.134Z')) + + expect(relativeDateParseForFeatureFlagMatching('24h')).toEqual(relativeDateParseForFeatureFlagMatching('1d')) + expect(relativeDateParseForFeatureFlagMatching('48h')).toEqual(relativeDateParseForFeatureFlagMatching('2d')) + }) + + it('day parsing', () => { + expect(relativeDateParseForFeatureFlagMatching('1d')).toEqual(new Date('2019-12-31T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('2d')).toEqual(new Date('2019-12-30T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('7d')).toEqual(new Date('2019-12-25T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('14d')).toEqual(new Date('2019-12-18T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('30d')).toEqual(new Date('2019-12-02T12:01:20.134Z')) + + expect(relativeDateParseForFeatureFlagMatching('7d')).toEqual(relativeDateParseForFeatureFlagMatching('1w')) + }) + + it('week parsing', () => { + expect(relativeDateParseForFeatureFlagMatching('1w')).toEqual(new Date('2019-12-25T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('2w')).toEqual(new Date('2019-12-18T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('4w')).toEqual(new Date('2019-12-04T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('8w')).toEqual(new Date('2019-11-06T12:01:20.134Z')) + + expect(relativeDateParseForFeatureFlagMatching('1m')).toEqual(new Date('2019-12-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('4w')).not.toEqual(relativeDateParseForFeatureFlagMatching('1m')) + }) + + it('month parsing', () => { + expect(relativeDateParseForFeatureFlagMatching('1m')).toEqual(new Date('2019-12-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('2m')).toEqual(new Date('2019-11-01T12:01:20.134Z')) + + expect(relativeDateParseForFeatureFlagMatching('4m')).toEqual(new Date('2019-09-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('5m')).toEqual(new Date('2019-08-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('6m')).toEqual(new Date('2019-07-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('8m')).toEqual(new Date('2019-05-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('10m')).toEqual(new Date('2019-03-01T12:01:20.134Z')) + + expect(relativeDateParseForFeatureFlagMatching('24m')).toEqual(new Date('2018-01-01T12:01:20.134Z')) + + expect(relativeDateParseForFeatureFlagMatching('1y')).toEqual(new Date('2019-01-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('12m')).toEqual(relativeDateParseForFeatureFlagMatching('1y')) + + jest.setSystemTime(new Date('2020-04-03T00:00:00Z')) + expect(relativeDateParseForFeatureFlagMatching('1m')).toEqual(new Date('2020-03-03T00:00:00Z')) + expect(relativeDateParseForFeatureFlagMatching('2m')).toEqual(new Date('2020-02-03T00:00:00Z')) + expect(relativeDateParseForFeatureFlagMatching('4m')).toEqual(new Date('2019-12-03T00:00:00Z')) + expect(relativeDateParseForFeatureFlagMatching('8m')).toEqual(new Date('2019-08-03T00:00:00Z')) + + expect(relativeDateParseForFeatureFlagMatching('1y')).toEqual(new Date('2019-04-03T00:00:00Z')) + expect(relativeDateParseForFeatureFlagMatching('12m')).toEqual(relativeDateParseForFeatureFlagMatching('1y')) + }) + + it('year parsing', () => { + expect(relativeDateParseForFeatureFlagMatching('1y')).toEqual(new Date('2019-01-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('2y')).toEqual(new Date('2018-01-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('4y')).toEqual(new Date('2016-01-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('8y')).toEqual(new Date('2012-01-01T12:01:20.134Z')) + + expect(relativeDateParseForFeatureFlagMatching('1y')).toEqual(new Date('2019-01-01T12:01:20.134Z')) + expect(relativeDateParseForFeatureFlagMatching('12m')).toEqual(relativeDateParseForFeatureFlagMatching('1y')) + }) }) describe('consistency tests', () => {