From e636f29eb74fa0d9e5faedb07c13ac1c4c4e4f11 Mon Sep 17 00:00:00 2001 From: Alan Barker Date: Thu, 20 Mar 2025 17:16:30 -0400 Subject: [PATCH 1/2] feat: OpenFeature Tracking Support --- CONTRIBUTING.md | 2 +- __tests__/LaunchDarklyProvider.test.ts | 50 +++++++++++++++++++ .../translateTrackingEventDetails.test.ts | 20 ++++++++ package.json | 4 +- src/LaunchDarklyProvider.ts | 22 ++++++++ src/translateTrackingEventDetails.ts | 18 +++++++ 6 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 __tests__/translateTrackingEventDetails.test.ts create mode 100644 src/translateTrackingEventDetails.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93b7f5d..c87917f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ We encourage pull requests and other contributions from the community. Before su ### Prerequisites -The project should be built and tested against the lowest compatible version, Node 16. It uses `npm`, which is bundled in all supported versions of Node. +The project should be built and tested against the lowest compatible version, Node 18. It uses `npm`, which is bundled in all supported versions of Node. ### Setup diff --git a/__tests__/LaunchDarklyProvider.test.ts b/__tests__/LaunchDarklyProvider.test.ts index 4737e7c..276307a 100644 --- a/__tests__/LaunchDarklyProvider.test.ts +++ b/__tests__/LaunchDarklyProvider.test.ts @@ -326,4 +326,54 @@ describe('given a mock LaunchDarkly client', () => { expect(logger.logs[0]).toEqual("The EvaluationContext contained both a 'targetingKey' and a" + " 'key' attribute. The 'key' attribute will be discarded."); }); + + it('handles tracking with invalid context', () => { + ofClient.track('test-event', {}); + expect(logger.logs[0]).toEqual("The EvaluationContext must contain either a 'targetingKey' " + + "or a 'key' and the type must be a string."); + }); + + it('handles tracking with no data or metricValue', () => { + ldClient.track = jest.fn(); + ofClient.track('test-event', basicContext); + expect(ldClient.track).toHaveBeenCalledWith( + 'test-event', + translateContext(logger, basicContext), + undefined, + undefined, + ); + }); + + it('handles tracking with only metricValue', () => { + ldClient.track = jest.fn(); + ofClient.track('test-event', basicContext, { value: 12345 }); + expect(ldClient.track).toHaveBeenCalledWith( + 'test-event', + translateContext(logger, basicContext), + undefined, + 12345, + ); + }); + + it('handles tracking with data but no metricValue', () => { + ldClient.track = jest.fn(); + ofClient.track('test-event', basicContext, { key1: 'val1' }); + expect(ldClient.track).toHaveBeenCalledWith( + 'test-event', + translateContext(logger, basicContext), + { key1: 'val1' }, + undefined, + ); + }); + + it('handles tracking with data and metricValue', () => { + ldClient.track = jest.fn(); + ofClient.track('test-event', basicContext, { value: 12345, key1: 'val1' }); + expect(ldClient.track).toHaveBeenCalledWith( + 'test-event', + translateContext(logger, basicContext), + { key1: 'val1' }, + 12345, + ); + }); }); diff --git a/__tests__/translateTrackingEventDetails.test.ts b/__tests__/translateTrackingEventDetails.test.ts new file mode 100644 index 0000000..384400f --- /dev/null +++ b/__tests__/translateTrackingEventDetails.test.ts @@ -0,0 +1,20 @@ +import translateTrackingEventDetails from '../src/translateTrackingEventDetails'; + +it('returns undefined if details are empty', () => { + expect(translateTrackingEventDetails({})).toBeUndefined(); +}); + +it('returns undefined if details only contains value', () => { + expect(translateTrackingEventDetails({ value: 12345 })).toBeUndefined(); +}); + +it('returns object without value attribute', () => { + expect(translateTrackingEventDetails({ + value: 12345, + key1: 'val1', + key2: 'val2', + })).toEqual({ + key1: 'val1', + key2: 'val2', + }); +}); diff --git a/package.json b/package.json index e283bf3..d530f7f 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,11 @@ "license": "Apache-2.0", "peerDependencies": { "@launchdarkly/node-server-sdk": "9.x", - "@openfeature/server-sdk": "^1.14.0" + "@openfeature/server-sdk": "^1.16.0" }, "devDependencies": { "@launchdarkly/node-server-sdk": "9.x", - "@openfeature/server-sdk": "^1.14.0", + "@openfeature/server-sdk": "^1.16.0", "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", diff --git a/src/LaunchDarklyProvider.ts b/src/LaunchDarklyProvider.ts index d8320e7..2362bc6 100644 --- a/src/LaunchDarklyProvider.ts +++ b/src/LaunchDarklyProvider.ts @@ -9,12 +9,14 @@ import { ProviderMetadata, ResolutionDetails, StandardResolutionReasons, + TrackingEventDetails, } from '@openfeature/server-sdk'; import { basicLogger, init, LDClient, LDLogger, LDOptions, } from '@launchdarkly/node-server-sdk'; import translateContext from './translateContext'; import translateResult from './translateResult'; +import translateTrackingEventDetails from './translateTrackingEventDetails'; import SafeLogger from './SafeLogger'; /** @@ -229,4 +231,24 @@ export default class LaunchDarklyProvider implements Provider { await this.client.flush(); this.client.close(); } + + /** + * Track a user action or application state, usually representing a business objective or outcome. + * @param trackingEventName The name of the event, which may correspond to a metric + * in Experimentation. + * @param context The context to track. + * @param trackingEventDetails Optional additional information to associate with the event. + */ + track( + trackingEventName: string, + context: EvaluationContext, + trackingEventDetails: TrackingEventDetails, + ): void { + this.client.track( + trackingEventName, + this.translateContext(context), + translateTrackingEventDetails(trackingEventDetails), + trackingEventDetails?.value, + ); + } } diff --git a/src/translateTrackingEventDetails.ts b/src/translateTrackingEventDetails.ts new file mode 100644 index 0000000..f541787 --- /dev/null +++ b/src/translateTrackingEventDetails.ts @@ -0,0 +1,18 @@ +import { TrackingEventDetails, TrackingEventValue } from '@openfeature/server-sdk'; + +/** + * Translate {@link TrackingEventDetails} to an object suitable for use as the data + * parameter in LDClient.track(). + * @param details The {@link TrackingEventDetails} to translate. + * @returns An object suitable use as the data parameter in LDClient.track(). + * The value attribute will be removed and if the resulting object is empty, + * returns undefined. + * + * @internal + */ +export default function translateTrackingEventDetails( + details: TrackingEventDetails, +): Record | undefined { + const { value, ...data } = details; + return Object.keys(data).length ? data : undefined; +} From 807308eb50b16c3b3d55ac5db645defd01158647 Mon Sep 17 00:00:00 2001 From: Alan Barker Date: Fri, 21 Mar 2025 11:41:43 -0400 Subject: [PATCH 2/2] chore: update wording of test --- __tests__/translateTrackingEventDetails.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/translateTrackingEventDetails.test.ts b/__tests__/translateTrackingEventDetails.test.ts index 384400f..709d6a0 100644 --- a/__tests__/translateTrackingEventDetails.test.ts +++ b/__tests__/translateTrackingEventDetails.test.ts @@ -8,7 +8,7 @@ it('returns undefined if details only contains value', () => { expect(translateTrackingEventDetails({ value: 12345 })).toBeUndefined(); }); -it('returns object without value attribute', () => { +it('returns an object without the value attribute', () => { expect(translateTrackingEventDetails({ value: 12345, key1: 'val1',