forked from open-feature/js-sdk-contrib
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(go-feature-flag-web): Add support for data collection (open-feat…
…ure#1101) Signed-off-by: Thomas Poignant <[email protected]>
- Loading branch information
1 parent
775a7c8
commit 34fcecd
Showing
9 changed files
with
532 additions
and
48 deletions.
There are no files selected for viewing
143 changes: 143 additions & 0 deletions
143
libs/providers/go-feature-flag-web/src/lib/controller/goff-api.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
import fetchMock from 'fetch-mock-jest'; | ||
import { GoffApiController } from './goff-api'; | ||
import { GoFeatureFlagWebProviderOptions } from '../model'; | ||
|
||
describe('Collect Data API', () => { | ||
beforeEach(() => { | ||
fetchMock.mockClear(); | ||
fetchMock.mockReset(); | ||
jest.resetAllMocks(); | ||
}); | ||
|
||
it('should call the API to collect data with apiKey', async () => { | ||
fetchMock.post('https://gofeatureflag.org/v1/data/collector', 200); | ||
const options: GoFeatureFlagWebProviderOptions = { | ||
endpoint: 'https://gofeatureflag.org', | ||
apiTimeout: 1000, | ||
apiKey: '123456', | ||
}; | ||
const goff = new GoffApiController(options); | ||
await goff.collectData( | ||
[ | ||
{ | ||
key: 'flagKey', | ||
contextKind: 'user', | ||
creationDate: 1733138237486, | ||
default: false, | ||
kind: 'feature', | ||
userKey: 'toto', | ||
value: true, | ||
variation: 'varA', | ||
}, | ||
], | ||
{ provider: 'open-feature-js-sdk' }, | ||
); | ||
expect(fetchMock.lastUrl()).toBe('https://gofeatureflag.org/v1/data/collector'); | ||
expect(fetchMock.lastOptions()?.headers).toEqual({ | ||
'Content-Type': 'application/json', | ||
Accept: 'application/json', | ||
Authorization: `Bearer ${options.apiKey}`, | ||
}); | ||
expect(fetchMock.lastOptions()?.body).toEqual( | ||
JSON.stringify({ | ||
events: [ | ||
{ | ||
key: 'flagKey', | ||
contextKind: 'user', | ||
creationDate: 1733138237486, | ||
default: false, | ||
kind: 'feature', | ||
userKey: 'toto', | ||
value: true, | ||
variation: 'varA', | ||
}, | ||
], | ||
meta: { provider: 'open-feature-js-sdk' }, | ||
}), | ||
); | ||
}); | ||
|
||
it('should call the API to collect data', async () => { | ||
fetchMock.post('https://gofeatureflag.org/v1/data/collector', 200); | ||
const options: GoFeatureFlagWebProviderOptions = { | ||
endpoint: 'https://gofeatureflag.org', | ||
apiTimeout: 1000, | ||
}; | ||
const goff = new GoffApiController(options); | ||
await goff.collectData( | ||
[ | ||
{ | ||
key: 'flagKey', | ||
contextKind: 'user', | ||
creationDate: 1733138237486, | ||
default: false, | ||
kind: 'feature', | ||
userKey: 'toto', | ||
value: true, | ||
variation: 'varA', | ||
}, | ||
], | ||
{ provider: 'open-feature-js-sdk' }, | ||
); | ||
expect(fetchMock.lastUrl()).toBe('https://gofeatureflag.org/v1/data/collector'); | ||
expect(fetchMock.lastOptions()?.headers).toEqual({ | ||
'Content-Type': 'application/json', | ||
Accept: 'application/json', | ||
}); | ||
expect(fetchMock.lastOptions()?.body).toEqual( | ||
JSON.stringify({ | ||
events: [ | ||
{ | ||
key: 'flagKey', | ||
contextKind: 'user', | ||
creationDate: 1733138237486, | ||
default: false, | ||
kind: 'feature', | ||
userKey: 'toto', | ||
value: true, | ||
variation: 'varA', | ||
}, | ||
], | ||
meta: { provider: 'open-feature-js-sdk' }, | ||
}), | ||
); | ||
}); | ||
|
||
it('should not call the API to collect data if no event provided', async () => { | ||
fetchMock.post('https://gofeatureflag.org/v1/data/collector', 200); | ||
const options: GoFeatureFlagWebProviderOptions = { | ||
endpoint: 'https://gofeatureflag.org', | ||
apiTimeout: 1000, | ||
apiKey: '123456', | ||
}; | ||
const goff = new GoffApiController(options); | ||
await goff.collectData([], { provider: 'open-feature-js-sdk' }); | ||
expect(fetchMock).toHaveBeenCalledTimes(0); | ||
}); | ||
|
||
it('should throw an error if API call fails', async () => { | ||
fetchMock.post('https://gofeatureflag.org/v1/data/collector', 500); | ||
const options: GoFeatureFlagWebProviderOptions = { | ||
endpoint: 'https://gofeatureflag.org', | ||
apiTimeout: 1000, | ||
}; | ||
const goff = new GoffApiController(options); | ||
await expect( | ||
goff.collectData( | ||
[ | ||
{ | ||
key: 'flagKey', | ||
contextKind: 'user', | ||
creationDate: 1733138237486, | ||
default: false, | ||
kind: 'feature', | ||
userKey: 'toto', | ||
value: true, | ||
variation: 'varA', | ||
}, | ||
], | ||
{ provider: 'open-feature-js-sdk' }, | ||
), | ||
).rejects.toThrow('impossible to send the data to the collector'); | ||
}); | ||
}); |
54 changes: 54 additions & 0 deletions
54
libs/providers/go-feature-flag-web/src/lib/controller/goff-api.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import { DataCollectorRequest, FeatureEvent, GoFeatureFlagWebProviderOptions } from '../model'; | ||
import { CollectorError } from '../errors/collector-error'; | ||
|
||
export class GoffApiController { | ||
// 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; | ||
private options: GoFeatureFlagWebProviderOptions; | ||
|
||
constructor(options: GoFeatureFlagWebProviderOptions) { | ||
this.endpoint = options.endpoint; | ||
this.timeout = options.apiTimeout ?? 0; | ||
this.options = options; | ||
} | ||
|
||
async collectData(events: FeatureEvent<any>[], dataCollectorMetadata: Record<string, string>) { | ||
if (events?.length === 0) { | ||
return; | ||
} | ||
|
||
const request: DataCollectorRequest<boolean> = { events: events, meta: dataCollectorMetadata }; | ||
const endpointURL = new URL(this.endpoint); | ||
endpointURL.pathname = 'v1/data/collector'; | ||
|
||
try { | ||
const headers: HeadersInit = { | ||
'Content-Type': 'application/json', | ||
Accept: 'application/json', | ||
}; | ||
|
||
if (this.options.apiKey) { | ||
headers['Authorization'] = `Bearer ${this.options.apiKey}`; | ||
} | ||
|
||
const controller = new AbortController(); | ||
const id = setTimeout(() => controller.abort(), this.timeout ?? 10000); | ||
const response = await fetch(endpointURL.toString(), { | ||
method: 'POST', | ||
headers: headers, | ||
body: JSON.stringify(request), | ||
signal: controller.signal, | ||
}); | ||
clearTimeout(id); | ||
|
||
if (!response.ok) { | ||
throw new Error(`HTTP error! status: ${response.status}`); | ||
} | ||
} catch (e) { | ||
throw new CollectorError(`impossible to send the data to the collector: ${e}`); | ||
} | ||
} | ||
} |
91 changes: 91 additions & 0 deletions
91
libs/providers/go-feature-flag-web/src/lib/data-collector-hook.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import { EvaluationDetails, FlagValue, Hook, HookContext, Logger } from '@openfeature/server-sdk'; | ||
import { FeatureEvent, GoFeatureFlagWebProviderOptions } from './model'; | ||
import { copy } from 'copy-anything'; | ||
import { CollectorError } from './errors/collector-error'; | ||
import { GoffApiController } from './controller/goff-api'; | ||
|
||
const defaultTargetingKey = 'undefined-targetingKey'; | ||
type Timer = ReturnType<typeof setInterval>; | ||
|
||
export class GoFeatureFlagDataCollectorHook implements Hook { | ||
// bgSchedulerId contains the id of the setInterval that is running. | ||
private bgScheduler?: Timer; | ||
// dataCollectorBuffer contains all the FeatureEvents that we need to send to the relay-proxy for data collection. | ||
private dataCollectorBuffer?: FeatureEvent<any>[]; | ||
// dataFlushInterval interval time (in millisecond) we use to call the relay proxy to collect data. | ||
private readonly dataFlushInterval: number; | ||
// dataCollectorMetadata are the metadata used when calling the data collector endpoint | ||
private readonly dataCollectorMetadata: Record<string, string> = { | ||
provider: 'open-feature-js-sdk', | ||
}; | ||
private readonly goffApiController: GoffApiController; | ||
// logger is the Open Feature logger to use | ||
private logger?: Logger; | ||
|
||
constructor(options: GoFeatureFlagWebProviderOptions, logger?: Logger) { | ||
this.dataFlushInterval = options.dataFlushInterval || 1000 * 60; | ||
this.logger = logger; | ||
this.goffApiController = new GoffApiController(options); | ||
} | ||
|
||
init() { | ||
this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval); | ||
this.dataCollectorBuffer = []; | ||
} | ||
|
||
async close() { | ||
clearInterval(this.bgScheduler); | ||
// We call the data collector with what is still in the buffer. | ||
await this.callGoffDataCollection(); | ||
} | ||
|
||
/** | ||
* 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() { | ||
const dataToSend = copy(this.dataCollectorBuffer) || []; | ||
this.dataCollectorBuffer = []; | ||
try { | ||
await this.goffApiController.collectData(dataToSend, this.dataCollectorMetadata); | ||
} catch (e) { | ||
if (!(e instanceof CollectorError)) { | ||
throw e; | ||
} | ||
this.logger?.error(e); | ||
// if we have an issue calling the collector, we put the data back in the buffer | ||
this.dataCollectorBuffer = [...this.dataCollectorBuffer, ...dataToSend]; | ||
return; | ||
} | ||
} | ||
|
||
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) { | ||
const event = { | ||
contextKind: hookContext.context['anonymous'] ? 'anonymousUser' : 'user', | ||
kind: 'feature', | ||
creationDate: Math.round(Date.now() / 1000), | ||
default: false, | ||
key: hookContext.flagKey, | ||
value: evaluationDetails.value, | ||
variation: evaluationDetails.variant || 'SdkDefault', | ||
userKey: hookContext.context.targetingKey || defaultTargetingKey, | ||
source: 'PROVIDER_CACHE', | ||
}; | ||
this.dataCollectorBuffer?.push(event); | ||
} | ||
|
||
error(hookContext: HookContext) { | ||
const event = { | ||
contextKind: hookContext.context['anonymous'] ? 'anonymousUser' : 'user', | ||
kind: 'feature', | ||
creationDate: Math.round(Date.now() / 1000), | ||
default: true, | ||
key: hookContext.flagKey, | ||
value: hookContext.defaultValue, | ||
variation: 'SdkDefault', | ||
userKey: hookContext.context.targetingKey || defaultTargetingKey, | ||
source: 'PROVIDER_CACHE', | ||
}; | ||
this.dataCollectorBuffer?.push(event); | ||
} | ||
} |
10 changes: 10 additions & 0 deletions
10
libs/providers/go-feature-flag-web/src/lib/errors/collector-error.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { GoFeatureFlagError } from './goff-error'; | ||
|
||
/** | ||
* An error occurred while calling the GOFF event collector. | ||
*/ | ||
export class CollectorError extends GoFeatureFlagError { | ||
constructor(message?: string, originalError?: Error) { | ||
super(`${message}: ${originalError}`); | ||
} | ||
} |
File renamed without changes.
1 change: 1 addition & 0 deletions
1
libs/providers/go-feature-flag-web/src/lib/errors/goff-error.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export class GoFeatureFlagError extends Error {} |
Oops, something went wrong.