Skip to content

Commit 34fcecd

Browse files
feat(go-feature-flag-web): Add support for data collection (#1101)
Signed-off-by: Thomas Poignant <[email protected]>
1 parent 775a7c8 commit 34fcecd

File tree

9 files changed

+532
-48
lines changed

9 files changed

+532
-48
lines changed
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import fetchMock from 'fetch-mock-jest';
2+
import { GoffApiController } from './goff-api';
3+
import { GoFeatureFlagWebProviderOptions } from '../model';
4+
5+
describe('Collect Data API', () => {
6+
beforeEach(() => {
7+
fetchMock.mockClear();
8+
fetchMock.mockReset();
9+
jest.resetAllMocks();
10+
});
11+
12+
it('should call the API to collect data with apiKey', async () => {
13+
fetchMock.post('https://gofeatureflag.org/v1/data/collector', 200);
14+
const options: GoFeatureFlagWebProviderOptions = {
15+
endpoint: 'https://gofeatureflag.org',
16+
apiTimeout: 1000,
17+
apiKey: '123456',
18+
};
19+
const goff = new GoffApiController(options);
20+
await goff.collectData(
21+
[
22+
{
23+
key: 'flagKey',
24+
contextKind: 'user',
25+
creationDate: 1733138237486,
26+
default: false,
27+
kind: 'feature',
28+
userKey: 'toto',
29+
value: true,
30+
variation: 'varA',
31+
},
32+
],
33+
{ provider: 'open-feature-js-sdk' },
34+
);
35+
expect(fetchMock.lastUrl()).toBe('https://gofeatureflag.org/v1/data/collector');
36+
expect(fetchMock.lastOptions()?.headers).toEqual({
37+
'Content-Type': 'application/json',
38+
Accept: 'application/json',
39+
Authorization: `Bearer ${options.apiKey}`,
40+
});
41+
expect(fetchMock.lastOptions()?.body).toEqual(
42+
JSON.stringify({
43+
events: [
44+
{
45+
key: 'flagKey',
46+
contextKind: 'user',
47+
creationDate: 1733138237486,
48+
default: false,
49+
kind: 'feature',
50+
userKey: 'toto',
51+
value: true,
52+
variation: 'varA',
53+
},
54+
],
55+
meta: { provider: 'open-feature-js-sdk' },
56+
}),
57+
);
58+
});
59+
60+
it('should call the API to collect data', async () => {
61+
fetchMock.post('https://gofeatureflag.org/v1/data/collector', 200);
62+
const options: GoFeatureFlagWebProviderOptions = {
63+
endpoint: 'https://gofeatureflag.org',
64+
apiTimeout: 1000,
65+
};
66+
const goff = new GoffApiController(options);
67+
await goff.collectData(
68+
[
69+
{
70+
key: 'flagKey',
71+
contextKind: 'user',
72+
creationDate: 1733138237486,
73+
default: false,
74+
kind: 'feature',
75+
userKey: 'toto',
76+
value: true,
77+
variation: 'varA',
78+
},
79+
],
80+
{ provider: 'open-feature-js-sdk' },
81+
);
82+
expect(fetchMock.lastUrl()).toBe('https://gofeatureflag.org/v1/data/collector');
83+
expect(fetchMock.lastOptions()?.headers).toEqual({
84+
'Content-Type': 'application/json',
85+
Accept: 'application/json',
86+
});
87+
expect(fetchMock.lastOptions()?.body).toEqual(
88+
JSON.stringify({
89+
events: [
90+
{
91+
key: 'flagKey',
92+
contextKind: 'user',
93+
creationDate: 1733138237486,
94+
default: false,
95+
kind: 'feature',
96+
userKey: 'toto',
97+
value: true,
98+
variation: 'varA',
99+
},
100+
],
101+
meta: { provider: 'open-feature-js-sdk' },
102+
}),
103+
);
104+
});
105+
106+
it('should not call the API to collect data if no event provided', async () => {
107+
fetchMock.post('https://gofeatureflag.org/v1/data/collector', 200);
108+
const options: GoFeatureFlagWebProviderOptions = {
109+
endpoint: 'https://gofeatureflag.org',
110+
apiTimeout: 1000,
111+
apiKey: '123456',
112+
};
113+
const goff = new GoffApiController(options);
114+
await goff.collectData([], { provider: 'open-feature-js-sdk' });
115+
expect(fetchMock).toHaveBeenCalledTimes(0);
116+
});
117+
118+
it('should throw an error if API call fails', async () => {
119+
fetchMock.post('https://gofeatureflag.org/v1/data/collector', 500);
120+
const options: GoFeatureFlagWebProviderOptions = {
121+
endpoint: 'https://gofeatureflag.org',
122+
apiTimeout: 1000,
123+
};
124+
const goff = new GoffApiController(options);
125+
await expect(
126+
goff.collectData(
127+
[
128+
{
129+
key: 'flagKey',
130+
contextKind: 'user',
131+
creationDate: 1733138237486,
132+
default: false,
133+
kind: 'feature',
134+
userKey: 'toto',
135+
value: true,
136+
variation: 'varA',
137+
},
138+
],
139+
{ provider: 'open-feature-js-sdk' },
140+
),
141+
).rejects.toThrow('impossible to send the data to the collector');
142+
});
143+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { DataCollectorRequest, FeatureEvent, GoFeatureFlagWebProviderOptions } from '../model';
2+
import { CollectorError } from '../errors/collector-error';
3+
4+
export class GoffApiController {
5+
// endpoint of your go-feature-flag relay proxy instance
6+
private readonly endpoint: string;
7+
8+
// timeout in millisecond before we consider the request as a failure
9+
private readonly timeout: number;
10+
private options: GoFeatureFlagWebProviderOptions;
11+
12+
constructor(options: GoFeatureFlagWebProviderOptions) {
13+
this.endpoint = options.endpoint;
14+
this.timeout = options.apiTimeout ?? 0;
15+
this.options = options;
16+
}
17+
18+
async collectData(events: FeatureEvent<any>[], dataCollectorMetadata: Record<string, string>) {
19+
if (events?.length === 0) {
20+
return;
21+
}
22+
23+
const request: DataCollectorRequest<boolean> = { events: events, meta: dataCollectorMetadata };
24+
const endpointURL = new URL(this.endpoint);
25+
endpointURL.pathname = 'v1/data/collector';
26+
27+
try {
28+
const headers: HeadersInit = {
29+
'Content-Type': 'application/json',
30+
Accept: 'application/json',
31+
};
32+
33+
if (this.options.apiKey) {
34+
headers['Authorization'] = `Bearer ${this.options.apiKey}`;
35+
}
36+
37+
const controller = new AbortController();
38+
const id = setTimeout(() => controller.abort(), this.timeout ?? 10000);
39+
const response = await fetch(endpointURL.toString(), {
40+
method: 'POST',
41+
headers: headers,
42+
body: JSON.stringify(request),
43+
signal: controller.signal,
44+
});
45+
clearTimeout(id);
46+
47+
if (!response.ok) {
48+
throw new Error(`HTTP error! status: ${response.status}`);
49+
}
50+
} catch (e) {
51+
throw new CollectorError(`impossible to send the data to the collector: ${e}`);
52+
}
53+
}
54+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { EvaluationDetails, FlagValue, Hook, HookContext, Logger } from '@openfeature/server-sdk';
2+
import { FeatureEvent, GoFeatureFlagWebProviderOptions } from './model';
3+
import { copy } from 'copy-anything';
4+
import { CollectorError } from './errors/collector-error';
5+
import { GoffApiController } from './controller/goff-api';
6+
7+
const defaultTargetingKey = 'undefined-targetingKey';
8+
type Timer = ReturnType<typeof setInterval>;
9+
10+
export class GoFeatureFlagDataCollectorHook implements Hook {
11+
// bgSchedulerId contains the id of the setInterval that is running.
12+
private bgScheduler?: Timer;
13+
// dataCollectorBuffer contains all the FeatureEvents that we need to send to the relay-proxy for data collection.
14+
private dataCollectorBuffer?: FeatureEvent<any>[];
15+
// dataFlushInterval interval time (in millisecond) we use to call the relay proxy to collect data.
16+
private readonly dataFlushInterval: number;
17+
// dataCollectorMetadata are the metadata used when calling the data collector endpoint
18+
private readonly dataCollectorMetadata: Record<string, string> = {
19+
provider: 'open-feature-js-sdk',
20+
};
21+
private readonly goffApiController: GoffApiController;
22+
// logger is the Open Feature logger to use
23+
private logger?: Logger;
24+
25+
constructor(options: GoFeatureFlagWebProviderOptions, logger?: Logger) {
26+
this.dataFlushInterval = options.dataFlushInterval || 1000 * 60;
27+
this.logger = logger;
28+
this.goffApiController = new GoffApiController(options);
29+
}
30+
31+
init() {
32+
this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval);
33+
this.dataCollectorBuffer = [];
34+
}
35+
36+
async close() {
37+
clearInterval(this.bgScheduler);
38+
// We call the data collector with what is still in the buffer.
39+
await this.callGoffDataCollection();
40+
}
41+
42+
/**
43+
* callGoffDataCollection is a function called periodically to send the usage of the flag to the
44+
* central service in charge of collecting the data.
45+
*/
46+
async callGoffDataCollection() {
47+
const dataToSend = copy(this.dataCollectorBuffer) || [];
48+
this.dataCollectorBuffer = [];
49+
try {
50+
await this.goffApiController.collectData(dataToSend, this.dataCollectorMetadata);
51+
} catch (e) {
52+
if (!(e instanceof CollectorError)) {
53+
throw e;
54+
}
55+
this.logger?.error(e);
56+
// if we have an issue calling the collector, we put the data back in the buffer
57+
this.dataCollectorBuffer = [...this.dataCollectorBuffer, ...dataToSend];
58+
return;
59+
}
60+
}
61+
62+
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) {
63+
const event = {
64+
contextKind: hookContext.context['anonymous'] ? 'anonymousUser' : 'user',
65+
kind: 'feature',
66+
creationDate: Math.round(Date.now() / 1000),
67+
default: false,
68+
key: hookContext.flagKey,
69+
value: evaluationDetails.value,
70+
variation: evaluationDetails.variant || 'SdkDefault',
71+
userKey: hookContext.context.targetingKey || defaultTargetingKey,
72+
source: 'PROVIDER_CACHE',
73+
};
74+
this.dataCollectorBuffer?.push(event);
75+
}
76+
77+
error(hookContext: HookContext) {
78+
const event = {
79+
contextKind: hookContext.context['anonymous'] ? 'anonymousUser' : 'user',
80+
kind: 'feature',
81+
creationDate: Math.round(Date.now() / 1000),
82+
default: true,
83+
key: hookContext.flagKey,
84+
value: hookContext.defaultValue,
85+
variation: 'SdkDefault',
86+
userKey: hookContext.context.targetingKey || defaultTargetingKey,
87+
source: 'PROVIDER_CACHE',
88+
};
89+
this.dataCollectorBuffer?.push(event);
90+
}
91+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { GoFeatureFlagError } from './goff-error';
2+
3+
/**
4+
* An error occurred while calling the GOFF event collector.
5+
*/
6+
export class CollectorError extends GoFeatureFlagError {
7+
constructor(message?: string, originalError?: Error) {
8+
super(`${message}: ${originalError}`);
9+
}
10+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class GoFeatureFlagError extends Error {}

0 commit comments

Comments
 (0)