diff --git a/package.json b/package.json index 19f110bd8..cd7d06915 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,11 @@ "dependencies": { "@altack/nx-bundlefy": "^0.16.0", "@devcycle/assemblyscript-json": "^2.0.0", - "@openfeature/core": "^1.1.0", + "@openfeature/core": "^1.5.0", "@openfeature/multi-provider": "^0.1.1", "@openfeature/multi-provider-web": "^0.0.2", - "@openfeature/server-sdk": "^1.13.5", - "@openfeature/web-sdk": "^1.0.3", + "@openfeature/server-sdk": "^1.16.2", + "@openfeature/web-sdk": "^1.3.2", "@swc/helpers": "~0.5.2", "@types/express": "^4.17.17", "@vercel/edge-config": "^1.2.0", diff --git a/sdk/nodejs/__tests__/open-feature-provider/DevCycleProvider.test.ts b/sdk/nodejs/__tests__/open-feature-provider/DevCycleProvider.test.ts index 044284319..96704f607 100644 --- a/sdk/nodejs/__tests__/open-feature-provider/DevCycleProvider.test.ts +++ b/sdk/nodejs/__tests__/open-feature-provider/DevCycleProvider.test.ts @@ -2,6 +2,7 @@ import { OpenFeature, Client, StandardResolutionReasons, + ProviderEvents, } from '@openfeature/server-sdk' import { DevCycleClient, @@ -13,9 +14,12 @@ import { jest.mock('../../src/bucketing') jest.mock('@devcycle/config-manager') +jest.mock('../../src/eventQueue') const variableMock = jest.spyOn(DevCycleClient.prototype, 'variable') const cloudVariableMock = jest.spyOn(DevCycleCloudClient.prototype, 'variable') +const trackMock = jest.spyOn(DevCycleClient.prototype, 'track') +const cloudTrackMock = jest.spyOn(DevCycleCloudClient.prototype, 'track') const logger = { debug: jest.fn(), @@ -443,5 +447,92 @@ describe.each(['DevCycleClient', 'DevCycleCloudClient'])( }) }) }) + + describe(`${dvcClientType} - Tracking`, () => { + beforeEach(() => { + trackMock.mockClear() + cloudTrackMock.mockClear() + }) + + it('should track an event with value and metadata', async () => { + const { ofClient } = await initOFClient() + const trackingData = { + value: 123, + customField: 'custom value', + } + + ofClient.track( + 'test-event', + { targetingKey: 'user-123' }, + trackingData, + ) + + const expectedTrackCall = { + type: 'test-event', + value: 123, + metaData: { + customField: 'custom value', + }, + } + + if (dvcClientType === 'DevCycleClient') { + expect(trackMock).toHaveBeenCalledWith( + expect.any(DevCycleUser), + expectedTrackCall, + ) + } else { + expect(cloudTrackMock).toHaveBeenCalledWith( + expect.any(DevCycleUser), + expectedTrackCall, + ) + } + }) + + it('should track an event without value or metadata', async () => { + const { ofClient } = await initOFClient() + + ofClient.track('test-event', { + targetingKey: 'user-123', + }) + + const expectedTrackCall = { + type: 'test-event', + } + + if (dvcClientType === 'DevCycleClient') { + expect(trackMock).toHaveBeenCalledWith( + expect.any(DevCycleUser), + expectedTrackCall, + ) + } else { + expect(cloudTrackMock).toHaveBeenCalledWith( + expect.any(DevCycleUser), + expectedTrackCall, + ) + } + }) + + it('should throw error if context is missing', async () => { + const { ofClient } = await initOFClient() + + ofClient.addHandler(ProviderEvents.Error, (error) => { + expect(error?.message).toBe( + 'Missing targetingKey or user_id in context', + ) + }) + ofClient.track('test-event') + }) + + it('should throw error if targetingKey is missing', async () => { + const { ofClient } = await initOFClient() + + ofClient.addHandler(ProviderEvents.Error, (error) => { + expect(error?.message).toBe( + 'Missing targetingKey or user_id in context', + ) + }) + ofClient.track('test-event', {}) + }) + }) }, ) diff --git a/sdk/nodejs/src/open-feature/DevCycleProvider.ts b/sdk/nodejs/src/open-feature/DevCycleProvider.ts index d88cd2191..aea3ea9dd 100644 --- a/sdk/nodejs/src/open-feature/DevCycleProvider.ts +++ b/sdk/nodejs/src/open-feature/DevCycleProvider.ts @@ -10,6 +10,7 @@ import { TargetingKeyMissingError, InvalidContextError, ProviderStatus, + TrackingEventDetails, } from '@openfeature/server-sdk' import { DevCycleClient, @@ -73,6 +74,28 @@ export class DevCycleProvider implements Provider { await this.devcycleClient.close() } + track( + trackingEventName: string, + context?: EvaluationContext, + trackingEventDetails?: TrackingEventDetails, + ): void { + const user_id = context?.targetingKey ?? context?.user_id + if (!context || !user_id) { + throw new TargetingKeyMissingError( + 'Missing targetingKey or user_id in context', + ) + } + + this.devcycleClient.track(this.devcycleUserFromContext(context), { + type: trackingEventName, + value: trackingEventDetails?.value, + metaData: trackingEventDetails && { + ...trackingEventDetails, + value: undefined, + }, + }) + } + /** * Generic function to retrieve a DVC variable and convert it to a ResolutionDetails. * @param flagKey diff --git a/sdk/openfeature-web-provider/__tests__/DevCycleProvider.test.ts b/sdk/openfeature-web-provider/__tests__/DevCycleProvider.test.ts index 54d2b8ae1..d7850ede4 100644 --- a/sdk/openfeature-web-provider/__tests__/DevCycleProvider.test.ts +++ b/sdk/openfeature-web-provider/__tests__/DevCycleProvider.test.ts @@ -438,4 +438,69 @@ describe('DevCycleProvider Unit Tests', () => { }) }) }) + + describe('Tracking Events', () => { + let trackMock: any + let openFeatureClient: Client + let provider: DevCycleProvider + + beforeEach(async () => { + const init = await initOFClient() + openFeatureClient = init.ofClient + provider = init.provider + + if (provider.devcycleClient) { + trackMock = jest + .spyOn(provider.devcycleClient, 'track') + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .mockResolvedValue() + } + }) + + afterEach(() => { + trackMock?.mockClear() + }) + + it('should track an event with just a name', () => { + openFeatureClient.track('event-name') + + expect(trackMock).toHaveBeenCalledWith({ + type: 'event-name', + value: undefined, + metaData: undefined, + }) + }) + + it('should track an event with value and metadata', () => { + openFeatureClient.track('event-name', { + value: 123, + someKey: 'someValue', + otherKey: true, + }) + + expect(trackMock).toHaveBeenCalledWith({ + type: 'event-name', + value: 123, + metaData: { + someKey: 'someValue', + otherKey: true, + }, + }) + }) + + it('should track an event with just metadata', () => { + openFeatureClient.track('event-name', { + someKey: 'someValue', + }) + + expect(trackMock).toHaveBeenCalledWith({ + type: 'event-name', + value: undefined, + metaData: { + someKey: 'someValue', + }, + }) + }) + }) }) diff --git a/sdk/openfeature-web-provider/src/DevCycleProvider.ts b/sdk/openfeature-web-provider/src/DevCycleProvider.ts index de89980a2..e7ea43223 100644 --- a/sdk/openfeature-web-provider/src/DevCycleProvider.ts +++ b/sdk/openfeature-web-provider/src/DevCycleProvider.ts @@ -12,6 +12,7 @@ import { ResolutionDetails, StandardResolutionReasons, TargetingKeyMissingError, + TrackingEventDetails, } from '@openfeature/web-sdk' // Need to disable this to keep the working jest mock // eslint-disable-next-line @nx/enforce-module-boundaries @@ -116,6 +117,21 @@ export default class DevCycleProvider implements Provider { ) } + track( + trackingEventName: string, + context?: EvaluationContext, + trackingEventDetails?: TrackingEventDetails, + ): void { + this._devcycleClient?.track({ + type: trackingEventName, + value: trackingEventDetails?.value, + metaData: trackingEventDetails && { + ...trackingEventDetails, + value: undefined, + }, + }) + } + /** * Generic function to retrieve a DVC variable and convert it to a ResolutionDetails. * @param flagKey diff --git a/yarn.lock b/yarn.lock index 18064ba96..328a2e7d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8395,10 +8395,10 @@ __metadata: languageName: node linkType: hard -"@openfeature/core@npm:^1.1.0": - version: 1.1.0 - resolution: "@openfeature/core@npm:1.1.0" - checksum: c94dfc47b3542a837ec8378a64ab6ad2884029947e27989ec1b4ace675b5598daaa9a004f7859df26a72e208b1a880c92a05c6e90d7d9cd8e61f079ffc4f140f +"@openfeature/core@npm:^1.5.0": + version: 1.5.0 + resolution: "@openfeature/core@npm:1.5.0" + checksum: b8191ef266cee855e4682b71f641edacbc36f293530ff9647f6391570874253240d8a89fe4215f7c9cbed41c9cc10c9e9de6879595553ddf2724aea3fdaff794 languageName: node linkType: hard @@ -8424,21 +8424,21 @@ __metadata: languageName: node linkType: hard -"@openfeature/server-sdk@npm:^1.13.5": - version: 1.13.5 - resolution: "@openfeature/server-sdk@npm:1.13.5" +"@openfeature/server-sdk@npm:^1.16.2": + version: 1.16.2 + resolution: "@openfeature/server-sdk@npm:1.16.2" peerDependencies: - "@openfeature/core": 1.1.0 - checksum: d024ebbfa3a1d63b67d78fa174663d58f0f7746792100c6d6616dfcf59cc3f48a1db3fe7027aabc9eb06f94345995710d2ebc8781fcdbbe41d81a7a4fcba2be6 + "@openfeature/core": ^1.5.0 + checksum: f84ab5e609887f5cec78b96c2d9f9d583d9370acf15022d5e146b3150581caba2c758f798db6d4952305bb8b7c1f87205e0f5ae6740c1910064fc3ad51c5cf43 languageName: node linkType: hard -"@openfeature/web-sdk@npm:^1.0.3": - version: 1.0.3 - resolution: "@openfeature/web-sdk@npm:1.0.3" +"@openfeature/web-sdk@npm:^1.3.2": + version: 1.3.2 + resolution: "@openfeature/web-sdk@npm:1.3.2" peerDependencies: - "@openfeature/core": 1.1.0 - checksum: f84835dad77bfb7a8c762ea45dbc9362f174c37983c87436b7daca94cb230157be9f9f0a896525c5f83bd3ad63e1d4234a55bb38010352a9b1054e48fcd4f7e0 + "@openfeature/core": ^1.5.0 + checksum: 35fb69a071d6cca056cbedc5418d384daf5743cca9eccf0d26775e4b14bbc2aa6bc1c9e9e83458da82817f29a1db5ed66a63cd4cdb1c7599bb9bb4a4d4620d6f languageName: node linkType: hard @@ -15316,11 +15316,11 @@ __metadata: "@nx/web": 16.10.0 "@nx/webpack": 16.10.0 "@nx/workspace": 16.10.0 - "@openfeature/core": ^1.1.0 + "@openfeature/core": ^1.5.0 "@openfeature/multi-provider": ^0.1.1 "@openfeature/multi-provider-web": ^0.0.2 - "@openfeature/server-sdk": ^1.13.5 - "@openfeature/web-sdk": ^1.0.3 + "@openfeature/server-sdk": ^1.16.2 + "@openfeature/web-sdk": ^1.3.2 "@playwright/test": ^1.36.0 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.7 "@react-native-async-storage/async-storage": 1.19.4