From c8e8c21dc68ab7019c036f5a5b9b64e0e1931f19 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 19 Nov 2024 13:57:20 -0500 Subject: [PATCH 01/11] ref(flags): Refactor LaunchDarkly integration to reusable functions Moves core logic into re-usable functions so that we can re-use for other integrations. --- .../featureFlags/launchdarkly/integration.ts | 24 ++---------- packages/browser/src/utils/featureFlags.ts | 38 ++++++++++++++----- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index 3d5b491ed889..fabf7db3c46f 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -1,8 +1,8 @@ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types'; import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; -import { defineIntegration, getCurrentScope } from '@sentry/core'; -import { insertToFlagBuffer } from '../../../utils/featureFlags'; +import { defineIntegration } from '@sentry/core'; +import { copyFlagsFromScopeToEvent, insertToFlagBuffer } from '../../../utils/featureFlags'; /** * Sentry integration for capturing feature flags from LaunchDarkly. @@ -24,15 +24,7 @@ export const launchDarklyIntegration = defineIntegration(() => { name: 'LaunchDarkly', processEvent(event: Event, _hint: EventHint, _client: Client): Event { - const scope = getCurrentScope(); - const flagContext = scope.getScopeData().contexts.flags; - const flagBuffer = flagContext ? flagContext.values : []; - - if (event.contexts === undefined) { - event.contexts = {}; - } - event.contexts.flags = { values: [...flagBuffer] }; - return event; + return copyFlagsFromScopeToEvent(event); }, }; }) satisfies IntegrationFn; @@ -54,15 +46,7 @@ export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler * Handle a flag evaluation by storing its name and value on the current scope. */ method: (flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext) => { - if (typeof flagDetail.value === 'boolean') { - const scopeContexts = getCurrentScope().getScopeData().contexts; - if (!scopeContexts.flags) { - scopeContexts.flags = { values: [] }; - } - const flagBuffer = scopeContexts.flags.values; - insertToFlagBuffer(flagBuffer, flagKey, flagDetail.value); - } - return; + insertToFlagBuffer(flagKey, flagDetail.value); }, }; } diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index caddd68bc31e..c88cad7c0252 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -1,5 +1,5 @@ -import { logger } from '@sentry/core'; -import type { FeatureFlag } from '@sentry/types'; +import { getCurrentScope, logger } from '@sentry/core'; +import type { Event, FeatureFlag } from '@sentry/types'; import { DEBUG_BUILD } from '../debug-build'; /** @@ -13,6 +13,21 @@ import { DEBUG_BUILD } from '../debug-build'; */ 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 (event.contexts === undefined) { + event.contexts = {}; + } + event.contexts.flags = { values: [...flagBuffer] }; + return event; +} + /** * Insert into a FeatureFlag array while maintaining ordered LRU properties. Not * thread-safe. After inserting: @@ -21,18 +36,23 @@ export const FLAG_BUFFER_SIZE = 100; * - The length of `flags` does not exceed `maxSize`. The oldest flag is evicted * as needed. * - * @param flags The array to insert into. * @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 DEFAULT_MAX_SIZE */ -export function insertToFlagBuffer( - flags: FeatureFlag[], - name: string, - value: boolean, - maxSize: number = FLAG_BUFFER_SIZE, -): void { +export function insertToFlagBuffer(name: string, value: unknown, maxSize: number = FLAG_BUFFER_SIZE): void { + // Currently only accepts boolean values + if (typeof value !== 'boolean') { + return; + } + + const scopeContexts = getCurrentScope().getScopeData().contexts; + if (!scopeContexts.flags) { + scopeContexts.flags = { values: [] }; + } + const flags = scopeContexts.flags.values as FeatureFlag[]; + if (flags.length > maxSize) { DEBUG_BUILD && logger.error(`[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize=${maxSize}`); return; From ddf814b3f69c6ce7de6b085658d96a2730daf4b3 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 19 Nov 2024 14:54:18 -0500 Subject: [PATCH 02/11] split insertToFlagBuffer into 2 functions (insertFlagToScope and insertToFlagBuffer) --- .../featureFlags/launchdarkly/integration.ts | 4 +-- packages/browser/src/utils/featureFlags.ts | 25 ++++++++++------ .../browser/test/utils/featureFlags.test.ts | 29 ++++++++++++++++++- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index fabf7db3c46f..0fc6f1085db7 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -2,7 +2,7 @@ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types'; import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; import { defineIntegration } from '@sentry/core'; -import { copyFlagsFromScopeToEvent, insertToFlagBuffer } from '../../../utils/featureFlags'; +import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; /** * Sentry integration for capturing feature flags from LaunchDarkly. @@ -46,7 +46,7 @@ export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler * Handle a flag evaluation by storing its name and value on the current scope. */ method: (flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext) => { - insertToFlagBuffer(flagKey, flagDetail.value); + insertFlagToScope(flagKey, flagDetail.value); }, }; } diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index c88cad7c0252..38f726735f19 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -29,8 +29,9 @@ export function copyFlagsFromScopeToEvent(event: Event): Event { } /** - * Insert into a FeatureFlag array while maintaining ordered LRU properties. Not - * thread-safe. After inserting: + * 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 @@ -39,19 +40,25 @@ export function copyFlagsFromScopeToEvent(event: Event): Event { * @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 DEFAULT_MAX_SIZE + * to keep this consistent across insertions. Default is FLAG_BUFFER_SIZE */ -export function insertToFlagBuffer(name: string, value: unknown, maxSize: number = FLAG_BUFFER_SIZE): void { - // Currently only accepts boolean values - if (typeof value !== 'boolean') { - return; - } - +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 + */ +export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: unknown, maxSize: number): void { + // Currently only accepts boolean values + if (typeof value !== 'boolean') { + return; + } if (flags.length > maxSize) { DEBUG_BUILD && logger.error(`[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize=${maxSize}`); diff --git a/packages/browser/test/utils/featureFlags.test.ts b/packages/browser/test/utils/featureFlags.test.ts index ef4ca7f4611f..d8ba469fc61a 100644 --- a/packages/browser/test/utils/featureFlags.test.ts +++ b/packages/browser/test/utils/featureFlags.test.ts @@ -1,9 +1,27 @@ +import { getCurrentScope } from '@sentry/core'; import type { FeatureFlag } from '@sentry/types'; import { logger } from '@sentry/utils'; import { vi } from 'vitest'; -import { insertToFlagBuffer } from '../../src/utils/featureFlags'; +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'); @@ -54,6 +72,15 @@ describe('flags', () => { ]); }); + 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 }, From 4195a8e8c7e24c240b7e559558991738fb8dbc0e Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 19 Nov 2024 17:06:48 -0500 Subject: [PATCH 03/11] only copy flags if buffer is not empty --- packages/browser/src/utils/featureFlags.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index 38f726735f19..b2090c2783b8 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -21,6 +21,10 @@ export function copyFlagsFromScopeToEvent(event: Event): Event { const flagContext = scope.getScopeData().contexts.flags; const flagBuffer = flagContext ? flagContext.values : []; + if (!flagBuffer.length) { + return event; + } + if (event.contexts === undefined) { event.contexts = {}; } From 2d48782fb07c30ee5beb62337209451b3f2a2d03 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 19 Nov 2024 13:57:20 -0500 Subject: [PATCH 04/11] ref(flags): Refactor LaunchDarkly integration to reusable functions Moves core logic into re-usable functions so that we can re-use for other integrations. --- .../src/integrations/featureFlags/launchdarkly/integration.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index 0fc6f1085db7..4495f87ad649 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -2,7 +2,11 @@ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types'; import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; import { defineIntegration } from '@sentry/core'; +<<<<<<< HEAD import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; +======= +import { copyFlagsFromScopeToEvent, insertToFlagBuffer } from '../../../utils/featureFlags'; +>>>>>>> 0f32cb567 (ref(flags): Refactor LaunchDarkly integration to reusable functions) /** * Sentry integration for capturing feature flags from LaunchDarkly. From 22e7e16ae06225884820ca90d1e97a00c83f9170 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 15 Nov 2024 13:22:03 -0600 Subject: [PATCH 05/11] Add OpenFeature browser integration --- .../featureFlags/openfeature/basic/test.ts | 48 ++++++++++ .../featureFlags/openfeature/init.js | 20 +++++ .../featureFlags/openfeature/subject.js | 3 + .../featureFlags/openfeature/template.html | 9 ++ .../openfeature/withScope/test.ts | 65 ++++++++++++++ .../utils/helpers.ts | 12 +++ packages/browser/src/index.ts | 1 + .../featureFlags/openfeature/index.ts | 1 + .../featureFlags/openfeature/integration.ts | 61 +++++++++++++ .../featureFlags/openfeature/types.ts | 89 +++++++++++++++++++ 10 files changed, 309 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/withScope/test.ts create mode 100644 packages/browser/src/integrations/featureFlags/openfeature/index.ts create mode 100644 packages/browser/src/integrations/featureFlags/openfeature/integration.ts create mode 100644 packages/browser/src/integrations/featureFlags/openfeature/types.ts 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..4b31f59d8ab9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts @@ -0,0 +1,48 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../../utils/fixtures'; + +import { envelopeRequestParser, shouldSkipOpenFeatureTest, 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 ({ getLocalTestPath, page }) => { + if (shouldSkipOpenFeatureTest()) { + 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 getLocalTestPath({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + await page.waitForFunction(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 + 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/openfeature/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js new file mode 100644 index 000000000000..f698b047c5ff --- /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..f895a0307937 --- /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, shouldSkipOpenFeatureTest, waitForErrorRequest } from '../../../../../utils/helpers'; + +import type { Scope } from '@sentry/browser'; + +sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestPath, page }) => { + if (shouldSkipOpenFeatureTest()) { + 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 getLocalTestPath({ 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.waitForFunction(() => { + 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 b77db038e020..d635576a7735 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 shouldSkipFeatureFlagsTest(): boolean { return bundle != null && !bundle.includes('esm') && !bundle.includes('cjs'); } +/** + * We can only test the openFeature browser integration in certain bundles/packages: + * - NPM (ESM, CJS) + * - Not CDNs. + * + * @returns `true` if we should skip the openFeature test + */ +export function shouldSkipOpenFeatureTest(): 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 5f762c2cfa9b..e9d336dc8682 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -78,3 +78,4 @@ export { makeBrowserOfflineTransport } from './transports/offline'; export { browserProfilingIntegration } from './profiling/integration'; export { spotlightBrowserIntegration } from './integrations/spotlight'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly'; +export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature'; 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..65524943e564 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -0,0 +1,61 @@ +/** + * 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/types'; +import type { OpenFeatureHook, FlagValue, JsonValue, HookContext, EvaluationDetails, HookHints } from './types'; + +import { defineIntegration, getCurrentScope } from '@sentry/core'; +import { insertToFlagBuffer } from '../../../utils/featureFlags' + +export const openFeatureIntegration = defineIntegration(() => { + return { + name: 'OpenFeature', + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + const scope = getCurrentScope(); + const flagContext = scope.getScopeData().contexts.flags; + const flagBuffer = flagContext ? flagContext.values : []; + + if (event.contexts === undefined) { + event.contexts = {}; + } + event.contexts.flags = { values: [...flagBuffer] }; + return event; + }, + }; +}) satisfies IntegrationFn; + +/** + * OpenFeature Hook class implementation. + */ +export class OpenFeatureIntegrationHook implements OpenFeatureHook { + /** + * Successful evaluation result. + */ + public after(_hookContext: Readonly>, evaluationDetails: EvaluationDetails): void { + processEvent(evaluationDetails.flagKey, evaluationDetails.value); + } + + /** + * On error evaluation result. + */ + public error(hookContext: Readonly>, _error: unknown, _hookHints?: HookHints): void { + processEvent(hookContext.flagKey, hookContext.defaultValue); + } +} + +function processEvent(key: string, value: FlagValue): void { + if (typeof value === 'boolean') { + const scopeContexts = getCurrentScope().getScopeData().contexts; + if (!scopeContexts.flags) { + scopeContexts.flags = { values: [] }; + } + const flagBuffer = scopeContexts.flags.values; + insertToFlagBuffer(flagBuffer, key, value); + } + return; +} 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; From c9d3a741f43c534f0057c8e90c35a91054bc0d6a Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 15 Nov 2024 15:18:38 -0600 Subject: [PATCH 06/11] Linting --- .../suites/integrations/featureFlags/openfeature/init.js | 6 +++--- .../integrations/featureFlags/openfeature/integration.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index f698b047c5ff..37a47b0d6357 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js @@ -13,8 +13,8 @@ window.initialize = () => { return { getBooleanValue(flag, value) { let hook = new Sentry.OpenFeatureIntegrationHook(); - hook.after(null, {flagKey: flag, value: value}) + hook.after(null, { flagKey: flag, value: value }) return value; - } - } + }, + }; }; diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index 65524943e564..8e73e0c3c706 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -6,7 +6,7 @@ * - OpenFeature.getClient().addHooks(new OpenFeatureIntegrationHook()); */ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types'; -import type { OpenFeatureHook, FlagValue, JsonValue, HookContext, EvaluationDetails, HookHints } from './types'; +import type { EvaluationDetails, FlagValue, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types'; import { defineIntegration, getCurrentScope } from '@sentry/core'; import { insertToFlagBuffer } from '../../../utils/featureFlags' From 3dcf07ecace2eb4cfc32f7a24cf5ba499fec44b2 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 15 Nov 2024 15:24:31 -0600 Subject: [PATCH 07/11] Add missing semi-colons --- .../suites/integrations/featureFlags/openfeature/init.js | 2 +- .../src/integrations/featureFlags/openfeature/integration.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 37a47b0d6357..b2b48519b8a9 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js @@ -13,7 +13,7 @@ window.initialize = () => { return { getBooleanValue(flag, value) { let hook = new Sentry.OpenFeatureIntegrationHook(); - hook.after(null, { flagKey: flag, value: value }) + hook.after(null, { flagKey: flag, value: value }); return value; }, }; diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index 8e73e0c3c706..736cd40f7153 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -9,7 +9,7 @@ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types'; import type { EvaluationDetails, FlagValue, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types'; import { defineIntegration, getCurrentScope } from '@sentry/core'; -import { insertToFlagBuffer } from '../../../utils/featureFlags' +import { insertToFlagBuffer } from '../../../utils/featureFlags'; export const openFeatureIntegration = defineIntegration(() => { return { From 1e1d734c3cbba2cb5a64c12f97a251a88a0ed208 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 19 Nov 2024 15:43:43 -0500 Subject: [PATCH 08/11] update tests to use `shouldSkipFeatureFlagsTest` and `getLocalTestUrl` --- .../featureFlags/openfeature/basic/test.ts | 8 ++++---- .../featureFlags/openfeature/withScope/test.ts | 8 ++++---- .../browser-integration-tests/utils/helpers.ts | 12 ------------ 3 files changed, 8 insertions(+), 20 deletions(-) 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 4b31f59d8ab9..9a4e360a3b06 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 @@ -2,12 +2,12 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipOpenFeatureTest, waitForErrorRequest } from '../../../../../utils/helpers'; +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 ({ getLocalTestPath, page }) => { - if (shouldSkipOpenFeatureTest()) { +sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { sentryTest.skip(); } @@ -19,7 +19,7 @@ sentryTest('Basic test with eviction, update, and no async tasks', async ({ getL }); }); - const url = await getLocalTestPath({ testDir: __dirname, skipDsnRouteHandler: true }); + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); await page.goto(url); await page.waitForFunction(bufferSize => { 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 index f895a0307937..8abb68559b6d 100644 --- 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 @@ -2,12 +2,12 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipOpenFeatureTest, waitForErrorRequest } from '../../../../../utils/helpers'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; import type { Scope } from '@sentry/browser'; -sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestPath, page }) => { - if (shouldSkipOpenFeatureTest()) { +sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { sentryTest.skip(); } @@ -19,7 +19,7 @@ sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ }); }); - const url = await getLocalTestPath({ testDir: __dirname, skipDsnRouteHandler: true }); + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); await page.goto(url); const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags && event.tags.isForked === true); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index d635576a7735..b77db038e020 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -285,18 +285,6 @@ export function shouldSkipFeatureFlagsTest(): boolean { return bundle != null && !bundle.includes('esm') && !bundle.includes('cjs'); } -/** - * We can only test the openFeature browser integration in certain bundles/packages: - * - NPM (ESM, CJS) - * - Not CDNs. - * - * @returns `true` if we should skip the openFeature test - */ -export function shouldSkipOpenFeatureTest(): 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 From 06e7fbb1be91703903819fc43a27ceadefe5f89a Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 19 Nov 2024 15:48:39 -0500 Subject: [PATCH 09/11] ref; use featureFlags util functions --- .../featureFlags/launchdarkly/integration.ts | 4 --- .../featureFlags/openfeature/integration.ts | 32 ++++--------------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index 4495f87ad649..0fc6f1085db7 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -2,11 +2,7 @@ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types'; import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; import { defineIntegration } from '@sentry/core'; -<<<<<<< HEAD import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; -======= -import { copyFlagsFromScopeToEvent, insertToFlagBuffer } from '../../../utils/featureFlags'; ->>>>>>> 0f32cb567 (ref(flags): Refactor LaunchDarkly integration to reusable functions) /** * Sentry integration for capturing feature flags from LaunchDarkly. diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index 736cd40f7153..2fea43f4acfc 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -6,25 +6,17 @@ * - OpenFeature.getClient().addHooks(new OpenFeatureIntegrationHook()); */ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types'; -import type { EvaluationDetails, FlagValue, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types'; +import type { EvaluationDetails, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types'; -import { defineIntegration, getCurrentScope } from '@sentry/core'; -import { insertToFlagBuffer } from '../../../utils/featureFlags'; +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 { - const scope = getCurrentScope(); - const flagContext = scope.getScopeData().contexts.flags; - const flagBuffer = flagContext ? flagContext.values : []; - - if (event.contexts === undefined) { - event.contexts = {}; - } - event.contexts.flags = { values: [...flagBuffer] }; - return event; + return copyFlagsFromScopeToEvent(event); }, }; }) satisfies IntegrationFn; @@ -37,25 +29,13 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook { * Successful evaluation result. */ public after(_hookContext: Readonly>, evaluationDetails: EvaluationDetails): void { - processEvent(evaluationDetails.flagKey, evaluationDetails.value); + insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value); } /** * On error evaluation result. */ public error(hookContext: Readonly>, _error: unknown, _hookHints?: HookHints): void { - processEvent(hookContext.flagKey, hookContext.defaultValue); - } -} - -function processEvent(key: string, value: FlagValue): void { - if (typeof value === 'boolean') { - const scopeContexts = getCurrentScope().getScopeData().contexts; - if (!scopeContexts.flags) { - scopeContexts.flags = { values: [] }; - } - const flagBuffer = scopeContexts.flags.values; - insertToFlagBuffer(flagBuffer, key, value); + insertFlagToScope(hookContext.flagKey, hookContext.defaultValue); } - return; } From a47d3371abe98e0148f82a4ce830e3af88b83d19 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 19 Nov 2024 17:01:07 -0500 Subject: [PATCH 10/11] export copyFlags/insertFlagToScope --- packages/browser/src/index.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index e9d336dc8682..850bd71b78a0 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, @@ -77,5 +68,6 @@ export type { Span } from '@sentry/types'; export { makeBrowserOfflineTransport } from './transports/offline'; export { browserProfilingIntegration } from './profiling/integration'; export { spotlightBrowserIntegration } from './integrations/spotlight'; +export { copyFlagsFromScopeToEvent, insertFlagToScope } from './utils/featureFlags'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly'; export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature'; From afd4f7826ed0bab4235fe1d2e89a0c1299c4ff2a Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 20 Nov 2024 11:33:23 -0500 Subject: [PATCH 11/11] add error hook test --- .../featureFlags/openfeature/basic/test.ts | 3 +- .../openfeature/errorHook/init.js | 20 ++++++++ .../openfeature/errorHook/test.ts | 49 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/test.ts 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 9a4e360a3b06..a3de589677ea 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 @@ -22,14 +22,13 @@ sentryTest('Basic test with eviction, update, and no async tasks', async ({ getL const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); await page.goto(url); - await page.waitForFunction(bufferSize => { + 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 - return true; }, FLAG_BUFFER_SIZE); const reqPromise = waitForErrorRequest(page); 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); +});