Skip to content

Commit 3041da7

Browse files
authored
feat(flags/v8): add Statsig browser integration (#15347)
1 parent bffbe8b commit 3041da7

File tree

15 files changed

+236
-5
lines changed

15 files changed

+236
-5
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.

dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/basic/test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { sentryTest } from '../../../../../utils/fixtures';
44

55
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
66

7-
const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.
7+
import { FLAG_BUFFER_SIZE } from '../../constants';
88

99
sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
1010
if (shouldSkipFeatureFlagsTest()) {

dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/basic/test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { sentryTest } from '../../../../../utils/fixtures';
44

55
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
66

7-
const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.
7+
import { FLAG_BUFFER_SIZE } from '../../constants';
88

99
sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
1010
if (shouldSkipFeatureFlagsTest()) {

dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { sentryTest } from '../../../../../utils/fixtures';
44

55
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
66

7-
const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.
7+
import { FLAG_BUFFER_SIZE } from '../../constants';
88

99
sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
1010
if (shouldSkipFeatureFlagsTest()) {

dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { sentryTest } from '../../../../../utils/fixtures';
44

55
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
66

7-
const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.
7+
import { FLAG_BUFFER_SIZE } from '../../constants';
88

99
sentryTest('Flag evaluation error hook', async ({ getLocalTestUrl, page }) => {
1010
if (shouldSkipFeatureFlagsTest()) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
5+
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
6+
7+
import { FLAG_BUFFER_SIZE } from '../../constants';
8+
9+
sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
10+
if (shouldSkipFeatureFlagsTest()) {
11+
sentryTest.skip();
12+
}
13+
14+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
15+
return route.fulfill({
16+
status: 200,
17+
contentType: 'application/json',
18+
body: JSON.stringify({ id: 'test-id' }),
19+
});
20+
});
21+
22+
const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
23+
await page.goto(url);
24+
25+
await page.evaluate(bufferSize => {
26+
const client = (window as any).statsigClient;
27+
for (let i = 1; i <= bufferSize; i++) {
28+
client.checkGate(`feat${i}`); // values default to false
29+
}
30+
31+
client.setMockGateValue(`feat${bufferSize + 1}`, true);
32+
client.checkGate(`feat${bufferSize + 1}`); // eviction
33+
34+
client.setMockGateValue('feat3', true);
35+
client.checkGate('feat3'); // update
36+
}, FLAG_BUFFER_SIZE);
37+
38+
const reqPromise = waitForErrorRequest(page);
39+
await page.locator('#error').click();
40+
const req = await reqPromise;
41+
const event = envelopeRequestParser(req);
42+
43+
const expectedFlags = [{ flag: 'feat2', result: false }];
44+
for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) {
45+
expectedFlags.push({ flag: `feat${i}`, result: false });
46+
}
47+
expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true });
48+
expectedFlags.push({ flag: 'feat3', result: true });
49+
50+
expect(event.contexts?.flags?.values).toEqual(expectedFlags);
51+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
class MockStatsigClient {
4+
constructor() {
5+
this._gateEvaluationListeners = [];
6+
this._mockGateValues = {};
7+
}
8+
9+
on(event, listener) {
10+
this._gateEvaluationListeners.push(listener);
11+
}
12+
13+
checkGate(name) {
14+
const value = this._mockGateValues[name] || false; // unknown features default to false.
15+
this._gateEvaluationListeners.forEach(listener => {
16+
listener({ gate: { name, value } });
17+
});
18+
return value;
19+
}
20+
21+
setMockGateValue(name, value) {
22+
this._mockGateValues[name] = value;
23+
}
24+
}
25+
26+
window.statsigClient = new MockStatsigClient();
27+
28+
window.Sentry = Sentry;
29+
window.sentryStatsigIntegration = Sentry.statsigIntegration({ featureFlagClient: window.statsigClient });
30+
31+
Sentry.init({
32+
dsn: 'https://[email protected]/1337',
33+
sampleRate: 1.0,
34+
integrations: [window.sentryStatsigIntegration],
35+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
document.getElementById('error').addEventListener('click', () => {
2+
throw new Error('Button triggered error');
3+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="error">Throw Error</button>
8+
</body>
9+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
5+
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
6+
7+
import type { Scope } from '@sentry/browser';
8+
9+
sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => {
10+
if (shouldSkipFeatureFlagsTest()) {
11+
sentryTest.skip();
12+
}
13+
14+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
15+
return route.fulfill({
16+
status: 200,
17+
contentType: 'application/json',
18+
body: JSON.stringify({ id: 'test-id' }),
19+
});
20+
});
21+
22+
const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
23+
await page.goto(url);
24+
25+
const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === true);
26+
const mainReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === false);
27+
28+
await page.evaluate(() => {
29+
const Sentry = (window as any).Sentry;
30+
const errorButton = document.querySelector('#error') as HTMLButtonElement;
31+
const client = (window as any).statsigClient;
32+
33+
client.setMockGateValue('shared', true);
34+
client.setMockGateValue('main', true);
35+
36+
client.checkGate('shared');
37+
38+
Sentry.withScope((scope: Scope) => {
39+
client.setMockGateValue('forked', true);
40+
client.setMockGateValue('shared', false); // override the value in the parent scope.
41+
42+
client.checkGate('forked');
43+
client.checkGate('shared');
44+
scope.setTag('isForked', true);
45+
errorButton.click();
46+
});
47+
48+
client.checkGate('main');
49+
Sentry.getCurrentScope().setTag('isForked', false);
50+
errorButton.click();
51+
return true;
52+
});
53+
54+
const forkedReq = await forkedReqPromise;
55+
const forkedEvent = envelopeRequestParser(forkedReq);
56+
57+
const mainReq = await mainReqPromise;
58+
const mainEvent = envelopeRequestParser(mainReq);
59+
60+
expect(forkedEvent.contexts?.flags?.values).toEqual([
61+
{ flag: 'forked', result: true },
62+
{ flag: 'shared', result: false },
63+
]);
64+
65+
expect(mainEvent.contexts?.flags?.values).toEqual([
66+
{ flag: 'shared', result: true },
67+
{ flag: 'main', result: true },
68+
]);
69+
});

dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/basic/test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { sentryTest } from '../../../../../utils/fixtures';
44

55
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
66

7-
const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.
7+
import { FLAG_BUFFER_SIZE } from '../../constants';
88

99
sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
1010
if (shouldSkipFeatureFlagsTest()) {

packages/browser/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,4 @@ export {
7676
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
7777
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature';
7878
export { unleashIntegration } from './integrations/featureFlags/unleash';
79+
export { statsigIntegration } from './integrations/featureFlags/statsig';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { statsigIntegration } from './integration';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core';
2+
3+
import { defineIntegration } from '@sentry/core';
4+
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
5+
import type { FeatureGate, StatsigClient } from './types';
6+
7+
/**
8+
* Sentry integration for capturing feature flag evaluations from the Statsig js-client SDK.
9+
*
10+
* See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information.
11+
*
12+
* @example
13+
* ```
14+
* import { StatsigClient } from '@statsig/js-client';
15+
* import * as Sentry from '@sentry/browser';
16+
*
17+
* const statsigClient = new StatsigClient();
18+
*
19+
* Sentry.init({
20+
* dsn: '___PUBLIC_DSN___',
21+
* integrations: [Sentry.statsigIntegration({featureFlagClient: statsigClient})],
22+
* });
23+
*
24+
* await statsigClient.initializeAsync(); // or statsigClient.initializeSync();
25+
*
26+
* const result = statsigClient.checkGate('my-feature-gate');
27+
* Sentry.captureException(new Error('something went wrong'));
28+
* ```
29+
*/
30+
export const statsigIntegration = defineIntegration(
31+
({ featureFlagClient: statsigClient }: { featureFlagClient: StatsigClient }) => {
32+
return {
33+
name: 'Statsig',
34+
35+
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
36+
return copyFlagsFromScopeToEvent(event);
37+
},
38+
39+
setup() {
40+
statsigClient.on('gate_evaluation', (event: { gate: FeatureGate }) => {
41+
insertFlagToScope(event.gate.name, event.gate.value);
42+
});
43+
},
44+
};
45+
},
46+
) satisfies IntegrationFn;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export type FeatureGate = {
2+
readonly name: string;
3+
readonly value: boolean;
4+
};
5+
6+
type EventNameToEventDataMap = {
7+
gate_evaluation: { gate: FeatureGate };
8+
};
9+
10+
export interface StatsigClient {
11+
on(
12+
event: keyof EventNameToEventDataMap,
13+
callback: (data: EventNameToEventDataMap[keyof EventNameToEventDataMap]) => void,
14+
): void;
15+
}

0 commit comments

Comments
 (0)