Skip to content

Commit

Permalink
feat(flags): add Statsig browser integration (#15319)
Browse files Browse the repository at this point in the history
Ref https://develop.sentry.dev/sdk/expected-features/#feature-flags.
Adds an integration for tracking Statsig js-client flag evaluations,
specifically the `checkGate` method which is used for boolean release
flags.

Statsig references:
- https://docs.statsig.com/client/javascript-sdk
- https://docs.statsig.com/client/javascript-sdk#client-event-emitter
- [Event emitter types
definitions](https://github.com/statsig-io/js-client-monorepo/blob/main/packages/client-core/src/StatsigClientEventEmitter.ts)

Our current FF integrations only support boolean flag values so "dynamic
config", "experiments", and "layers" will not be tracked for now.

Closes getsentry/team-replay#539
  • Loading branch information
aliu39 authored Feb 8, 2025
1 parent 725a548 commit 22f841e
Show file tree
Hide file tree
Showing 15 changed files with 236 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sentryTest } from '../../../../../utils/fixtures';

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

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

sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sentryTest } from '../../../../../utils/fixtures';

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

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

sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sentryTest } from '../../../../../utils/fixtures';

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

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

sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sentryTest } from '../../../../../utils/fixtures';

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

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

sentryTest('Flag evaluation error hook', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../../utils/fixtures';

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

import { FLAG_BUFFER_SIZE } from '../../constants';

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

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
await page.goto(url);

await page.evaluate(bufferSize => {
const client = (window as any).statsigClient;
for (let i = 1; i <= bufferSize; i++) {
client.checkGate(`feat${i}`); // values default to false
}

client.setMockGateValue(`feat${bufferSize + 1}`, true);
client.checkGate(`feat${bufferSize + 1}`); // eviction

client.setMockGateValue('feat3', true);
client.checkGate('feat3'); // update
}, FLAG_BUFFER_SIZE);

const reqPromise = waitForErrorRequest(page);
await page.locator('#error').click();
const req = await reqPromise;
const event = envelopeRequestParser(req);

const expectedFlags = [{ flag: 'feat2', result: false }];
for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) {
expectedFlags.push({ flag: `feat${i}`, result: false });
}
expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true });
expectedFlags.push({ flag: 'feat3', result: true });

expect(event.contexts?.flags?.values).toEqual(expectedFlags);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as Sentry from '@sentry/browser';

class MockStatsigClient {
constructor() {
this._gateEvaluationListeners = [];
this._mockGateValues = {};
}

on(event, listener) {
this._gateEvaluationListeners.push(listener);
}

checkGate(name) {
const value = this._mockGateValues[name] || false; // unknown features default to false.
this._gateEvaluationListeners.forEach(listener => {
listener({ gate: { name, value } });
});
return value;
}

setMockGateValue(name, value) {
this._mockGateValues[name] = value;
}
}

window.statsigClient = new MockStatsigClient();

window.Sentry = Sentry;
window.sentryStatsigIntegration = Sentry.statsigIntegration({ featureFlagClient: window.statsigClient });

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 1.0,
integrations: [window.sentryStatsigIntegration],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
document.getElementById('error').addEventListener('click', () => {
throw new Error('Button triggered error');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="error">Throw Error</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../../utils/fixtures';

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

import type { Scope } from '@sentry/browser';

sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
sentryTest.skip();
}

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
await page.goto(url);

const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === true);
const mainReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === false);

await page.evaluate(() => {
const Sentry = (window as any).Sentry;
const errorButton = document.querySelector('#error') as HTMLButtonElement;
const client = (window as any).statsigClient;

client.setMockGateValue('shared', true);
client.setMockGateValue('main', true);

client.checkGate('shared');

Sentry.withScope((scope: Scope) => {
client.setMockGateValue('forked', true);
client.setMockGateValue('shared', false); // override the value in the parent scope.

client.checkGate('forked');
client.checkGate('shared');
scope.setTag('isForked', true);
errorButton.click();
});

client.checkGate('main');
Sentry.getCurrentScope().setTag('isForked', false);
errorButton.click();
return true;
});

const forkedReq = await forkedReqPromise;
const forkedEvent = envelopeRequestParser(forkedReq);

const mainReq = await mainReqPromise;
const mainEvent = envelopeRequestParser(mainReq);

expect(forkedEvent.contexts?.flags?.values).toEqual([
{ flag: 'forked', result: true },
{ flag: 'shared', result: false },
]);

expect(mainEvent.contexts?.flags?.values).toEqual([
{ flag: 'shared', result: true },
{ flag: 'main', result: true },
]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sentryTest } from '../../../../../utils/fixtures';

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

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

sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ export {
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature';
export { unleashIntegration } from './integrations/featureFlags/unleash';
export { statsigIntegration } from './integrations/featureFlags/statsig';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { statsigIntegration } from './integration';
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core';

import { defineIntegration } from '@sentry/core';
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
import type { FeatureGate, StatsigClient } from './types';

/**
* Sentry integration for capturing feature flag evaluations from the Statsig js-client SDK.
*
* See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information.
*
* @example
* ```
* import { StatsigClient } from '@statsig/js-client';
* import * as Sentry from '@sentry/browser';
*
* const statsigClient = new StatsigClient();
*
* Sentry.init({
* dsn: '___PUBLIC_DSN___',
* integrations: [Sentry.statsigIntegration({featureFlagClient: statsigClient})],
* });
*
* await statsigClient.initializeAsync(); // or statsigClient.initializeSync();
*
* const result = statsigClient.checkGate('my-feature-gate');
* Sentry.captureException(new Error('something went wrong'));
* ```
*/
export const statsigIntegration = defineIntegration(
({ featureFlagClient: statsigClient }: { featureFlagClient: StatsigClient }) => {
return {
name: 'Statsig',

processEvent(event: Event, _hint: EventHint, _client: Client): Event {
return copyFlagsFromScopeToEvent(event);
},

setup() {
statsigClient.on('gate_evaluation', (event: { gate: FeatureGate }) => {
insertFlagToScope(event.gate.name, event.gate.value);
});
},
};
},
) satisfies IntegrationFn;
15 changes: 15 additions & 0 deletions packages/browser/src/integrations/featureFlags/statsig/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type FeatureGate = {
readonly name: string;
readonly value: boolean;
};

type EventNameToEventDataMap = {
gate_evaluation: { gate: FeatureGate };
};

export interface StatsigClient {
on(
event: keyof EventNameToEventDataMap,
callback: (data: EventNameToEventDataMap[keyof EventNameToEventDataMap]) => void,
): void;
}

0 comments on commit 22f841e

Please sign in to comment.