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 new file mode 100644 index 000000000000..ed909b19d1fa --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/basic/test.ts @@ -0,0 +1,48 @@ +import { expect } from '@playwright/test'; + +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. + +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 flagsIntegration = (window as any).Sentry.getClient().getIntegrationByName('FeatureFlags'); + for (let i = 1; i <= bufferSize; i++) { + flagsIntegration.addFeatureFlag(`feat${i}`, false); + } + flagsIntegration.addFeatureFlag(`feat${bufferSize + 1}`, true); // eviction + flagsIntegration.addFeatureFlag('feat3', true); // update + return true; + }, FLAG_BUFFER_SIZE); + + const reqPromise = waitForErrorRequest(page); + await page.locator('#error').click(); // trigger error + 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/featureFlags/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/init.js new file mode 100644 index 000000000000..894f46aaa102 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +// Not using this as we want to test the getIntegrationByName() approach +// window.sentryFeatureFlagsIntegration = Sentry.featureFlagsIntegration(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + integrations: [Sentry.featureFlagsIntegration()], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/subject.js new file mode 100644 index 000000000000..e6697408128c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/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/featureFlags/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/template.html new file mode 100644 index 000000000000..9330c6c679f4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/template.html @@ -0,0 +1,9 @@ + + +
+ + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/withScope/test.ts new file mode 100644 index 000000000000..97ecf2d961a7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/withScope/test.ts @@ -0,0 +1,65 @@ +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 && event.tags.isForked === true); + const mainReqPromise = waitForErrorRequest(page, event => !!event.tags && event.tags.isForked === false); + + await page.evaluate(() => { + const Sentry = (window as any).Sentry; + const errorButton = document.querySelector('#error') as HTMLButtonElement; + const flagsIntegration = (window as any).Sentry.getClient().getIntegrationByName('FeatureFlags'); + + flagsIntegration.addFeatureFlag('shared', true); + + Sentry.withScope((scope: Scope) => { + flagsIntegration.addFeatureFlag('forked', true); + flagsIntegration.addFeatureFlag('shared', false); + scope.setTag('isForked', true); + if (errorButton) { + errorButton.click(); + } + }); + + flagsIntegration.addFeatureFlag('main', true); + 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/packages/browser/src/index.ts b/packages/browser/src/index.ts index acb9b119d1a9..e6f57c13fe6b 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -69,5 +69,9 @@ export { makeBrowserOfflineTransport } from './transports/offline'; export { browserProfilingIntegration } from './profiling/integration'; export { spotlightBrowserIntegration } from './integrations/spotlight'; export { browserSessionIntegration } from './integrations/browsersession'; +export { + featureFlagsIntegration, + type FeatureFlagsIntegration, +} from './integrations/featureFlags'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly'; export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature'; diff --git a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts new file mode 100644 index 000000000000..01bc73190202 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -0,0 +1,47 @@ +import type { Client, Event, EventHint, Integration, IntegrationFn } from '@sentry/core'; + +import { defineIntegration } from '@sentry/core'; +import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../utils/featureFlags'; + +export interface FeatureFlagsIntegration extends Integration { + addFeatureFlag: (name: string, value: unknown) => void; +} + +/** + * Sentry integration for buffering feature flags manually with an API, and + * capturing them on error events. We recommend you do this on each flag + * evaluation. Flags are buffered per Sentry scope and limited to 100 per event. + * + * See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information. + * + * @example + * ``` + * import * as Sentry from '@sentry/browser'; + * import { type FeatureFlagsIntegration } from '@sentry/browser'; + * + * // Setup + * Sentry.init(..., integrations: [Sentry.featureFlagsIntegration()]) + * + * // Verify + * const flagsIntegration = Sentry.getClient()?.getIntegrationByName