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 new file mode 100644 index 000000000000..e97cb70761ba --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/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 ldClient = (window as any).initializeLD(); + for (let i = 1; i <= bufferSize; i++) { + ldClient.variation(`feat${i}`, false); + } + ldClient.variation(`feat${bufferSize + 1}`, true); // eviction + ldClient.variation('feat3', true); // update + return true; + }, 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/launchdarkly/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/init.js new file mode 100644 index 000000000000..aeea903b4eab --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/init.js @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.sentryLDIntegration = Sentry.launchDarklyIntegration(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + integrations: [window.sentryLDIntegration], +}); + +// Manually mocking this because LD only has mock test utils for the React SDK. +// Also, no SDK has mock utils for FlagUsedHandler's. +const MockLaunchDarkly = { + initialize(_clientId, context, options) { + const flagUsedHandler = options && options.inspectors ? options.inspectors[0].method : undefined; + + return { + variation(key, defaultValue) { + if (flagUsedHandler) { + flagUsedHandler(key, { value: defaultValue }, context); + } + return defaultValue; + }, + }; + }, +}; + +window.initializeLD = () => { + return MockLaunchDarkly.initialize( + 'example-client-id', + { kind: 'user', key: 'example-context-key' }, + { inspectors: [Sentry.buildLaunchDarklyFlagUsedHandler()] }, + ); +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/subject.js new file mode 100644 index 000000000000..e6697408128c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/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/launchdarkly/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/template.html new file mode 100644 index 000000000000..9330c6c679f4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/withScope/test.ts new file mode 100644 index 000000000000..6046da6241be --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/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 ldClient = (window as any).initializeLD(); + + ldClient.variation('shared', true); + + Sentry.withScope((scope: Scope) => { + ldClient.variation('forked', true); + ldClient.variation('shared', false); + scope.setTag('isForked', true); + if (errorButton) { + errorButton.click(); + } + }); + + ldClient.variation('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/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 new file mode 100644 index 000000000000..a3de589677ea --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts @@ -0,0 +1,47 @@ +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 client = (window as any).initialize(); + for (let i = 1; i <= bufferSize; i++) { + client.getBooleanValue(`feat${i}`, false); + } + client.getBooleanValue(`feat${bufferSize + 1}`, true); // eviction + client.getBooleanValue('feat3', true); // 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/openfeature/errorHook/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/init.js new file mode 100644 index 000000000000..971e08755fe6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.sentryOpenFeatureIntegration = Sentry.openFeatureIntegration(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + integrations: [window.sentryOpenFeatureIntegration], +}); + +window.initialize = () => { + return { + getBooleanValue(flag, value) { + let hook = new Sentry.OpenFeatureIntegrationHook(); + hook.error({ flagKey: flag, defaultValue: false }, new Error('flag eval error')); + return value; + }, + }; +}; 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 new file mode 100644 index 000000000000..719782d0b0ab --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/test.ts @@ -0,0 +1,49 @@ +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('Flag evaluation error hook', 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).initialize(); + for (let i = 1; i <= bufferSize; i++) { + client.getBooleanValue(`feat${i}`, false); + } + client.getBooleanValue(`feat${bufferSize + 1}`, true); // eviction + client.getBooleanValue('feat3', true); // update + }, FLAG_BUFFER_SIZE); + + const reqPromise = waitForErrorRequest(page); + await page.locator('#error').click(); + const req = await reqPromise; + const event = envelopeRequestParser(req); + + // Default value is mocked as false -- these will all error and use default + // value + 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: false }); + expectedFlags.push({ flag: 'feat3', result: false }); + + expect(event.contexts?.flags?.values).toEqual(expectedFlags); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js new file mode 100644 index 000000000000..b2b48519b8a9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.sentryOpenFeatureIntegration = Sentry.openFeatureIntegration(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + integrations: [window.sentryOpenFeatureIntegration], +}); + +window.initialize = () => { + return { + getBooleanValue(flag, value) { + let hook = new Sentry.OpenFeatureIntegrationHook(); + hook.after(null, { flagKey: flag, value: value }); + return value; + }, + }; +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/subject.js new file mode 100644 index 000000000000..e6697408128c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/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/openfeature/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/template.html new file mode 100644 index 000000000000..9330c6c679f4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/withScope/test.ts new file mode 100644 index 000000000000..7edb9b2e533b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/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 client = (window as any).initialize(); + + client.getBooleanValue('shared', true); + + Sentry.withScope((scope: Scope) => { + client.getBooleanValue('forked', true); + client.getBooleanValue('shared', false); + scope.setTag('isForked', true); + if (errorButton) { + errorButton.click(); + } + }); + + client.getBooleanValue('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/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index df60adfcaabb..91c96298fbce 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -285,6 +285,18 @@ export function shouldSkipMetricsTest(): boolean { return bundle != null && !bundle.includes('tracing') && !bundle.includes('esm') && !bundle.includes('cjs'); } +/** + * We only test feature flags integrations in certain bundles/packages: + * - NPM (ESM, CJS) + * - Not CDNs. + * + * @returns `true` if we should skip the feature flags test + */ +export function shouldSkipFeatureFlagsTest(): boolean { + const bundle = process.env.PW_BUNDLE as string | undefined; + return bundle != null && !bundle.includes('esm') && !bundle.includes('cjs'); +} + /** * Waits until a number of requests matching urlRgx at the given URL arrive. * If the timeout option is configured, this function will abort waiting, even if it hasn't received the configured diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 9440333caec4..acb9b119d1a9 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -15,10 +15,7 @@ export { captureFeedback, } from '@sentry/core'; -export { - replayIntegration, - getReplay, -} from '@sentry-internal/replay'; +export { replayIntegration, getReplay } from '@sentry-internal/replay'; export type { ReplayEventType, ReplayEventWithTime, @@ -36,17 +33,11 @@ export { replayCanvasIntegration } from '@sentry-internal/replay-canvas'; import { feedbackAsyncIntegration } from './feedbackAsync'; import { feedbackSyncIntegration } from './feedbackSync'; export { feedbackAsyncIntegration, feedbackSyncIntegration, feedbackSyncIntegration as feedbackIntegration }; -export { - getFeedback, - sendFeedback, -} from '@sentry-internal/feedback'; +export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; export * from './metrics'; -export { - defaultRequestInstrumentationOptions, - instrumentOutgoingRequests, -} from './tracing/request'; +export { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './tracing/request'; export { browserTracingIntegration, startBrowserTracingNavigationSpan, @@ -78,3 +69,5 @@ export { makeBrowserOfflineTransport } from './transports/offline'; export { browserProfilingIntegration } from './profiling/integration'; export { spotlightBrowserIntegration } from './integrations/spotlight'; export { browserSessionIntegration } from './integrations/browsersession'; +export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly'; +export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature'; diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/index.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/index.ts new file mode 100644 index 000000000000..7a81279ad319 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/index.ts @@ -0,0 +1 @@ +export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integration'; diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts new file mode 100644 index 000000000000..d08c34a59c5e --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -0,0 +1,52 @@ +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; +import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; + +import { defineIntegration } from '@sentry/core'; +import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; + +/** + * Sentry integration for capturing feature flags from LaunchDarkly. + * + * 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 {launchDarklyIntegration, buildLaunchDarklyFlagUsedInspector} from '@sentry/browser'; + * import * as LaunchDarkly from 'launchdarkly-js-client-sdk'; + * + * Sentry.init(..., integrations: [launchDarklyIntegration()]) + * const ldClient = LaunchDarkly.initialize(..., {inspectors: [buildLaunchDarklyFlagUsedHandler()]}); + * ``` + */ +export const launchDarklyIntegration = defineIntegration(() => { + return { + name: 'LaunchDarkly', + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return copyFlagsFromScopeToEvent(event); + }, + }; +}) satisfies IntegrationFn; + +/** + * LaunchDarkly hook that listens for flag evaluations and updates the `flags` + * context in our Sentry scope. This needs to be registered as an + * 'inspector' in LaunchDarkly initialize() options, separately from + * `launchDarklyIntegration`. Both are needed to collect feature flags on error. + */ +export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler { + return { + name: 'sentry-flag-auditor', + type: 'flag-used', + + synchronous: true, + + /** + * Handle a flag evaluation by storing its name and value on the current scope. + */ + method: (flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext) => { + insertFlagToScope(flagKey, flagDetail.value); + }, + }; +} diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/types.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/types.ts new file mode 100644 index 000000000000..55a388109e60 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/types.ts @@ -0,0 +1,50 @@ +/** + * Inline definitions of LaunchDarkly types so we don't have to include their + * SDK in devDependencies. These are only for type-checking and can be extended + * as needed - for exact definitions, reference `launchdarkly-js-client-sdk`. + */ + +/** + * Currently, the Sentry integration does not read from values of this type. + */ +export type LDContext = object; + +/** + * An object that combines the result of a feature flag evaluation with information about + * how it was calculated. + */ +export interface LDEvaluationDetail { + value: unknown; + // unused optional props: variationIndex and reason +} + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to collect information about flag usage. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionFlagUsedHandler { + type: 'flag-used'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * If `true`, then the inspector will be ran synchronously with evaluation. + * Synchronous inspectors execute inline with evaluation and care should be taken to ensure + * they have minimal performance overhead. + */ + synchronous?: boolean; + + /** + * This method is called when a flag is accessed via a variation method, or it can be called based on actions in + * wrapper SDKs which have different methods of tracking when a flag was accessed. It is not called when a call is made + * to allFlags. + */ + method: (flagKey: string, flagDetail: LDEvaluationDetail, context: LDContext) => void; +} diff --git a/packages/browser/src/integrations/featureFlags/openfeature/index.ts b/packages/browser/src/integrations/featureFlags/openfeature/index.ts new file mode 100644 index 000000000000..e3d425aeac29 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/openfeature/index.ts @@ -0,0 +1 @@ +export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integration'; diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts new file mode 100644 index 000000000000..5b5b56d65d18 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -0,0 +1,41 @@ +/** + * OpenFeature integration. + * + * Add the openFeatureIntegration() function call to your integration lists. + * Add the integration hook to your OpenFeature object. + * - OpenFeature.getClient().addHooks(new OpenFeatureIntegrationHook()); + */ +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; +import type { EvaluationDetails, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types'; + +import { defineIntegration } from '@sentry/core'; +import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; + +export const openFeatureIntegration = defineIntegration(() => { + return { + name: 'OpenFeature', + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return copyFlagsFromScopeToEvent(event); + }, + }; +}) satisfies IntegrationFn; + +/** + * OpenFeature Hook class implementation. + */ +export class OpenFeatureIntegrationHook implements OpenFeatureHook { + /** + * Successful evaluation result. + */ + public after(_hookContext: Readonly>, evaluationDetails: EvaluationDetails): void { + insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value); + } + + /** + * On error evaluation result. + */ + public error(hookContext: Readonly>, _error: unknown, _hookHints?: HookHints): void { + insertFlagToScope(hookContext.flagKey, hookContext.defaultValue); + } +} diff --git a/packages/browser/src/integrations/featureFlags/openfeature/types.ts b/packages/browser/src/integrations/featureFlags/openfeature/types.ts new file mode 100644 index 000000000000..835e684d86eb --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/openfeature/types.ts @@ -0,0 +1,89 @@ +export type FlagValue = boolean | string | number | JsonValue; +export type FlagValueType = 'boolean' | 'string' | 'number' | 'object'; +export type JsonArray = JsonValue[]; +export type JsonObject = { [key: string]: JsonValue }; +export type JsonValue = PrimitiveValue | JsonObject | JsonArray; +export type Metadata = Record; +export type PrimitiveValue = null | boolean | string | number; +export type FlagMetadata = Record; +export const StandardResolutionReasons = { + STATIC: 'STATIC', + DEFAULT: 'DEFAULT', + TARGETING_MATCH: 'TARGETING_MATCH', + SPLIT: 'SPLIT', + CACHED: 'CACHED', + DISABLED: 'DISABLED', + UNKNOWN: 'UNKNOWN', + STALE: 'STALE', + ERROR: 'ERROR', +} as const; +export enum ErrorCode { + PROVIDER_NOT_READY = 'PROVIDER_NOT_READY', + PROVIDER_FATAL = 'PROVIDER_FATAL', + FLAG_NOT_FOUND = 'FLAG_NOT_FOUND', + PARSE_ERROR = 'PARSE_ERROR', + TYPE_MISMATCH = 'TYPE_MISMATCH', + TARGETING_KEY_MISSING = 'TARGETING_KEY_MISSING', + INVALID_CONTEXT = 'INVALID_CONTEXT', + GENERAL = 'GENERAL', +} +export interface Logger { + error(...args: unknown[]): void; + warn(...args: unknown[]): void; + info(...args: unknown[]): void; + debug(...args: unknown[]): void; +} +export type ResolutionReason = keyof typeof StandardResolutionReasons | (string & Record); +export type EvaluationContextValue = + | PrimitiveValue + | Date + | { [key: string]: EvaluationContextValue } + | EvaluationContextValue[]; +export type EvaluationContext = { + targetingKey?: string; +} & Record; +export interface ProviderMetadata extends Readonly { + readonly name: string; +} +export interface ClientMetadata { + readonly name?: string; + readonly domain?: string; + readonly version?: string; + readonly providerMetadata: ProviderMetadata; +} +export type HookHints = Readonly>; +export interface HookContext { + readonly flagKey: string; + readonly defaultValue: T; + readonly flagValueType: FlagValueType; + readonly context: Readonly; + readonly clientMetadata: ClientMetadata; + readonly providerMetadata: ProviderMetadata; + readonly logger: Logger; +} +export interface BeforeHookContext extends HookContext { + context: EvaluationContext; +} +export type ResolutionDetails = { + value: U; + variant?: string; + flagMetadata?: FlagMetadata; + reason?: ResolutionReason; + errorCode?: ErrorCode; + errorMessage?: string; +}; +export type EvaluationDetails = { + flagKey: string; + flagMetadata: Readonly; +} & ResolutionDetails; +export interface BaseHook { + before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn; + after?( + hookContext: Readonly>, + evaluationDetails: EvaluationDetails, + hookHints?: HookHints, + ): HooksReturn; + error?(hookContext: Readonly>, error: unknown, hookHints?: HookHints): HooksReturn; + finally?(hookContext: Readonly>, hookHints?: HookHints): HooksReturn; +} +export type OpenFeatureHook = BaseHook; diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts new file mode 100644 index 000000000000..9793eda49191 --- /dev/null +++ b/packages/browser/src/utils/featureFlags.ts @@ -0,0 +1,90 @@ +import type { Event, FeatureFlag } from '@sentry/core'; + +import { getCurrentScope, logger } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +/** + * Ordered LRU cache for storing feature flags in the scope context. The name + * of each flag in the buffer is unique, and the output of getAll() is ordered + * from oldest to newest. + */ + +/** + * Max size of the LRU flag buffer stored in Sentry scope and event contexts. + */ +export const FLAG_BUFFER_SIZE = 100; + +/** + * Copies feature flags that are in current scope context to the event context + */ +export function copyFlagsFromScopeToEvent(event: Event): Event { + const scope = getCurrentScope(); + const flagContext = scope.getScopeData().contexts.flags; + const flagBuffer = flagContext ? flagContext.values : []; + + if (!flagBuffer.length) { + return event; + } + + if (event.contexts === undefined) { + event.contexts = {}; + } + event.contexts.flags = { values: [...flagBuffer] }; + return event; +} + +/** + * Creates a feature flags values array in current context if it does not exist + * and inserts the flag into a FeatureFlag array while maintaining ordered LRU + * properties. Not thread-safe. After inserting: + * - `flags` is sorted in order of recency, with the newest flag at the end. + * - No other flags with the same name exist in `flags`. + * - The length of `flags` does not exceed `maxSize`. The oldest flag is evicted + * as needed. + * + * @param name Name of the feature flag to insert. + * @param value Value of the feature flag. + * @param maxSize Max number of flags the buffer should store. It's recommended + * to keep this consistent across insertions. Default is FLAG_BUFFER_SIZE + */ +export function insertFlagToScope(name: string, value: unknown, maxSize: number = FLAG_BUFFER_SIZE): void { + const scopeContexts = getCurrentScope().getScopeData().contexts; + if (!scopeContexts.flags) { + scopeContexts.flags = { values: [] }; + } + const flags = scopeContexts.flags.values as FeatureFlag[]; + insertToFlagBuffer(flags, name, value, maxSize); +} + +/** + * Exported for tests. Currently only accepts boolean values (otherwise no-op). + */ +export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: unknown, maxSize: number): void { + if (typeof value !== 'boolean') { + return; + } + + if (flags.length > maxSize) { + DEBUG_BUILD && logger.error(`[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize=${maxSize}`); + return; + } + + // Check if the flag is already in the buffer - O(n) + const index = flags.findIndex(f => f.flag === name); + + if (index !== -1) { + // The flag was found, remove it from its current position - O(n) + flags.splice(index, 1); + } + + if (flags.length === maxSize) { + // If at capacity, pop the earliest flag - O(n) + flags.shift(); + } + + // Push the flag to the end - O(1) + flags.push({ + flag: name, + result: value, + }); +} diff --git a/packages/browser/test/utils/featureFlags.test.ts b/packages/browser/test/utils/featureFlags.test.ts new file mode 100644 index 000000000000..48d2f20e3c91 --- /dev/null +++ b/packages/browser/test/utils/featureFlags.test.ts @@ -0,0 +1,109 @@ +import type { FeatureFlag } from '@sentry/core'; + +import { getCurrentScope, logger } from '@sentry/core'; +import { vi } from 'vitest'; +import { insertFlagToScope, insertToFlagBuffer } from '../../src/utils/featureFlags'; + +describe('flags', () => { + describe('insertFlagToScope()', () => { + it('adds flags to the current scope context', () => { + const maxSize = 3; + insertFlagToScope('feat1', true, maxSize); + insertFlagToScope('feat2', true, maxSize); + insertFlagToScope('feat3', true, maxSize); + insertFlagToScope('feat4', true, maxSize); + + const scope = getCurrentScope(); + expect(scope.getScopeData().contexts.flags?.values).toEqual([ + { flag: 'feat2', result: true }, + { flag: 'feat3', result: true }, + { flag: 'feat4', result: true }, + ]); + }); + }); + + describe('insertToFlagBuffer()', () => { + const loggerSpy = vi.spyOn(logger, 'error'); + + afterEach(() => { + loggerSpy.mockClear(); + }); + + it('maintains ordering and evicts the oldest entry', () => { + const buffer: FeatureFlag[] = []; + const maxSize = 3; + insertToFlagBuffer(buffer, 'feat1', true, maxSize); + insertToFlagBuffer(buffer, 'feat2', true, maxSize); + insertToFlagBuffer(buffer, 'feat3', true, maxSize); + insertToFlagBuffer(buffer, 'feat4', true, maxSize); + + expect(buffer).toEqual([ + { flag: 'feat2', result: true }, + { flag: 'feat3', result: true }, + { flag: 'feat4', result: true }, + ]); + }); + + it('does not duplicate same-name flags and updates order and values', () => { + const buffer: FeatureFlag[] = []; + const maxSize = 3; + insertToFlagBuffer(buffer, 'feat1', true, maxSize); + insertToFlagBuffer(buffer, 'feat2', true, maxSize); + insertToFlagBuffer(buffer, 'feat3', true, maxSize); + insertToFlagBuffer(buffer, 'feat3', false, maxSize); + insertToFlagBuffer(buffer, 'feat1', false, maxSize); + + expect(buffer).toEqual([ + { flag: 'feat2', result: true }, + { flag: 'feat3', result: false }, + { flag: 'feat1', result: false }, + ]); + }); + + it('does not allocate unnecessary space', () => { + const buffer: FeatureFlag[] = []; + const maxSize = 1000; + insertToFlagBuffer(buffer, 'feat1', true, maxSize); + insertToFlagBuffer(buffer, 'feat2', true, maxSize); + + expect(buffer).toEqual([ + { flag: 'feat1', result: true }, + { flag: 'feat2', result: true }, + ]); + }); + + it('does not accept non-boolean values', () => { + const buffer: FeatureFlag[] = []; + const maxSize = 1000; + insertToFlagBuffer(buffer, 'feat1', 1, maxSize); + insertToFlagBuffer(buffer, 'feat2', 'string', maxSize); + + expect(buffer).toEqual([]); + }); + + it('logs error and is a no-op when buffer is larger than maxSize', () => { + const buffer: FeatureFlag[] = [ + { flag: 'feat1', result: true }, + { flag: 'feat2', result: true }, + ]; + + insertToFlagBuffer(buffer, 'feat1', true, 1); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize'), + ); + expect(buffer).toEqual([ + { flag: 'feat1', result: true }, + { flag: 'feat2', result: true }, + ]); + + insertToFlagBuffer(buffer, 'feat1', true, -2); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize'), + ); + expect(buffer).toEqual([ + { flag: 'feat1', result: true }, + { flag: 'feat2', result: true }, + ]); + }); + }); +}); diff --git a/packages/core/src/featureFlags.ts b/packages/core/src/featureFlags.ts new file mode 100644 index 000000000000..f80e17ef7f9d --- /dev/null +++ b/packages/core/src/featureFlags.ts @@ -0,0 +1 @@ +export type FeatureFlag = { readonly flag: string; readonly result: boolean }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 751279cde693..ac1a44cf3712 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -124,3 +124,5 @@ export { getCurrentHubShim, getCurrentHub } from './getCurrentHubShim'; export * from './utils-hoist/index'; // TODO(v9): Make this structure pretty again and don't do "export *" export * from './types-hoist/index'; + +export type { FeatureFlag } from './featureFlags'; diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 656ada3d6355..81d8018259b5 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -130,6 +130,14 @@ class ScopeClass implements ScopeInterface { newScope._tags = { ...this._tags }; newScope._extra = { ...this._extra }; newScope._contexts = { ...this._contexts }; + if (this._contexts.flags) { + // We need to copy the `values` array so insertions on a cloned scope + // won't affect the original array. + newScope._contexts.flags = { + values: [...this._contexts.flags.values], + }; + } + newScope._user = this._user; newScope._level = this._level; newScope._session = this._session; diff --git a/packages/core/src/types-hoist/context.ts b/packages/core/src/types-hoist/context.ts index 10fc61420e25..60aa60b38868 100644 --- a/packages/core/src/types-hoist/context.ts +++ b/packages/core/src/types-hoist/context.ts @@ -1,3 +1,4 @@ +import type { FeatureFlag } from '../featureFlags'; import type { Primitive } from './misc'; import type { SpanOrigin } from './span'; @@ -13,6 +14,7 @@ export interface Contexts extends Record { cloud_resource?: CloudResourceContext; state?: StateContext; profile?: ProfileContext; + flags?: FeatureFlagContext; } export interface StateContext extends Record { @@ -124,3 +126,13 @@ export interface MissingInstrumentationContext extends Record { package: string; ['javascript.is_cjs']?: boolean; } + +/** + * Used to buffer flag evaluation data on the current scope and attach it to + * error events. `values` should be initialized as empty ([]), and modifying + * directly is not recommended. Use the functions in @sentry/browser + * src/utils/featureFlags instead. + */ +export interface FeatureFlagContext extends Record { + values: FeatureFlag[]; +} diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index 4c1c848a1a57..e896a401be20 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -306,6 +306,14 @@ function normalizeEvent(event: Event | null, depth: number, maxBreadth: number): }); } + // event.contexts.flags (FeatureFlagContext) stores context for our feature + // flag integrations. It has a greater nesting depth than our other typed + // Contexts, so we re-normalize with a fixed depth of 3 here. We do not want + // to skip this in case of conflicting, user-provided context. + if (event.contexts && event.contexts.flags && normalized.contexts) { + normalized.contexts.flags = normalize(event.contexts.flags, 3, maxBreadth); + } + return normalized; } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 372e5e854a87..a0239af93af8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -54,6 +54,7 @@ import type { Extra as Extra_imported, ExtractedNodeRequestData as ExtractedNodeRequestData_imported, Extras as Extras_imported, + FeatureFlag as FeatureFlag_imported, FeedbackEvent as FeedbackEvent_imported, FeedbackFormData as FeedbackFormData_imported, FeedbackInternalOptions as FeedbackInternalOptions_imported, @@ -568,3 +569,5 @@ export type Profiler = Profiler_imported; export type ViewHierarchyData = ViewHierarchyData_imported; /** @deprecated This type has been moved to `@sentry/core`. */ export type ViewHierarchyWindow = ViewHierarchyWindow_imported; +/** @deprecated This type has been moved to `@sentry/core`. */ +export type FeatureFlag = FeatureFlag_imported;