diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts new file mode 100644 index 000000000000..680105d242e5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts @@ -0,0 +1 @@ +export const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils. diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/basic/test.ts index ed909b19d1fa..5cf63a4914ed 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/basic/test.ts @@ -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()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/basic/test.ts index e97cb70761ba..d0adafc3d101 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/basic/test.ts @@ -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()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts index a3de589677ea..21db2c0b7d85 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts @@ -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()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/test.ts index 719782d0b0ab..bae111a5e6f5 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/test.ts @@ -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()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/basic/test.ts new file mode 100644 index 000000000000..5b484b91ba22 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/basic/test.ts @@ -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); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/init.js new file mode 100644 index 000000000000..61d00587f4c6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/init.js @@ -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://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + integrations: [window.sentryStatsigIntegration], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/subject.js new file mode 100644 index 000000000000..e6697408128c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/subject.js @@ -0,0 +1,3 @@ +document.getElementById('error').addEventListener('click', () => { + throw new Error('Button triggered error'); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/template.html new file mode 100644 index 000000000000..9330c6c679f4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/template.html @@ -0,0 +1,9 @@ + + +
+ + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/withScope/test.ts new file mode 100644 index 000000000000..cf41767036b6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/withScope/test.ts @@ -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 }, + ]); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/basic/test.ts index 5bb72caddd24..7e8065dc6172 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/basic/test.ts @@ -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()) { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 42c388d73547..2abe7beb55fb 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -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'; diff --git a/packages/browser/src/integrations/featureFlags/statsig/index.ts b/packages/browser/src/integrations/featureFlags/statsig/index.ts new file mode 100644 index 000000000000..a208e4c7dc0e --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/statsig/index.ts @@ -0,0 +1 @@ +export { statsigIntegration } from './integration'; diff --git a/packages/browser/src/integrations/featureFlags/statsig/integration.ts b/packages/browser/src/integrations/featureFlags/statsig/integration.ts new file mode 100644 index 000000000000..7870f1250032 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/statsig/integration.ts @@ -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; diff --git a/packages/browser/src/integrations/featureFlags/statsig/types.ts b/packages/browser/src/integrations/featureFlags/statsig/types.ts new file mode 100644 index 000000000000..ff0ea820d31f --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/statsig/types.ts @@ -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; +}