Skip to content

Commit

Permalink
feat(go-feature-flag-web): Add support for data collection (open-feat…
Browse files Browse the repository at this point in the history
…ure#1101)

Signed-off-by: Thomas Poignant <[email protected]>
  • Loading branch information
thomaspoignant authored Dec 3, 2024
1 parent 775a7c8 commit 34fcecd
Show file tree
Hide file tree
Showing 9 changed files with 532 additions and 48 deletions.
143 changes: 143 additions & 0 deletions libs/providers/go-feature-flag-web/src/lib/controller/goff-api.spec.ts
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 libs/providers/go-feature-flag-web/src/lib/controller/goff-api.ts
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 libs/providers/go-feature-flag-web/src/lib/data-collector-hook.ts
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);
}
}
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}`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class GoFeatureFlagError extends Error {}
Loading

0 comments on commit 34fcecd

Please sign in to comment.