From 42195a06ff69cb71ed2b1acd67463d675e7d076f Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 9 Jun 2023 18:33:41 +0200 Subject: [PATCH 01/10] add LRU TTL Cache Signed-off-by: Thomas Poignant --- .../src/lib/go-feature-flag-provider.ts | 40 ++++++++++++++++--- .../go-feature-flag/src/lib/model.ts | 13 ++++++ .../go-feature-flag/src/lib/test.spec.ts | 11 +++++ package-lock.json | 23 +++++++++-- package.json | 1 + 5 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 libs/providers/go-feature-flag/src/lib/test.spec.ts diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts index 52f281385..df3996602 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts @@ -20,6 +20,7 @@ import { GoFeatureFlagProxyResponse, GoFeatureFlagUser, } from './model'; +import Receptacle from 'receptacle'; // GoFeatureFlagProvider is the official Open-feature provider for GO Feature Flag. @@ -29,20 +30,30 @@ export class GoFeatureFlagProvider implements Provider { }; // endpoint of your go-feature-flag relay proxy instance - private endpoint: string; + private readonly endpoint: string; // timeout in millisecond before we consider the request as a failure - private timeout: number; - // apiKey contains the token to use while calling GO Feature Flag relay proxy - private apiKey?: string; + private readonly timeout: number; + // cache contains the local cache used in the provider to avoid calling the relay-proxy for every evaluation + private cache?: Receptacle>; + + // cacheTTL is the time we keep the evaluation in the cache before we consider it as obsolete. + // If you want to keep the value forever you can set the FlagCacheTTL field to -1 + private readonly cacheTTL?: number constructor(options: GoFeatureFlagProviderOptions) { this.timeout = options.timeout || 0; // default is 0 = no timeout this.endpoint = options.endpoint; + this.cacheTTL = options.flagCacheTTL !== undefined && options.flagCacheTTL !== 0 ? options.flagCacheTTL : 1000 * 60; // Add API key to the headers if (options.apiKey) { axios.defaults.headers.common['Authorization'] = `Bearer ${options.apiKey}`; } + + if(!options.disableCache) { + const cacheSize = options.flagCacheSize!== undefined && options.flagCacheSize !== 0 ? options.flagCacheSize : 10000; + this.cache = new Receptacle>({max: cacheSize}) + } } /** @@ -166,8 +177,17 @@ export class GoFeatureFlagProvider implements Provider { user: GoFeatureFlagUser, expectedType: string ): Promise> { - const request: GoFeatureFlagProxyRequest = { user, defaultValue }; + const cacheKey = `${flagKey}-${user.key}`; + // check if flag is available in the cache + if (this.cache !== undefined) { + const cacheValue = this.cache.get(cacheKey); + if (cacheValue !== null) { + // console.log(this.cache.get(cacheKey).) + return cacheValue; + } + } + const request: GoFeatureFlagProxyRequest = { user, defaultValue }; // build URL to access to the endpoint const endpointURL = new URL(this.endpoint); endpointURL.pathname = `v1/feature/${flagKey}/eval`; @@ -234,7 +254,6 @@ export class GoFeatureFlagProvider implements Provider { const sdkResponse: ResolutionDetails = { value: apiResponseData.value, - variant: apiResponseData.variationType, reason: apiResponseData.reason?.toString() || 'UNKNOWN' }; if (Object.values(ErrorCode).includes(apiResponseData.errorCode as ErrorCode)) { @@ -242,6 +261,15 @@ export class GoFeatureFlagProvider implements Provider { } else if (apiResponseData.errorCode) { sdkResponse.errorCode = ErrorCode.GENERAL; } + + if(this.cache!==undefined && apiResponseData.cacheable){ + console.log('add cache', cacheKey) + if (this.cacheTTL === -1){ + this.cache.set(cacheKey, sdkResponse) + } else { + this.cache.set(cacheKey, sdkResponse, {ttl: this.cacheTTL, refresh: false}) + } + } return sdkResponse; } } diff --git a/libs/providers/go-feature-flag/src/lib/model.ts b/libs/providers/go-feature-flag/src/lib/model.ts index 9db29a801..af9034f54 100644 --- a/libs/providers/go-feature-flag/src/lib/model.ts +++ b/libs/providers/go-feature-flag/src/lib/model.ts @@ -38,6 +38,7 @@ export interface GoFeatureFlagProxyResponse { version?: string; reason: string | GOFeatureFlagResolutionReasons; errorCode?: ErrorCode | GOFeatureFlagErrorCode; + cacheable: boolean; } /** @@ -53,6 +54,18 @@ export interface GoFeatureFlagProviderOptions { // (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above) // Default: null apiKey?: string; + + // disableCache (optional) set to true if you would like that every flag evaluation goes to the GO Feature Flag directly. + disableCache?: boolean + + // flagCacheSize (optional) is the maximum number of flag events we keep in memory to cache your flags. + // default: 10000 + flagCacheSize?: number + + // flagCacheTTL (optional) is the time we keep the evaluation in the cache before we consider it as obsolete. + // If you want to keep the value forever you can set the FlagCacheTTL field to -1 + // default: 1 minute + flagCacheTTL?: number } // GOFeatureFlagResolutionReasons allows to extends resolution reasons diff --git a/libs/providers/go-feature-flag/src/lib/test.spec.ts b/libs/providers/go-feature-flag/src/lib/test.spec.ts new file mode 100644 index 000000000..68ea7b4d7 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/test.spec.ts @@ -0,0 +1,11 @@ +import {OpenFeature} from '@openfeature/js-sdk'; +import { GoFeatureFlagProvider } from './go-feature-flag-provider'; +it('XXX', async () => { + const goff = new GoFeatureFlagProvider({endpoint:'http://localhost:1031', flagCacheTTL: 3000, flagCacheSize:1}) + OpenFeature.setProvider(goff) + const cli = OpenFeature.getClient() + console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) + await new Promise((r) => setTimeout(r, 3100)); + console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) + +}) diff --git a/package-lock.json b/package-lock.json index 3fcc5ebfa..357c2c11e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "lodash.isequal": "^4.5.0", "lru-cache": "^9.0.0", "object-hash": "^3.0.0", + "receptacle": "^1.3.2", "tslib": "2.5.0" }, "devDependencies": { @@ -9241,8 +9242,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.6", @@ -10785,6 +10785,14 @@ "node": ">=8.10.0" } }, + "node_modules/receptacle": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/receptacle/-/receptacle-1.3.2.tgz", + "integrity": "sha512-HrsFvqZZheusncQRiEE7GatOAETrARKV/lnfYicIm8lbvp/JQOdADOfhjBd2DajvoszEyxSM6RlAAIZgEoeu/A==", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -19273,8 +19281,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "nanoid": { "version": "3.3.6", @@ -20337,6 +20344,14 @@ "picomatch": "^2.2.1" } }, + "receptacle": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/receptacle/-/receptacle-1.3.2.tgz", + "integrity": "sha512-HrsFvqZZheusncQRiEE7GatOAETrARKV/lnfYicIm8lbvp/JQOdADOfhjBd2DajvoszEyxSM6RlAAIZgEoeu/A==", + "requires": { + "ms": "^2.1.1" + } + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", diff --git a/package.json b/package.json index 55475bd31..12fd5e95e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "lodash.isequal": "^4.5.0", "lru-cache": "^9.0.0", "object-hash": "^3.0.0", + "receptacle": "^1.3.2", "tslib": "2.5.0" }, "devDependencies": { From 62efbe5adf39154abac21b6e2dbd1d16daabd9bc Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 13 Jun 2023 22:36:35 +0200 Subject: [PATCH 02/10] Add call to data collector Signed-off-by: Thomas Poignant --- .../src/lib/go-feature-flag-provider.ts | 110 +++++++++++++++--- .../go-feature-flag/src/lib/model.ts | 28 +++++ .../go-feature-flag/src/lib/test.spec.ts | 20 +++- libs/providers/go-feature-flag/tsconfig.json | 7 +- 4 files changed, 142 insertions(+), 23 deletions(-) diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts index c3daf6808..6491191dc 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts @@ -9,12 +9,15 @@ import { TypeMismatchError, } from '@openfeature/js-sdk'; import axios from 'axios'; -import { transformContext } from './context-transformer'; -import { ProxyNotReady } from './errors/proxyNotReady'; -import { ProxyTimeout } from './errors/proxyTimeout'; -import { UnknownError } from './errors/unknownError'; -import { Unauthorized } from './errors/unauthorized'; +import {transformContext} from './context-transformer'; +import {ProxyNotReady} from './errors/proxyNotReady'; +import {ProxyTimeout} from './errors/proxyTimeout'; +import {UnknownError} from './errors/unknownError'; +import {Unauthorized} from './errors/unauthorized'; import { + DataCollectorRequest, + DataCollectorResponse, + FeatureEvent, GoFeatureFlagProviderOptions, GoFeatureFlagProxyRequest, GoFeatureFlagProxyResponse, @@ -22,7 +25,6 @@ import { } from './model'; import Receptacle from 'receptacle'; - // GoFeatureFlagProvider is the official Open-feature provider for GO Feature Flag. export class GoFeatureFlagProvider implements Provider { metadata = { @@ -33,29 +35,90 @@ export class GoFeatureFlagProvider implements Provider { private readonly endpoint: string; // timeout in millisecond before we consider the request as a failure private readonly timeout: number; + + // cache contains the local cache used in the provider to avoid calling the relay-proxy for every evaluation - private cache?: Receptacle>; + private readonly cache?: Receptacle>; + // bgSchedulerId contains the id of the setInterval that is running. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + private bgScheduler?: NodeJS.Timer; + + // dataCollectorBuffer contains all the FeatureEvents that we need to send to the relay-proxy for data collection. + private dataCollectorBuffer?: FeatureEvent[]; + // dataCollectorMetadata are the metadata used when calling the data collector endpoint + private readonly dataCollectorMetadata: Record = { + provider: 'open-feature-js-sdk', + }; // cacheTTL is the time we keep the evaluation in the cache before we consider it as obsolete. // If you want to keep the value forever you can set the FlagCacheTTL field to -1 - private readonly cacheTTL?: number + private readonly cacheTTL?: number; + + // dataFlushInterval interval time (in millisecond) we use to call the relay proxy to collect data. + private readonly dataFlushInterval: number; constructor(options: GoFeatureFlagProviderOptions) { this.timeout = options.timeout || 0; // default is 0 = no timeout this.endpoint = options.endpoint; this.cacheTTL = options.flagCacheTTL !== undefined && options.flagCacheTTL !== 0 ? options.flagCacheTTL : 1000 * 60; + this.dataFlushInterval = options.dataFlushInterval || 1000 * 60; // Add API key to the headers if (options.apiKey) { axios.defaults.headers.common['Authorization'] = `Bearer ${options.apiKey}`; } - if(!options.disableCache) { - const cacheSize = options.flagCacheSize!== undefined && options.flagCacheSize !== 0 ? options.flagCacheSize : 10000; + if (!options.disableCache) { + const cacheSize = options.flagCacheSize !== undefined && options.flagCacheSize !== 0 ? options.flagCacheSize : 10000; this.cache = new Receptacle>({max: cacheSize}) } } + + async onClose() { + if (this.cache !== undefined && this.bgScheduler !== undefined) { + // we stop the background task to call the data collector endpoint + clearInterval(this.bgScheduler); + // We call the data collector with what is still in the buffer. + await this.callGoffDataCollection() + } + } + + async initialize() { + if (this.cache !== undefined) { + this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval) + this.dataCollectorBuffer = [] + } + } + + async callGoffDataCollection() { + if (this.dataCollectorBuffer?.length === 0) { + return + } + + const dataToSend = structuredClone(this.dataCollectorBuffer); + this.dataCollectorBuffer = [] + + const request: DataCollectorRequest = {events: dataToSend, meta: this.dataCollectorMetadata,} + const endpointURL = new URL(this.endpoint); + endpointURL.pathname = 'v1/data/collector'; + + try { + await axios.post(endpointURL.toString(), request, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + timeout: this.timeout, + }); + } catch (e) { + // TODO : add a log here + // if we have an issue calling the collector we put the data back in the buffer + this.dataCollectorBuffer = [...this.dataCollectorBuffer, ...dataToSend] + } + } + /** * resolveBooleanEvaluation is calling the GO Feature Flag relay-proxy API and return a boolean value. * @param flagKey - name of your feature flag key. @@ -136,7 +199,7 @@ export class GoFeatureFlagProvider implements Provider { * @param flagKey - name of your feature flag key. * @param defaultValue - default value is used if we are not able to evaluate the flag for this user. * @param context - the context used for flag evaluation. - * @return {Promise>} An object containing the result of the flag evaluation by GO Feature Flag. + * @return {Promise>} An object containing the result of the flag evaluation by GO Feature Flag. * @throws {ProxyNotReady} When we are not able to communicate with the relay-proxy * @throws {ProxyTimeout} When the HTTP call is timing out * @throws {UnknownError} When an unknown error occurs @@ -182,12 +245,25 @@ export class GoFeatureFlagProvider implements Provider { if (this.cache !== undefined) { const cacheValue = this.cache.get(cacheKey); if (cacheValue !== null) { - // console.log(this.cache.get(cacheKey).) + // Building and inserting an event to the data collector buffer, + // so we will be able to bulk send these events to GO Feature Flag. + const dataCollectorEvent: FeatureEvent = { + contextKind: user.anonymous ? 'anonymousUser' : 'user', + kind: 'feature', + creationDate: Math.round(Date.now() / 1000), + default: false, + key: flagKey, + value: cacheValue.value, + variation: cacheValue.variant || 'SdkDefault', + userKey: user.key, + } + this.dataCollectorBuffer?.push(dataCollectorEvent) + return cacheValue; } } - const request: GoFeatureFlagProxyRequest = { user, defaultValue }; + const request: GoFeatureFlagProxyRequest = {user, defaultValue}; // build URL to access to the endpoint const endpointURL = new URL(this.endpoint); endpointURL.pathname = `v1/feature/${flagKey}/eval`; @@ -249,12 +325,11 @@ export class GoFeatureFlagProvider implements Provider { if (apiResponseData.reason === StandardResolutionReasons.DISABLED) { // we don't set a variant since we are using the default value, and we are not able to know // which variant it is. - return { value: defaultValue, reason: apiResponseData.reason }; + return {value: defaultValue, reason: apiResponseData.reason}; } const sdkResponse: ResolutionDetails = { value: apiResponseData.value, - reason: apiResponseData.reason?.toString() || 'UNKNOWN' variant: apiResponseData.variationType, reason: apiResponseData.reason?.toString() || 'UNKNOWN', flagMetadata: apiResponseData.metadata || undefined, @@ -265,9 +340,8 @@ export class GoFeatureFlagProvider implements Provider { sdkResponse.errorCode = ErrorCode.GENERAL; } - if(this.cache!==undefined && apiResponseData.cacheable){ - console.log('add cache', cacheKey) - if (this.cacheTTL === -1){ + if (this.cache !== undefined && apiResponseData.cacheable) { + if (this.cacheTTL === -1) { this.cache.set(cacheKey, sdkResponse) } else { this.cache.set(cacheKey, sdkResponse, {ttl: this.cacheTTL, refresh: false}) diff --git a/libs/providers/go-feature-flag/src/lib/model.ts b/libs/providers/go-feature-flag/src/lib/model.ts index da0b553aa..13e1e45d3 100644 --- a/libs/providers/go-feature-flag/src/lib/model.ts +++ b/libs/providers/go-feature-flag/src/lib/model.ts @@ -67,6 +67,12 @@ export interface GoFeatureFlagProviderOptions { // If you want to keep the value forever you can set the FlagCacheTTL field to -1 // default: 1 minute flagCacheTTL?: number + + // dataFlushInterval (optional) interval time (in millisecond) we use to call the relay proxy to collect data. + // The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly + // when calling the evaluation API. + // default: 1 minute + dataFlushInterval?: number } // GOFeatureFlagResolutionReasons allows to extends resolution reasons @@ -74,3 +80,25 @@ export declare enum GOFeatureFlagResolutionReasons {} // GOFeatureFlagErrorCode allows to extends error codes export declare enum GOFeatureFlagErrorCode {} + + +export interface DataCollectorRequest { + events: FeatureEvent[]; + meta: Record; +} + +export interface FeatureEvent { + contextKind: string; + creationDate: number; + default: boolean; + key: string; + kind: string; + userKey: string; + value: T; + variation: string; + version?: string; +} + +export interface DataCollectorResponse { + ingestedContentCount: number; +} diff --git a/libs/providers/go-feature-flag/src/lib/test.spec.ts b/libs/providers/go-feature-flag/src/lib/test.spec.ts index 68ea7b4d7..40810e5b4 100644 --- a/libs/providers/go-feature-flag/src/lib/test.spec.ts +++ b/libs/providers/go-feature-flag/src/lib/test.spec.ts @@ -1,11 +1,25 @@ import {OpenFeature} from '@openfeature/js-sdk'; -import { GoFeatureFlagProvider } from './go-feature-flag-provider'; +import {GoFeatureFlagProvider} from './go-feature-flag-provider'; + +jest.setTimeout(1000 * 60 * 60); it('XXX', async () => { - const goff = new GoFeatureFlagProvider({endpoint:'http://localhost:1031', flagCacheTTL: 3000, flagCacheSize:1}) + const goff = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + flagCacheTTL: 3000, + flagCacheSize: 1, + dataFlushInterval: 1000000 + }) OpenFeature.setProvider(goff) const cli = OpenFeature.getClient() console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) - await new Promise((r) => setTimeout(r, 3100)); console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) + await new Promise((r) => setTimeout(r, 1000)); + console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) + console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) + console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) + console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) + console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) + await OpenFeature.close() + // await new Promise((r) => setTimeout(r, 1000 * 60)); }) diff --git a/libs/providers/go-feature-flag/tsconfig.json b/libs/providers/go-feature-flag/tsconfig.json index 140e5a783..f17043ebe 100644 --- a/libs/providers/go-feature-flag/tsconfig.json +++ b/libs/providers/go-feature-flag/tsconfig.json @@ -7,7 +7,10 @@ "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "types": [ + "node" + ] }, "files": [], "include": [], @@ -18,5 +21,5 @@ { "path": "./tsconfig.spec.json" } - ] + ], } From cbdd0387603e1a83047da6f6f3c00dc3847e68d7 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 13 Jun 2023 22:38:59 +0200 Subject: [PATCH 03/10] revert change in tsconfig Signed-off-by: Thomas Poignant --- libs/providers/go-feature-flag/tsconfig.json | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/libs/providers/go-feature-flag/tsconfig.json b/libs/providers/go-feature-flag/tsconfig.json index f17043ebe..140e5a783 100644 --- a/libs/providers/go-feature-flag/tsconfig.json +++ b/libs/providers/go-feature-flag/tsconfig.json @@ -7,10 +7,7 @@ "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "types": [ - "node" - ] + "noFallthroughCasesInSwitch": true }, "files": [], "include": [], @@ -21,5 +18,5 @@ { "path": "./tsconfig.spec.json" } - ], + ] } From fb8e63dd42e16e9e47e563c3bd6399aba00f89bd Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 13 Jun 2023 22:51:38 +0200 Subject: [PATCH 04/10] Add comments Signed-off-by: Thomas Poignant --- .../src/lib/go-feature-flag-provider.ts | 23 ++++++++++++++----- .../go-feature-flag/src/lib/model.ts | 3 +++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts index 6491191dc..c7648fb5e 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts @@ -57,12 +57,15 @@ export class GoFeatureFlagProvider implements Provider { // dataFlushInterval interval time (in millisecond) we use to call the relay proxy to collect data. private readonly dataFlushInterval: number; + // disableDataCollection set to true if you don't want to collect the usage of flags retrieved in the cache. + private readonly disableDataCollection: boolean; constructor(options: GoFeatureFlagProviderOptions) { this.timeout = options.timeout || 0; // default is 0 = no timeout this.endpoint = options.endpoint; this.cacheTTL = options.flagCacheTTL !== undefined && options.flagCacheTTL !== 0 ? options.flagCacheTTL : 1000 * 60; this.dataFlushInterval = options.dataFlushInterval || 1000 * 60; + this.disableDataCollection = options.disableDataCollection; // Add API key to the headers if (options.apiKey) { @@ -75,7 +78,21 @@ export class GoFeatureFlagProvider implements Provider { } } + /** + * initialize is called everytime the provider is instanced inside GO Feature Flag. + * It will start the background process for data collection to be able to run every X ms. + */ + async initialize() { + if (!this.disableDataCollection) { + this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval) + this.dataCollectorBuffer = [] + } + } + /** + * onClose is called everytime OpenFeature.Close() function is called. + * It will terminate gracefully the provider and ensure that all the data are send to the relay-proxy. + */ async onClose() { if (this.cache !== undefined && this.bgScheduler !== undefined) { // we stop the background task to call the data collector endpoint @@ -85,12 +102,6 @@ export class GoFeatureFlagProvider implements Provider { } } - async initialize() { - if (this.cache !== undefined) { - this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval) - this.dataCollectorBuffer = [] - } - } async callGoffDataCollection() { if (this.dataCollectorBuffer?.length === 0) { diff --git a/libs/providers/go-feature-flag/src/lib/model.ts b/libs/providers/go-feature-flag/src/lib/model.ts index 13e1e45d3..54adae7be 100644 --- a/libs/providers/go-feature-flag/src/lib/model.ts +++ b/libs/providers/go-feature-flag/src/lib/model.ts @@ -73,6 +73,9 @@ export interface GoFeatureFlagProviderOptions { // when calling the evaluation API. // default: 1 minute dataFlushInterval?: number + + // disableDataCollection set to true if you don't want to collect the usage of flags retrieved in the cache. + disableDataCollection: boolean } // GOFeatureFlagResolutionReasons allows to extends resolution reasons From 04b5d2f3d2a80f208afa74509277f3f5d2d85461 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 15 Jun 2023 14:28:47 +0200 Subject: [PATCH 05/10] Adding test for the cache Signed-off-by: Thomas Poignant --- .../src/lib/go-feature-flag-provider.spec.ts | 227 ++++++++++++++---- .../src/lib/go-feature-flag-provider.ts | 2 +- .../go-feature-flag/src/lib/model.ts | 2 +- 3 files changed, 183 insertions(+), 48 deletions(-) diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts index 213721318..bfdef6314 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts @@ -3,35 +3,38 @@ */ import { ErrorCode, - FlagNotFoundError, ResolutionDetails, + FlagNotFoundError, OpenFeature, ResolutionDetails, StandardResolutionReasons, TypeMismatchError } from '@openfeature/js-sdk'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { ProxyNotReady } from './errors/proxyNotReady'; -import { ProxyTimeout } from './errors/proxyTimeout'; -import { UnknownError } from './errors/unknownError'; -import { Unauthorized } from './errors/unauthorized'; -import { GoFeatureFlagProvider } from './go-feature-flag-provider'; -import { GoFeatureFlagProxyResponse } from './model'; +import {ProxyNotReady} from './errors/proxyNotReady'; +import {ProxyTimeout} from './errors/proxyTimeout'; +import {UnknownError} from './errors/unknownError'; +import {Unauthorized} from './errors/unauthorized'; +import {GoFeatureFlagProvider} from './go-feature-flag-provider'; +import {GoFeatureFlagProxyResponse} from './model'; describe('GoFeatureFlagProvider', () => { const endpoint = 'http://go-feature-flag-relay-proxy.local:1031/'; const axiosMock = new MockAdapter(axios); let goff: GoFeatureFlagProvider; - afterEach(() => { + afterEach(async () => { axiosMock.reset(); + axiosMock.resetHistory(); + axiosMock.resetHandlers() + await OpenFeature.close(); }); beforeEach(() => { - goff = new GoFeatureFlagProvider({ endpoint }); + goff = new GoFeatureFlagProvider({endpoint}); }); describe('common usecases and errors', () => { it('should be an instance of GoFeatureFlagProvider', () => { - const goff = new GoFeatureFlagProvider({ endpoint }); + const goff = new GoFeatureFlagProvider({endpoint}); expect(goff).toBeInstanceOf(GoFeatureFlagProvider); }); @@ -41,7 +44,7 @@ describe('GoFeatureFlagProvider', () => { const dns = `${endpoint}v1/feature/${flagName}/eval`; axiosMock.onPost(dns).reply(404); await goff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .catch((err) => { expect(err).toBeInstanceOf(ProxyNotReady); expect(err.message).toEqual( @@ -56,7 +59,7 @@ describe('GoFeatureFlagProvider', () => { const dns = `${endpoint}v1/feature/${flagName}/eval`; axiosMock.onPost(dns).timeout(); await goff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .catch((err) => { expect(err).toBeInstanceOf(ProxyTimeout); expect(err.message).toEqual( @@ -76,7 +79,7 @@ describe('GoFeatureFlagProvider', () => { errorCode: ErrorCode.PARSE_ERROR, } as GoFeatureFlagProxyResponse); await goff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .then((result) => { expect(result.errorCode).toEqual(ErrorCode.PARSE_ERROR) }) @@ -92,7 +95,7 @@ describe('GoFeatureFlagProvider', () => { errorCode: 'NOT-AN-SDK-ERROR', } as unknown as GoFeatureFlagProxyResponse); await goff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .then((result) => { expect(result.errorCode).toEqual(ErrorCode.GENERAL) }) @@ -105,7 +108,7 @@ describe('GoFeatureFlagProvider', () => { const dns = `${endpoint}v1/feature/${flagName}/eval`; axiosMock.onPost(dns).networkError(); await goff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .catch((err) => { expect(err).toBeInstanceOf(UnknownError); expect(err.message).toEqual( @@ -125,7 +128,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveStringEvaluation(flagName, 'sdk-default', { targetingKey }) + .resolveStringEvaluation(flagName, 'sdk-default', {targetingKey}) .catch((err) => { expect(err).toBeInstanceOf(FlagNotFoundError); expect(err.message).toEqual( @@ -141,9 +144,9 @@ describe('GoFeatureFlagProvider', () => { axiosMock.onPost(dns).reply(401, {} as GoFeatureFlagProxyResponse); - const authenticatedGoff = new GoFeatureFlagProvider({ endpoint, apiKey }); + const authenticatedGoff = new GoFeatureFlagProvider({endpoint, apiKey}); await authenticatedGoff - .resolveStringEvaluation(flagName, 'sdk-default', { targetingKey }) + .resolveStringEvaluation(flagName, 'sdk-default', {targetingKey}) .catch((err) => { expect(err).toBeInstanceOf(Unauthorized); expect(err.message).toEqual( @@ -167,9 +170,9 @@ describe('GoFeatureFlagProvider', () => { version: '1.0.0', } as GoFeatureFlagProxyResponse); - const authenticatedGoff = new GoFeatureFlagProvider({ endpoint, apiKey }); + const authenticatedGoff = new GoFeatureFlagProvider({endpoint, apiKey}); await authenticatedGoff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.TARGETING_MATCH, @@ -192,7 +195,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .catch((err) => { expect(err).toBeInstanceOf(TypeMismatchError); expect(err.message).toEqual( @@ -216,7 +219,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.TARGETING_MATCH, @@ -240,7 +243,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.SPLIT, @@ -264,7 +267,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.DISABLED, @@ -286,7 +289,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveStringEvaluation(flagName, 'false', { targetingKey }) + .resolveStringEvaluation(flagName, 'false', {targetingKey}) .catch((err) => { expect(err).toBeInstanceOf(TypeMismatchError); expect(err.message).toEqual( @@ -309,7 +312,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveStringEvaluation(flagName, 'default', { targetingKey }) + .resolveStringEvaluation(flagName, 'default', {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.TARGETING_MATCH, @@ -333,7 +336,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveStringEvaluation(flagName, 'default', { targetingKey }) + .resolveStringEvaluation(flagName, 'default', {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.SPLIT, @@ -381,7 +384,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveNumberEvaluation(flagName, 14, { targetingKey }) + .resolveNumberEvaluation(flagName, 14, {targetingKey}) .catch((err) => { expect(err).toBeInstanceOf(TypeMismatchError); expect(err.message).toEqual( @@ -404,7 +407,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveNumberEvaluation(flagName, 17, { targetingKey }) + .resolveNumberEvaluation(flagName, 17, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.TARGETING_MATCH, @@ -428,7 +431,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveNumberEvaluation(flagName, 17, { targetingKey }) + .resolveNumberEvaluation(flagName, 17, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.SPLIT, @@ -452,7 +455,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveNumberEvaluation(flagName, 124, { targetingKey }) + .resolveNumberEvaluation(flagName, 124, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.DISABLED, @@ -474,7 +477,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveObjectEvaluation(flagName, {}, { targetingKey }) + .resolveObjectEvaluation(flagName, {}, {targetingKey}) .catch((err) => { expect(err).toBeInstanceOf(TypeMismatchError); expect(err.message).toEqual( @@ -488,7 +491,7 @@ describe('GoFeatureFlagProvider', () => { const dns = `${endpoint}v1/feature/${flagName}/eval`; axiosMock.onPost(dns).reply(200, { - value: { key: true }, + value: {key: true}, variationType: 'trueVariation', reason: StandardResolutionReasons.TARGETING_MATCH, failed: false, @@ -497,11 +500,11 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveObjectEvaluation(flagName, { key: 'default' }, { targetingKey }) + .resolveObjectEvaluation(flagName, {key: 'default'}, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.TARGETING_MATCH, - value: { key: true }, + value: {key: true}, variant: 'trueVariation', } as ResolutionDetails); }); @@ -512,7 +515,7 @@ describe('GoFeatureFlagProvider', () => { const dns = `${endpoint}v1/feature/${flagName}/eval`; axiosMock.onPost(dns).reply(200, { - value: { key: true }, + value: {key: true}, variationType: 'trueVariation', reason: StandardResolutionReasons.SPLIT, failed: false, @@ -521,11 +524,11 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveObjectEvaluation(flagName, { key: 'default' }, { targetingKey }) + .resolveObjectEvaluation(flagName, {key: 'default'}, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.SPLIT, - value: { key: true }, + value: {key: true}, variant: 'trueVariation', } as ResolutionDetails); }); @@ -536,7 +539,7 @@ describe('GoFeatureFlagProvider', () => { const dns = `${endpoint}v1/feature/${flagName}/eval`; axiosMock.onPost(dns).reply(200, { - value: { key: 123 }, + value: {key: 123}, variationType: 'defaultSdk', reason: StandardResolutionReasons.DISABLED, failed: false, @@ -545,11 +548,11 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveObjectEvaluation(flagName, { key: 124 }, { targetingKey }) + .resolveObjectEvaluation(flagName, {key: 124}, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.DISABLED, - value: { key: 124 }, + value: {key: 124}, } as ResolutionDetails); }); }); @@ -569,7 +572,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveObjectEvaluation(flagName, { key: 'default' }, { targetingKey }) + .resolveObjectEvaluation(flagName, {key: 'default'}, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.TARGETING_MATCH, @@ -593,7 +596,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveObjectEvaluation(flagName, { key: 'default' }, { targetingKey }) + .resolveObjectEvaluation(flagName, {key: 'default'}, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.SPLIT, @@ -617,7 +620,7 @@ describe('GoFeatureFlagProvider', () => { } as GoFeatureFlagProxyResponse); await goff - .resolveObjectEvaluation(flagName, ['key', '124'], { targetingKey }) + .resolveObjectEvaluation(flagName, ['key', '124'], {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.DISABLED, @@ -640,11 +643,12 @@ describe('GoFeatureFlagProvider', () => { metadata: { description: 'a description of the flag', issue_number: 1, - } + }, + cacheable: true, } as GoFeatureFlagProxyResponse); await goff - .resolveBooleanEvaluation(flagName, false, { targetingKey }) + .resolveBooleanEvaluation(flagName, false, {targetingKey}) .then((res) => { expect(res).toEqual({ reason: StandardResolutionReasons.TARGETING_MATCH, @@ -658,4 +662,135 @@ describe('GoFeatureFlagProvider', () => { }); }); }); + describe('cache testing', () => { + const validBoolResponse: GoFeatureFlagProxyResponse = { + value: true, + variationType: 'trueVariation', + reason: StandardResolutionReasons.TARGETING_MATCH, + failed: false, + trackEvents: true, + version: '1.0.0', + metadata: { + description: 'a description of the flag', + issue_number: 1, + }, + cacheable: true, + }; + + it('should use the cache if we evaluate 2 times the same flag', async () => { + const flagName = 'random-flag'; + const targetingKey = 'user-key'; + const dns = `${endpoint}v1/feature/${flagName}/eval`; + + axiosMock.onPost(dns).reply(200, validBoolResponse); + const goff = new GoFeatureFlagProvider({ + endpoint, + flagCacheTTL: 3000, + flagCacheSize: 1, + disableDataCollection: true, + }) + OpenFeature.setProvider(goff); + const cli = OpenFeature.getClient(); + const got1 = await cli.getBooleanDetails(flagName, false, {targetingKey}); + const got2 = await cli.getBooleanDetails(flagName, false, {targetingKey}); + expect(got1).toEqual(got2); + expect(axiosMock.history['post'].length).toBe(1); + }); + + it('should use not use the cache if we evaluate 2 times the same flag if cache is disabled', async () => { + const flagName = 'random-flag'; + const targetingKey = 'user-key'; + const dns = `${endpoint}v1/feature/${flagName}/eval`; + + axiosMock.onPost(dns).reply(200, validBoolResponse); + const goff = new GoFeatureFlagProvider({ + endpoint, + disableCache: true, + disableDataCollection: true, + }) + OpenFeature.setProvider(goff); + const cli = OpenFeature.getClient(); + const got1 = await cli.getBooleanDetails(flagName, false, {targetingKey}); + const got2 = await cli.getBooleanDetails(flagName, false, {targetingKey}); + expect(got1).toEqual(got2); + expect(axiosMock.history['post'].length).toBe(2); + }); + + it('should not retrieve from the cache if max size cache is reached', async () => { + const flagName1 = 'random-flag'; + const flagName2 = 'random-flag-1'; + const targetingKey = 'user-key'; + const dns1 = `${endpoint}v1/feature/${flagName1}/eval`; + const dns2 = `${endpoint}v1/feature/${flagName2}/eval`; + axiosMock.onPost(dns1).reply(200, validBoolResponse); + axiosMock.onPost(dns2).reply(200, validBoolResponse); + const goff = new GoFeatureFlagProvider({ + endpoint, + flagCacheSize: 1, + disableDataCollection: true, + }) + OpenFeature.setProvider(goff); + const cli = OpenFeature.getClient(); + await cli.getBooleanDetails(flagName1, false, {targetingKey}); + await cli.getBooleanDetails(flagName2, false, {targetingKey}); + await cli.getBooleanDetails(flagName1, false, {targetingKey}); + expect(axiosMock.history['post'].length).toBe(3); + }); + + it('should not store in the cache if cacheable is false', async () => { + const flagName = 'random-flag'; + const targetingKey = 'user-key'; + const dns1 = `${endpoint}v1/feature/${flagName}/eval`; + axiosMock.onPost(dns1).reply(200, {...validBoolResponse, cacheable: false}); + const goff = new GoFeatureFlagProvider({ + endpoint, + flagCacheSize: 1, + disableDataCollection: true, + }) + OpenFeature.setProvider(goff); + const cli = OpenFeature.getClient(); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + expect(axiosMock.history['post'].length).toBe(2); + }); + + it('should not retrieve from the cache it the TTL is reached', async () => { + const flagName = 'random-flag'; + const targetingKey = 'user-key'; + const dns1 = `${endpoint}v1/feature/${flagName}/eval`; + axiosMock.onPost(dns1).reply(200, {...validBoolResponse}); + const goff = new GoFeatureFlagProvider({ + endpoint, + flagCacheSize: 1, + disableDataCollection: true, + flagCacheTTL: 200, + }) + OpenFeature.setProvider(goff); + const cli = OpenFeature.getClient(); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await new Promise((r) => setTimeout(r, 300)); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + expect(axiosMock.history['post'].length).toBe(2); + }); + + it('should not retrieve from the cache if we have 2 different flag', async () => { + const flagName1 = 'random-flag'; + const flagName2 = 'random-flag-1'; + const targetingKey = 'user-key'; + const dns1 = `${endpoint}v1/feature/${flagName1}/eval`; + const dns2 = `${endpoint}v1/feature/${flagName2}/eval`; + axiosMock.onPost(dns1).reply(200, validBoolResponse); + axiosMock.onPost(dns2).reply(200, validBoolResponse); + const goff = new GoFeatureFlagProvider({ + endpoint, + flagCacheSize: 1, + disableDataCollection: true, + }) + OpenFeature.setProvider(goff); + const cli = OpenFeature.getClient(); + await cli.getBooleanDetails(flagName1, false, {targetingKey}); + await cli.getBooleanDetails(flagName2, false, {targetingKey}); + expect(axiosMock.history['post'].length).toBe(2); + }); + }); }); diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts index c7648fb5e..6e07bd953 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts @@ -65,7 +65,7 @@ export class GoFeatureFlagProvider implements Provider { this.endpoint = options.endpoint; this.cacheTTL = options.flagCacheTTL !== undefined && options.flagCacheTTL !== 0 ? options.flagCacheTTL : 1000 * 60; this.dataFlushInterval = options.dataFlushInterval || 1000 * 60; - this.disableDataCollection = options.disableDataCollection; + this.disableDataCollection = options.disableDataCollection || false; // Add API key to the headers if (options.apiKey) { diff --git a/libs/providers/go-feature-flag/src/lib/model.ts b/libs/providers/go-feature-flag/src/lib/model.ts index 54adae7be..d8caac155 100644 --- a/libs/providers/go-feature-flag/src/lib/model.ts +++ b/libs/providers/go-feature-flag/src/lib/model.ts @@ -75,7 +75,7 @@ export interface GoFeatureFlagProviderOptions { dataFlushInterval?: number // disableDataCollection set to true if you don't want to collect the usage of flags retrieved in the cache. - disableDataCollection: boolean + disableDataCollection?: boolean } // GOFeatureFlagResolutionReasons allows to extends resolution reasons From 9a959050d1da1f0760ebdbbb489ce03f61850f76 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 15 Jun 2023 14:50:32 +0200 Subject: [PATCH 06/10] Use LRU cache instead of receptacle Signed-off-by: Thomas Poignant --- .../src/lib/go-feature-flag-provider.ts | 17 ++++++++----- .../go-feature-flag/src/lib/test.spec.ts | 25 ------------------- package-lock.json | 23 +++-------------- package.json | 1 - 4 files changed, 15 insertions(+), 51 deletions(-) delete mode 100644 libs/providers/go-feature-flag/src/lib/test.spec.ts diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts index 6e07bd953..4e055c53b 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts @@ -14,6 +14,7 @@ import {ProxyNotReady} from './errors/proxyNotReady'; import {ProxyTimeout} from './errors/proxyTimeout'; import {UnknownError} from './errors/unknownError'; import {Unauthorized} from './errors/unauthorized'; +import {LRUCache} from 'lru-cache'; import { DataCollectorRequest, DataCollectorResponse, @@ -23,7 +24,6 @@ import { GoFeatureFlagProxyResponse, GoFeatureFlagUser, } from './model'; -import Receptacle from 'receptacle'; // GoFeatureFlagProvider is the official Open-feature provider for GO Feature Flag. export class GoFeatureFlagProvider implements Provider { @@ -38,7 +38,9 @@ export class GoFeatureFlagProvider implements Provider { // cache contains the local cache used in the provider to avoid calling the relay-proxy for every evaluation - private readonly cache?: Receptacle>; + private readonly cache?: LRUCache>; + + // bgSchedulerId contains the id of the setInterval that is running. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -74,7 +76,7 @@ export class GoFeatureFlagProvider implements Provider { if (!options.disableCache) { const cacheSize = options.flagCacheSize !== undefined && options.flagCacheSize !== 0 ? options.flagCacheSize : 10000; - this.cache = new Receptacle>({max: cacheSize}) + this.cache = new LRUCache({maxSize: cacheSize, sizeCalculation: () => 1}); } } @@ -103,6 +105,10 @@ export class GoFeatureFlagProvider implements Provider { } + /** + * callGoffDataCollection is a function called periodically to send the usage of the flag to the + * central service in charge of collecting the data. + */ async callGoffDataCollection() { if (this.dataCollectorBuffer?.length === 0) { return @@ -255,7 +261,7 @@ export class GoFeatureFlagProvider implements Provider { // check if flag is available in the cache if (this.cache !== undefined) { const cacheValue = this.cache.get(cacheKey); - if (cacheValue !== null) { + if (cacheValue !== undefined) { // Building and inserting an event to the data collector buffer, // so we will be able to bulk send these events to GO Feature Flag. const dataCollectorEvent: FeatureEvent = { @@ -269,7 +275,6 @@ export class GoFeatureFlagProvider implements Provider { userKey: user.key, } this.dataCollectorBuffer?.push(dataCollectorEvent) - return cacheValue; } } @@ -355,7 +360,7 @@ export class GoFeatureFlagProvider implements Provider { if (this.cacheTTL === -1) { this.cache.set(cacheKey, sdkResponse) } else { - this.cache.set(cacheKey, sdkResponse, {ttl: this.cacheTTL, refresh: false}) + this.cache.set(cacheKey, sdkResponse, {ttl: this.cacheTTL}) } } return sdkResponse; diff --git a/libs/providers/go-feature-flag/src/lib/test.spec.ts b/libs/providers/go-feature-flag/src/lib/test.spec.ts deleted file mode 100644 index 40810e5b4..000000000 --- a/libs/providers/go-feature-flag/src/lib/test.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {OpenFeature} from '@openfeature/js-sdk'; -import {GoFeatureFlagProvider} from './go-feature-flag-provider'; - -jest.setTimeout(1000 * 60 * 60); -it('XXX', async () => { - const goff = new GoFeatureFlagProvider({ - endpoint: 'http://localhost:1031', - flagCacheTTL: 3000, - flagCacheSize: 1, - dataFlushInterval: 1000000 - }) - OpenFeature.setProvider(goff) - const cli = OpenFeature.getClient() - console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) - console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) - await new Promise((r) => setTimeout(r, 1000)); - console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) - console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) - console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) - console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) - console.log(await cli.getBooleanDetails('bool_targeting_match', false, {targetingKey: 'my-key'})) - await OpenFeature.close() - - // await new Promise((r) => setTimeout(r, 1000 * 60)); -}) diff --git a/package-lock.json b/package-lock.json index 869815dda..2edfd38b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "lodash.isequal": "^4.5.0", "lru-cache": "^9.0.0", "object-hash": "^3.0.0", - "receptacle": "^1.3.2", "tslib": "2.5.0" }, "devDependencies": { @@ -9242,7 +9241,8 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true }, "node_modules/nanoid": { "version": "3.3.6", @@ -10785,14 +10785,6 @@ "node": ">=8.10.0" } }, - "node_modules/receptacle": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/receptacle/-/receptacle-1.3.2.tgz", - "integrity": "sha512-HrsFvqZZheusncQRiEE7GatOAETrARKV/lnfYicIm8lbvp/JQOdADOfhjBd2DajvoszEyxSM6RlAAIZgEoeu/A==", - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -19281,7 +19273,8 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true }, "nanoid": { "version": "3.3.6", @@ -20344,14 +20337,6 @@ "picomatch": "^2.2.1" } }, - "receptacle": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/receptacle/-/receptacle-1.3.2.tgz", - "integrity": "sha512-HrsFvqZZheusncQRiEE7GatOAETrARKV/lnfYicIm8lbvp/JQOdADOfhjBd2DajvoszEyxSM6RlAAIZgEoeu/A==", - "requires": { - "ms": "^2.1.1" - } - }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", diff --git a/package.json b/package.json index d2d3edcbc..1ebcea1b3 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "lodash.isequal": "^4.5.0", "lru-cache": "^9.0.0", "object-hash": "^3.0.0", - "receptacle": "^1.3.2", "tslib": "2.5.0" }, "devDependencies": { From d1aa010710b04bb186e3bbc1f695eaca27360be2 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 15 Jun 2023 16:54:25 +0200 Subject: [PATCH 07/10] Add test for data collector Signed-off-by: Thomas Poignant --- .../src/lib/go-feature-flag-provider.spec.ts | 149 +++++++++++++++--- 1 file changed, 131 insertions(+), 18 deletions(-) diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts index bfdef6314..f83e7b2f3 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts @@ -18,17 +18,33 @@ import {GoFeatureFlagProxyResponse} from './model'; describe('GoFeatureFlagProvider', () => { const endpoint = 'http://go-feature-flag-relay-proxy.local:1031/'; + const dataCollectorEndpoint = `${endpoint}v1/data/collector`; const axiosMock = new MockAdapter(axios); + const validBoolResponse: GoFeatureFlagProxyResponse = { + value: true, + variationType: 'trueVariation', + reason: StandardResolutionReasons.TARGETING_MATCH, + failed: false, + trackEvents: true, + version: '1.0.0', + metadata: { + description: 'a description of the flag', + issue_number: 1, + }, + cacheable: true, + }; let goff: GoFeatureFlagProvider; afterEach(async () => { - axiosMock.reset(); - axiosMock.resetHistory(); - axiosMock.resetHandlers() await OpenFeature.close(); + await axiosMock.reset(); + await axiosMock.resetHistory(); }); - beforeEach(() => { + beforeEach(async () => { + await OpenFeature.close(); + await axiosMock.reset(); + await axiosMock.resetHistory(); goff = new GoFeatureFlagProvider({endpoint}); }); @@ -663,20 +679,6 @@ describe('GoFeatureFlagProvider', () => { }); }); describe('cache testing', () => { - const validBoolResponse: GoFeatureFlagProxyResponse = { - value: true, - variationType: 'trueVariation', - reason: StandardResolutionReasons.TARGETING_MATCH, - failed: false, - trackEvents: true, - version: '1.0.0', - metadata: { - description: 'a description of the flag', - issue_number: 1, - }, - cacheable: true, - }; - it('should use the cache if we evaluate 2 times the same flag', async () => { const flagName = 'random-flag'; const targetingKey = 'user-key'; @@ -793,4 +795,115 @@ describe('GoFeatureFlagProvider', () => { expect(axiosMock.history['post'].length).toBe(2); }); }); + describe('data collector testing', () => { + it('should call the data collector when closing Open Feature', async () => { + const flagName = 'random-flag'; + const targetingKey = 'user-key'; + const dns = `${endpoint}v1/feature/${flagName}/eval`; + + axiosMock.onPost(dns).reply(200, validBoolResponse); + const goff = new GoFeatureFlagProvider({ + endpoint, + flagCacheTTL: 3000, + flagCacheSize: 100, + dataFlushInterval: 1000, // in milliseconds + }) + const providerName = expect.getState().currentTestName || 'test'; + OpenFeature.setProvider(providerName, goff); + const cli = OpenFeature.getClient(providerName); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await OpenFeature.close() + const collectorCalls = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint); + expect(collectorCalls.length).toBe(1); + const got = JSON.parse(collectorCalls[0].data); + expect(isNaN(got.events[0].creationDate)).toBe(false); + const want = { + events: [{ + contextKind: 'user', + kind: 'feature', + creationDate: got.events[0].creationDate, + default: false, + key: 'random-flag', + value: true, + variation: 'trueVariation', + userKey: 'user-key' + }], meta: {provider: 'open-feature-js-sdk'} + }; + expect(want).toEqual(got); + }); + + it('should call the data collector when waiting more than the dataFlushInterval', async () => { + const flagName = 'random-flag'; + const targetingKey = 'user-key'; + const dns = `${endpoint}v1/feature/${flagName}/eval`; + + axiosMock.onPost(dns).reply(200, validBoolResponse); + const goff = new GoFeatureFlagProvider({ + endpoint, + flagCacheTTL: 3000, + flagCacheSize: 100, + dataFlushInterval: 100, // in milliseconds + }) + const providerName = expect.getState().currentTestName || 'test'; + OpenFeature.setProvider(providerName, goff); + const cli = OpenFeature.getClient(providerName); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await new Promise((r) => setTimeout(r, 130)); + const collectorCalls = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint); + expect(collectorCalls.length).toBe(1); + }); + + it('should call the data collector multiple time while waiting dataFlushInterval time', async () => { + const flagName = 'random-flag'; + const targetingKey = 'user-key'; + const dns = `${endpoint}v1/feature/${flagName}/eval`; + + axiosMock.onPost(dns).reply(200, validBoolResponse); + const goff = new GoFeatureFlagProvider({ + endpoint, + flagCacheTTL: 3000, + flagCacheSize: 100, + dataFlushInterval: 100, // in milliseconds + }) + const providerName = expect.getState().currentTestName || 'test'; + OpenFeature.setProvider(providerName, goff); + const cli = OpenFeature.getClient(providerName); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await new Promise((r) => setTimeout(r, 130)); + const collectorCalls = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint); + expect(collectorCalls.length).toBe(1); + axiosMock.resetHistory(); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await new Promise((r) => setTimeout(r, 130)); + const collectorCalls2 = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint); + expect(collectorCalls2.length).toBe(1); + }); + + it('should not call the data collector before the dataFlushInterval', async () => { + const flagName = 'random-flag'; + const targetingKey = 'user-key'; + const dns = `${endpoint}v1/feature/${flagName}/eval`; + + axiosMock.onPost(dns).reply(200, validBoolResponse); + const goff = new GoFeatureFlagProvider({ + endpoint, + flagCacheTTL: 3000, + flagCacheSize: 100, + dataFlushInterval: 200, // in milliseconds + }) + const providerName = expect.getState().currentTestName || 'test'; + OpenFeature.setProvider(providerName, goff); + const cli = OpenFeature.getClient(providerName); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await new Promise((r) => setTimeout(r, 130)); + const collectorCalls = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint); + + expect(collectorCalls.length).toBe(0); + }); + }); }); From f72a0e646ab3d5b4173810b04bafb8c4a84c29b0 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 15 Jun 2023 17:03:40 +0200 Subject: [PATCH 08/10] code style Signed-off-by: Thomas Poignant --- .../go-feature-flag/src/lib/go-feature-flag-provider.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts index 4e055c53b..8e43ad13c 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts @@ -33,14 +33,13 @@ export class GoFeatureFlagProvider implements Provider { // endpoint of your go-feature-flag relay proxy instance private readonly endpoint: string; + // timeout in millisecond before we consider the request as a failure private readonly timeout: number; - // cache contains the local cache used in the provider to avoid calling the relay-proxy for every evaluation private readonly cache?: LRUCache>; - // bgSchedulerId contains the id of the setInterval that is running. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -48,6 +47,7 @@ export class GoFeatureFlagProvider implements Provider { // dataCollectorBuffer contains all the FeatureEvents that we need to send to the relay-proxy for data collection. private dataCollectorBuffer?: FeatureEvent[]; + // dataCollectorMetadata are the metadata used when calling the data collector endpoint private readonly dataCollectorMetadata: Record = { provider: 'open-feature-js-sdk', @@ -59,6 +59,7 @@ export class GoFeatureFlagProvider implements Provider { // dataFlushInterval interval time (in millisecond) we use to call the relay proxy to collect data. private readonly dataFlushInterval: number; + // disableDataCollection set to true if you don't want to collect the usage of flags retrieved in the cache. private readonly disableDataCollection: boolean; From 1b323cbc99c7c51e8e75f6915d0060d6fd8db0d7 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 16 Jun 2023 09:45:40 +0200 Subject: [PATCH 09/10] import style Signed-off-by: Thomas Poignant --- .../go-feature-flag/src/lib/go-feature-flag-provider.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts index f83e7b2f3..caaa59a92 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts @@ -3,7 +3,9 @@ */ import { ErrorCode, - FlagNotFoundError, OpenFeature, ResolutionDetails, + FlagNotFoundError, + OpenFeature, + ResolutionDetails, StandardResolutionReasons, TypeMismatchError } from '@openfeature/js-sdk'; From 01c6fc812e9b8aae7e04587ddeb85cee47c634b7 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 16 Jun 2023 11:40:36 +0200 Subject: [PATCH 10/10] Adding a log if we are not able to send the data to the collector Signed-off-by: Thomas Poignant --- .../src/lib/go-feature-flag-provider.spec.ts | 29 ++++++++++++++++ .../src/lib/go-feature-flag-provider.ts | 11 +++++-- .../go-feature-flag/src/lib/test-logger.ts | 33 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 libs/providers/go-feature-flag/src/lib/test-logger.ts diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts index caaa59a92..86ad4aece 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts @@ -17,6 +17,7 @@ import {UnknownError} from './errors/unknownError'; import {Unauthorized} from './errors/unauthorized'; import {GoFeatureFlagProvider} from './go-feature-flag-provider'; import {GoFeatureFlagProxyResponse} from './model'; +import TestLogger from './test-logger'; describe('GoFeatureFlagProvider', () => { const endpoint = 'http://go-feature-flag-relay-proxy.local:1031/'; @@ -35,12 +36,15 @@ describe('GoFeatureFlagProvider', () => { }, cacheable: true, }; + let goff: GoFeatureFlagProvider; + const testLogger = new TestLogger(); afterEach(async () => { await OpenFeature.close(); await axiosMock.reset(); await axiosMock.resetHistory(); + testLogger.reset(); }); beforeEach(async () => { @@ -907,5 +911,30 @@ describe('GoFeatureFlagProvider', () => { expect(collectorCalls.length).toBe(0); }); + + it('should have a log when data collector is not available', async () => { + const flagName = 'random-flag'; + const targetingKey = 'user-key'; + const dns = `${endpoint}v1/feature/${flagName}/eval`; + + axiosMock.onPost(dns).reply(200, validBoolResponse); + axiosMock.onPost(dataCollectorEndpoint).reply(500, {}); + + const goff = new GoFeatureFlagProvider({ + endpoint, + flagCacheTTL: 3000, + flagCacheSize: 100, + dataFlushInterval: 2000, // in milliseconds + }, testLogger) + const providerName = expect.getState().currentTestName || 'test'; + OpenFeature.setProvider(providerName, goff); + const cli = OpenFeature.getClient(providerName); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await cli.getBooleanDetails(flagName, false, {targetingKey}); + await OpenFeature.close(); + + expect(testLogger.inMemoryLogger['error'].length).toBe(1); + expect(testLogger.inMemoryLogger['error']).toContain('impossible to send the data to the collector: Error: Request failed with status code 500') + }); }); }); diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts index 8e43ad13c..352ceaf96 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts @@ -3,6 +3,7 @@ import { EvaluationContext, FlagNotFoundError, JsonValue, + Logger, Provider, ResolutionDetails, StandardResolutionReasons, @@ -59,16 +60,20 @@ export class GoFeatureFlagProvider implements Provider { // dataFlushInterval interval time (in millisecond) we use to call the relay proxy to collect data. private readonly dataFlushInterval: number; - + // disableDataCollection set to true if you don't want to collect the usage of flags retrieved in the cache. private readonly disableDataCollection: boolean; - constructor(options: GoFeatureFlagProviderOptions) { + // logger is the Open Feature logger to use + private logger?: Logger; + + constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) { this.timeout = options.timeout || 0; // default is 0 = no timeout this.endpoint = options.endpoint; this.cacheTTL = options.flagCacheTTL !== undefined && options.flagCacheTTL !== 0 ? options.flagCacheTTL : 1000 * 60; this.dataFlushInterval = options.dataFlushInterval || 1000 * 60; this.disableDataCollection = options.disableDataCollection || false; + this.logger = logger; // Add API key to the headers if (options.apiKey) { @@ -131,7 +136,7 @@ export class GoFeatureFlagProvider implements Provider { timeout: this.timeout, }); } catch (e) { - // TODO : add a log here + this.logger?.error(`impossible to send the data to the collector: ${e}`) // if we have an issue calling the collector we put the data back in the buffer this.dataCollectorBuffer = [...this.dataCollectorBuffer, ...dataToSend] } diff --git a/libs/providers/go-feature-flag/src/lib/test-logger.ts b/libs/providers/go-feature-flag/src/lib/test-logger.ts new file mode 100644 index 000000000..4d578da76 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/test-logger.ts @@ -0,0 +1,33 @@ +export default class TestLogger { + public inMemoryLogger: Record = { + error: [], + warn: [], + info: [], + debug: [], + }; + + error(...args: unknown[]): void { + this.inMemoryLogger['error'].push(args.join(' ')); + } + + warn(...args: unknown[]): void { + this.inMemoryLogger['warn'].push(args.join(' ')); + } + + info(...args: unknown[]): void { + this.inMemoryLogger['info'].push(args.join(' ')); + } + + debug(...args: unknown[]): void { + this.inMemoryLogger['debug'].push(args.join(' ')); + } + + reset() { + this.inMemoryLogger = { + error: [], + warn: [], + info: [], + debug: [], + }; + } +}