From 3f840b4ab2f8527701f730874456907a27123cba Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:36:03 -0400 Subject: [PATCH 01/29] Buffer with global weakmap and save on spanEnd --- .../test/integration/transactions.test.ts | 76 +++++++++---------- .../featureFlags/featureFlagsIntegration.ts | 16 +++- .../featureFlags/launchdarkly/integration.ts | 16 +++- .../featureFlags/openfeature/integration.ts | 17 ++++- .../featureFlags/statsig/integration.ts | 22 ++++-- .../featureFlags/unleash/integration.ts | 20 ++++- packages/browser/src/utils/featureFlags.ts | 51 ++++++++++++- packages/core/src/utils-hoist/worldwide.ts | 6 ++ 8 files changed, 168 insertions(+), 56 deletions(-) diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts index 3bdf6c113555..0bbb77296a58 100644 --- a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts +++ b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts @@ -548,57 +548,57 @@ describe('Integration | Transactions', () => { expect(finishedSpans.length).toBe(0); }); -it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { - const timeout = 5 * 60 * 1000; - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now); + it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { + const timeout = 5 * 60 * 1000; + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); - const logs: unknown[] = []; - vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); - const transactions: Event[] = []; + const transactions: Event[] = []; - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction: event => { - transactions.push(event); - return null; - }, - }); + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); - const provider = getProvider(); - const spanProcessor = getSpanProcessor(); + const provider = getProvider(); + const spanProcessor = getSpanProcessor(); - const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; - if (!exporter) { - throw new Error('No exporter found, aborting test...'); - } + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } - startSpanManual({ name: 'test name' }, async span => { - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); + startSpanManual({ name: 'test name' }, async span => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); - const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); + const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); - span.end(); + span.end(); - setTimeout(() => { - subSpan2.end(); - }, timeout - 2); - }); + setTimeout(() => { + subSpan2.end(); + }, timeout - 2); + }); - vi.advanceTimersByTime(timeout - 1); + vi.advanceTimersByTime(timeout - 1); - expect(transactions).toHaveLength(2); - expect(transactions[0]?.spans).toHaveLength(1); + expect(transactions).toHaveLength(2); + expect(transactions[0]?.spans).toHaveLength(1); - const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => - bucket ? Array.from(bucket.spans) : [], - ); - expect(finishedSpans.length).toBe(0); -}); + const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => + bucket ? Array.from(bucket.spans) : [], + ); + expect(finishedSpans.length).toBe(0); + }); it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => { const timeout = 5 * 60 * 1000; diff --git a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts index 54b5680cccd1..aa569380d79f 100644 --- a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts +++ b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -1,6 +1,11 @@ -import type { Client, Event, EventHint, Integration, IntegrationFn } from '@sentry/core'; +import type { Client, Event, EventHint, Integration, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../utils/featureFlags'; +import { + bufferSpanFeatureFlag, + copyFlagsFromScopeToEvent, + freezeSpanFeatureFlags, + insertFlagToScope, +} from '../../utils/featureFlags'; export interface FeatureFlagsIntegration extends Integration { addFeatureFlag: (name: string, value: unknown) => void; @@ -35,12 +40,19 @@ export const featureFlagsIntegration = defineIntegration(() => { return { name: 'FeatureFlags', + setup(client: Client) { + client.on('spanEnd', (span: Span) => { + freezeSpanFeatureFlags(span); + }); + }, + processEvent(event: Event, _hint: EventHint, _client: Client): Event { return copyFlagsFromScopeToEvent(event); }, addFeatureFlag(name: string, value: unknown): void { insertFlagToScope(name, value); + bufferSpanFeatureFlag(name, value); }, }; }) as IntegrationFn; diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index f96b8deb8fa0..eef12f76d634 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -1,6 +1,11 @@ -import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; +import { + bufferSpanFeatureFlag, + copyFlagsFromScopeToEvent, + freezeSpanFeatureFlags, + insertFlagToScope, +} from '../../../utils/featureFlags'; import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; /** @@ -22,6 +27,12 @@ export const launchDarklyIntegration = defineIntegration(() => { return { name: 'LaunchDarkly', + setup(client: Client) { + client.on('spanEnd', (span: Span) => { + freezeSpanFeatureFlags(span); + }); + }, + processEvent(event: Event, _hint: EventHint, _client: Client): Event { return copyFlagsFromScopeToEvent(event); }, @@ -46,6 +57,7 @@ export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler */ method: (flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext) => { insertFlagToScope(flagKey, flagDetail.value); + bufferSpanFeatureFlag(flagKey, flagDetail.value); }, }; } diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index b1963e9964e6..3c5264dcc019 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -5,15 +5,26 @@ * Add the integration hook to your OpenFeature object. * - OpenFeature.getClient().addHooks(new OpenFeatureIntegrationHook()); */ -import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; +import { + bufferSpanFeatureFlag, + copyFlagsFromScopeToEvent, + freezeSpanFeatureFlags, + insertFlagToScope, +} from '../../../utils/featureFlags'; import type { EvaluationDetails, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types'; export const openFeatureIntegration = defineIntegration(() => { return { name: 'OpenFeature', + setup(client: Client) { + client.on('spanEnd', (span: Span) => { + freezeSpanFeatureFlags(span); + }); + }, + processEvent(event: Event, _hint: EventHint, _client: Client): Event { return copyFlagsFromScopeToEvent(event); }, @@ -29,6 +40,7 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook { */ public after(_hookContext: Readonly>, evaluationDetails: EvaluationDetails): void { insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value); + bufferSpanFeatureFlag(evaluationDetails.flagKey, evaluationDetails.value); } /** @@ -36,5 +48,6 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook { */ public error(hookContext: Readonly>, _error: unknown, _hookHints?: HookHints): void { insertFlagToScope(hookContext.flagKey, hookContext.defaultValue); + bufferSpanFeatureFlag(hookContext.flagKey, hookContext.defaultValue); } } diff --git a/packages/browser/src/integrations/featureFlags/statsig/integration.ts b/packages/browser/src/integrations/featureFlags/statsig/integration.ts index 54600458cfb9..641e7412e6a8 100644 --- a/packages/browser/src/integrations/featureFlags/statsig/integration.ts +++ b/packages/browser/src/integrations/featureFlags/statsig/integration.ts @@ -1,6 +1,11 @@ -import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; +import { + bufferSpanFeatureFlag, + copyFlagsFromScopeToEvent, + freezeSpanFeatureFlags, + insertFlagToScope, +} from '../../../utils/featureFlags'; import type { FeatureGate, StatsigClient } from './types'; /** @@ -31,15 +36,20 @@ export const statsigIntegration = defineIntegration( return { name: 'Statsig', - processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return copyFlagsFromScopeToEvent(event); - }, + setup(client: Client) { + client.on('spanEnd', (span: Span) => { + freezeSpanFeatureFlags(span); + }); - setup() { statsigClient.on('gate_evaluation', (event: { gate: FeatureGate }) => { insertFlagToScope(event.gate.name, event.gate.value); + bufferSpanFeatureFlag(event.gate.name, event.gate.value); }); }, + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return copyFlagsFromScopeToEvent(event); + }, }; }, ) satisfies IntegrationFn; diff --git a/packages/browser/src/integrations/featureFlags/unleash/integration.ts b/packages/browser/src/integrations/featureFlags/unleash/integration.ts index 21d945dfcaae..75559dd3841b 100644 --- a/packages/browser/src/integrations/featureFlags/unleash/integration.ts +++ b/packages/browser/src/integrations/featureFlags/unleash/integration.ts @@ -1,7 +1,12 @@ -import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration, fill, logger } from '@sentry/core'; import { DEBUG_BUILD } from '../../../debug-build'; -import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; +import { + bufferSpanFeatureFlag, + copyFlagsFromScopeToEvent, + freezeSpanFeatureFlags, + insertFlagToScope, +} from '../../../utils/featureFlags'; import type { UnleashClient, UnleashClientClass } from './types'; type UnleashIntegrationOptions = { @@ -35,14 +40,20 @@ export const unleashIntegration = defineIntegration( return { name: 'Unleash', - processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return copyFlagsFromScopeToEvent(event); + setup(client: Client) { + client.on('spanEnd', (span: Span) => { + freezeSpanFeatureFlags(span); + }); }, setupOnce() { const unleashClientPrototype = unleashClientClass.prototype as UnleashClient; fill(unleashClientPrototype, 'isEnabled', _wrappedIsEnabled); }, + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return copyFlagsFromScopeToEvent(event); + }, }; }, ) satisfies IntegrationFn; @@ -65,6 +76,7 @@ function _wrappedIsEnabled( if (typeof toggleName === 'string' && typeof result === 'boolean') { insertFlagToScope(toggleName, result); + bufferSpanFeatureFlag(toggleName, result); } else if (DEBUG_BUILD) { logger.error( `[Feature Flags] UnleashClient.isEnabled does not match expected signature. arg0: ${toggleName} (${typeof toggleName}), result: ${result} (${typeof result})`, diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index a71e7233fe75..ff85e160c8b0 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -1,5 +1,5 @@ -import type { Event, FeatureFlag } from '@sentry/core'; -import { getCurrentScope, logger } from '@sentry/core'; +import type { Event, FeatureFlag, Span } from '@sentry/core'; +import { getActiveSpan, getCurrentScope, GLOBAL_OBJ, logger } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; /** @@ -13,6 +13,13 @@ import { DEBUG_BUILD } from '../debug-build'; */ export const FLAG_BUFFER_SIZE = 100; +/** + * Max number of flag evaluations to record per span. + */ +export const MAX_FLAGS_PER_SPAN = 10; + +GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap(); + /** * Copies feature flags that are in current scope context to the event context */ @@ -87,3 +94,43 @@ export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: un result: value, }); } + +/** + * Records a feature flag evaluation for the active span, adding it to a weak map of flag buffers. This is a no-op for non-boolean values. + * The keys in each buffer are unique. Once the buffer for a span reaches maxFlagsPerSpan, subsequent flags are dropped. + * + * @param name Name of the feature flag. + * @param value Value of the feature flag. Non-boolean values are ignored. + * @param maxFlagsPerSpan Max number of flags a buffer should store. Default value should always be used in production. + */ +export function bufferSpanFeatureFlag( + name: string, + value: unknown, + maxFlagsPerSpan: number = MAX_FLAGS_PER_SPAN, +): void { + const spanFlagMap = GLOBAL_OBJ._spanToFlagBufferMap; + if (!spanFlagMap || typeof value !== 'boolean') { + return; + } + + const span = getActiveSpan(); + if (span) { + const flags = spanFlagMap.get(span) || []; + if (!flags.find(flag => flag.flag === name) && flags.length < maxFlagsPerSpan) { + flags.push({ flag: name, result: value }); + } + spanFlagMap.set(span, flags); + } +} + +/** + * Add the buffered feature flags for a span to the span attributes. Call this on span end. + * + * @param span Span to add flags to. + */ +export function freezeSpanFeatureFlags(span: Span): void { + const flags = GLOBAL_OBJ._spanToFlagBufferMap?.get(span); + if (flags) { + span.setAttributes(Object.fromEntries(flags.map(flag => [`flag.evaluation.${flag.flag}`, flag.result]))); + } +} diff --git a/packages/core/src/utils-hoist/worldwide.ts b/packages/core/src/utils-hoist/worldwide.ts index 3a396d96f809..69e64c7ac98d 100644 --- a/packages/core/src/utils-hoist/worldwide.ts +++ b/packages/core/src/utils-hoist/worldwide.ts @@ -14,7 +14,9 @@ import type { Carrier } from '../carrier'; import type { Client } from '../client'; +import type { FeatureFlag } from '../featureFlags'; import type { SerializedLog } from '../types-hoist/log'; +import type { Span } from '../types-hoist/span'; import type { SdkSource } from './env'; /** Internal global with common properties and Sentry extensions */ @@ -56,6 +58,10 @@ export type InternalGlobal = { */ _sentryModuleMetadata?: Record; _sentryEsmLoaderHookRegistered?: boolean; + /** + * A map of spans to feature flag buffers. Populated by feature flag integrations. + */ + _spanToFlagBufferMap?: WeakMap; } & Carrier; /** Get's the global object for the current JavaScript runtime */ From af6fa724288775080192d87482798f42e4ba8be6 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:45:06 -0400 Subject: [PATCH 02/29] Update docstrs --- .../featureFlags/featureFlagsIntegration.ts | 5 ++--- .../featureFlags/launchdarkly/integration.ts | 10 +++++----- .../featureFlags/openfeature/integration.ts | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts index aa569380d79f..549c99af2c13 100644 --- a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts +++ b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -12,9 +12,8 @@ export interface FeatureFlagsIntegration extends Integration { } /** - * 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. + * Sentry integration for buffering feature flag evaluations manually with an API, and + * capturing them on error events and spans. * * See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information. * diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index eef12f76d634..caa860574ce4 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -9,7 +9,7 @@ import { import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; /** - * Sentry integration for capturing feature flags from LaunchDarkly. + * Sentry integration for capturing feature flag evaluations from LaunchDarkly. * * See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information. * @@ -40,10 +40,10 @@ export const launchDarklyIntegration = defineIntegration(() => { }) 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. + * LaunchDarkly hook to listen for and buffer flag evaluations. This needs to + * be registered as an 'inspector' in LaunchDarkly initialize() options, + * separately from `launchDarklyIntegration`. Both the hook and the integration + * are needed to capture LaunchDarkly flags. */ export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler { return { diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index 3c5264dcc019..bd5efd1e282f 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -1,9 +1,17 @@ /** - * OpenFeature integration. + * Sentry integration for capturing OpenFeature feature flag evaluations. * - * Add the openFeatureIntegration() function call to your integration lists. - * Add the integration hook to your OpenFeature object. - * - OpenFeature.getClient().addHooks(new OpenFeatureIntegrationHook()); + * 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 { OpenFeature } from "@openfeature/web-sdk"; + * + * Sentry.init(..., integrations: [Sentry.openFeatureIntegration()]); + * OpenFeature.setProvider(new MyProviderOfChoice()); + * OpenFeature.addHooks(new Sentry.OpenFeatureIntegrationHook()); + * ``` */ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; From 5453c1f6cfe06359ae02ac34516cd9a104aba0a8 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:57:10 -0400 Subject: [PATCH 03/29] Attribute prefix const --- packages/browser/src/utils/featureFlags.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index ff85e160c8b0..9ee5fd793a91 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -18,8 +18,11 @@ export const FLAG_BUFFER_SIZE = 100; */ export const MAX_FLAGS_PER_SPAN = 10; +// Global map of spans to feature flag buffers. Populated by feature flag integrations. GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap(); +const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.'; + /** * Copies feature flags that are in current scope context to the event context */ @@ -131,6 +134,6 @@ export function bufferSpanFeatureFlag( export function freezeSpanFeatureFlags(span: Span): void { const flags = GLOBAL_OBJ._spanToFlagBufferMap?.get(span); if (flags) { - span.setAttributes(Object.fromEntries(flags.map(flag => [`flag.evaluation.${flag.flag}`, flag.result]))); + span.setAttributes(Object.fromEntries(flags.map(flag => [`${SPAN_FLAG_ATTRIBUTE_PREFIX}${flag.flag}`, flag.result]))); } } From bc183e69920252e1154a4e1442ea6c10a5faff05 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:01:47 -0400 Subject: [PATCH 04/29] Handle dup evals of same flag --- packages/browser/src/utils/featureFlags.ts | 17 +++++++++-------- packages/core/src/utils-hoist/worldwide.ts | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index 9ee5fd793a91..6baf05f92b72 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -53,8 +53,7 @@ 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 FLAG_BUFFER_SIZE + * @param maxSize Max number of flags the buffer should store. Default value should always be used in production. */ export function insertFlagToScope(name: string, value: unknown, maxSize: number = FLAG_BUFFER_SIZE): void { const scopeContexts = getCurrentScope().getScopeData().contexts; @@ -68,7 +67,7 @@ export function insertFlagToScope(name: string, value: unknown, maxSize: number /** * Exported for tests. Currently only accepts boolean values (otherwise no-op). */ -export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: unknown, maxSize: number): void { +export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: unknown, maxSize: number, allowEviction: boolean = true): void { if (typeof value !== 'boolean') { return; } @@ -87,8 +86,12 @@ export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: un } if (flags.length === maxSize) { - // If at capacity, pop the earliest flag - O(n) - flags.shift(); + if (allowEviction) { + // If at capacity, pop the earliest flag - O(n) + flags.shift(); + } else { + return; + } } // Push the flag to the end - O(1) @@ -119,9 +122,7 @@ export function bufferSpanFeatureFlag( const span = getActiveSpan(); if (span) { const flags = spanFlagMap.get(span) || []; - if (!flags.find(flag => flag.flag === name) && flags.length < maxFlagsPerSpan) { - flags.push({ flag: name, result: value }); - } + insertToFlagBuffer(flags, name, value, maxFlagsPerSpan, false); spanFlagMap.set(span, flags); } } diff --git a/packages/core/src/utils-hoist/worldwide.ts b/packages/core/src/utils-hoist/worldwide.ts index 69e64c7ac98d..2b0624bbd303 100644 --- a/packages/core/src/utils-hoist/worldwide.ts +++ b/packages/core/src/utils-hoist/worldwide.ts @@ -61,7 +61,7 @@ export type InternalGlobal = { /** * A map of spans to feature flag buffers. Populated by feature flag integrations. */ - _spanToFlagBufferMap?: WeakMap; + _spanToFlagBufferMap?: WeakMap>; } & Carrier; /** Get's the global object for the current JavaScript runtime */ From bc439c86d5a1b4fa5bde0b381d4d3f54fcfc60fd Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:52:10 -0400 Subject: [PATCH 05/29] Update docstrs. Todo: update util unit tests --- packages/browser/src/utils/featureFlags.ts | 26 ++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index 6baf05f92b72..d4b591849599 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -43,18 +43,16 @@ export function copyFlagsFromScopeToEvent(event: Event): 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. + * Inserts a flag into the current scope's context while maintaining ordered LRU properties. + * Not thread-safe. After inserting: + * - The flag buffer is sorted in order of recency, with the newest evaluation at the end. + * - The names in the buffer are always unique. + * - The length of the buffer never exceeds `maxSize`. * * @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. Default value should always be used in production. - */ +*/ export function insertFlagToScope(name: string, value: unknown, maxSize: number = FLAG_BUFFER_SIZE): void { const scopeContexts = getCurrentScope().getScopeData().contexts; if (!scopeContexts.flags) { @@ -65,7 +63,17 @@ export function insertFlagToScope(name: string, value: unknown, maxSize: number } /** - * Exported for tests. Currently only accepts boolean values (otherwise no-op). + * Exported for tests only. Currently only accepts boolean values (otherwise no-op). + * Inserts a flag into a FeatureFlag array while maintaining the following properties: + * - Flags are sorted in order of recency, with the newest evaluation at the end. + * - The flag names are always unique. + * - The length of the array never exceeds `maxSize`. + * + * @param flags The buffer to insert the flag 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. Default value should always be used in production. + * @param allowEviction If true, the oldest flag is evicted when the buffer is full. Otherwise the new flag is dropped. */ export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: unknown, maxSize: number, allowEviction: boolean = true): void { if (typeof value !== 'boolean') { From 403a02bb5043bc862c95558a074e28db6c6fb154 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:32:08 -0400 Subject: [PATCH 06/29] Nest existing tests under onError folders --- .../featureFlags/featureFlags/{ => onError}/basic/test.ts | 6 +++--- .../featureFlags/featureFlags/{ => onError}/init.js | 0 .../featureFlags/featureFlags/{ => onError}/subject.js | 0 .../featureFlags/featureFlags/{ => onError}/template.html | 0 .../featureFlags/{ => onError}/withScope/test.ts | 4 ++-- .../featureFlags/launchdarkly/{ => onError}/basic/test.ts | 6 +++--- .../featureFlags/launchdarkly/{ => onError}/init.js | 0 .../featureFlags/launchdarkly/{ => onError}/subject.js | 0 .../featureFlags/launchdarkly/{ => onError}/template.html | 0 .../launchdarkly/{ => onError}/withScope/test.ts | 4 ++-- .../featureFlags/openfeature/{ => onError}/basic/test.ts | 6 +++--- .../openfeature/{ => onError}/errorHook/init.js | 0 .../openfeature/{ => onError}/errorHook/test.ts | 6 +++--- .../featureFlags/openfeature/{ => onError}/init.js | 0 .../featureFlags/openfeature/{ => onError}/subject.js | 0 .../featureFlags/openfeature/{ => onError}/template.html | 0 .../openfeature/{ => onError}/withScope/test.ts | 4 ++-- .../featureFlags/statsig/{ => onError}/basic/test.ts | 0 .../integrations/featureFlags/statsig/{ => onError}/init.js | 0 .../featureFlags/statsig/{ => onError}/subject.js | 0 .../featureFlags/statsig/{ => onError}/template.html | 0 .../featureFlags/statsig/{ => onError}/withScope/test.ts | 0 .../featureFlags/unleash/{ => onError}/basic/test.ts | 0 .../integrations/featureFlags/unleash/{ => onError}/init.js | 0 .../featureFlags/unleash/{ => onError}/subject.js | 0 .../featureFlags/unleash/{ => onError}/template.html | 0 .../featureFlags/unleash/{ => onError}/withScope/test.ts | 0 27 files changed, 18 insertions(+), 18 deletions(-) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/{ => onError}/basic/test.ts (90%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/{ => onError}/init.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/{ => onError}/subject.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/{ => onError}/template.html (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/{ => onError}/withScope/test.ts (94%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/{ => onError}/basic/test.ts (89%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/{ => onError}/init.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/{ => onError}/subject.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/{ => onError}/template.html (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/{ => onError}/withScope/test.ts (94%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/{ => onError}/basic/test.ts (89%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/{ => onError}/errorHook/init.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/{ => onError}/errorHook/test.ts (89%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/{ => onError}/init.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/{ => onError}/subject.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/{ => onError}/template.html (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/{ => onError}/withScope/test.ts (94%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/{ => onError}/basic/test.ts (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/{ => onError}/init.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/{ => onError}/subject.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/{ => onError}/template.html (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/{ => onError}/withScope/test.ts (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/{ => onError}/basic/test.ts (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/{ => onError}/init.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/{ => onError}/subject.js (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/{ => onError}/template.html (100%) rename dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/{ => onError}/withScope/test.ts (100%) 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/onError/basic/test.ts similarity index 90% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/basic/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts index b63583906cc4..cd9e18606ff8 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../constants'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/init.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/init.js diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/subject.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/subject.js diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/template.html rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/template.html 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/onError/withScope/test.ts similarity index 94% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/withScope/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/withScope/test.ts index 41418122b526..c7d1e714731e 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/withScope/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import type { Scope } from '@sentry/browser'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts similarity index 89% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/basic/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts index 5d7f58bdb27b..a02cff1b1f17 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../constants'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/init.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/init.js diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/subject.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/subject.js diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/template.html rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/template.html 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/onError/withScope/test.ts similarity index 94% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/withScope/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/withScope/test.ts index 78703e4e5389..e26c74e67f28 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/withScope/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import type { Scope } from '@sentry/browser'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts similarity index 89% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts index 77112ee82658..5858c6a44c0c 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../constants'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { 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/onError/errorHook/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/init.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/init.js 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/onError/errorHook/test.ts similarity index 89% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/test.ts index d8f1e1311dfa..cfba65eb371a 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/errorHook/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../constants'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Flag evaluation error hook', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/init.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/init.js diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/subject.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/subject.js diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/template.html rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/template.html 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/onError/withScope/test.ts similarity index 94% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/withScope/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/withScope/test.ts index 67e68becb104..b6540eb7e901 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/withScope/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import type { Scope } from '@sentry/browser'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/basic/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/init.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/init.js diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/subject.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/subject.js diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/template.html rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/template.html diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/withScope/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/withScope/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/withScope/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/basic/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/init.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/init.js diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/subject.js rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/subject.js diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/template.html rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/template.html diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/withScope/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/withScope/test.ts rename to dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/withScope/test.ts From ffe9bcdba07c68a393a02cdb001e488679d56338 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:36:52 -0400 Subject: [PATCH 07/29] Fix imports --- .../integrations/featureFlags/statsig/onError/basic/test.ts | 6 +++--- .../featureFlags/statsig/onError/withScope/test.ts | 4 ++-- .../integrations/featureFlags/unleash/onError/basic/test.ts | 6 +++--- .../featureFlags/unleash/onError/withScope/test.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts index cb434e49e86e..5ecf56c98ef8 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../constants'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/withScope/test.ts index 42ee35e4604d..b63a21db894a 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/withScope/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import type { Scope } from '@sentry/browser'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts index b2e522fc78f4..8c2e698ed79f 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../constants'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/withScope/test.ts index a512882b568a..73496719cf90 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/withScope/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import type { Scope } from '@sentry/browser'; -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { From 7c2c1616bc78fd24078c3edc6d8b6358b36a22d0 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 6 Jun 2025 12:49:30 -0400 Subject: [PATCH 08/29] Fix global type, add unit tests, add generic ffs test --- .../integrations/featureFlags/constants.ts | 4 +- .../featureFlags/onError/basic/test.ts | 6 +- .../featureFlags/onError/withScope/test.ts | 6 +- .../featureFlags/featureFlags/onSpan/init.js | 13 ++++ .../featureFlags/onSpan/subject.js | 28 ++++++++ .../featureFlags/onSpan/template.html | 12 ++++ .../featureFlags/featureFlags/onSpan/test.ts | 68 +++++++++++++++++++ .../launchdarkly/onError/basic/test.ts | 6 +- .../launchdarkly/onError/withScope/test.ts | 6 +- .../openfeature/onError/basic/test.ts | 6 +- .../openfeature/onError/errorHook/test.ts | 6 +- .../openfeature/onError/withScope/test.ts | 6 +- .../statsig/onError/basic/test.ts | 6 +- .../statsig/onError/withScope/test.ts | 6 +- .../unleash/onError/basic/test.ts | 6 +- .../unleash/onError/withScope/test.ts | 6 +- packages/browser/src/utils/featureFlags.ts | 15 +++- .../browser/test/utils/featureFlags.test.ts | 19 ++++++ packages/core/src/utils-hoist/worldwide.ts | 2 +- 19 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts index 680105d242e5..ba3c35a08241 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts @@ -1 +1,3 @@ -export const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils. +// Corresponds to constants in featureFlags.ts, in browser utils. +export const FLAG_BUFFER_SIZE = 100; +export const MAX_FLAGS_PER_SPAN = 10; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts index cd9e18606ff8..742cdd42109b 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts @@ -1,6 +1,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/withScope/test.ts index c7d1e714731e..fecc762d4c99 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/withScope/test.ts @@ -1,7 +1,11 @@ import { expect } from '@playwright/test'; import type { Scope } from '@sentry/browser'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/init.js new file mode 100644 index 000000000000..fa6a67ec3711 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + tracesSampleRate: 1.0, + integrations: [ + Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }), + Sentry.featureFlagsIntegration(), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/subject.js new file mode 100644 index 000000000000..4c04fd07f314 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/subject.js @@ -0,0 +1,28 @@ +const btnStartSpan = document.getElementById('btnStartSpan'); +const btnEndSpan = document.getElementById('btnEndSpan'); +const btnStartNestedSpan = document.getElementById('btnStartNestedSpan'); +const btnEndNestedSpan = document.getElementById('btnEndNestedSpan'); + +window.withNestedSpans = callback => { + window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => { + window.traceId = rootSpan.spanContext().traceId; + + window.Sentry.startSpan({ name: 'test-span' }, _span => { + window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => { + callback(); + }); + }); + }); +}; + +// btnStartNestedSpan.addEventListener('click', () => { +// Sentry.startSpanManual( +// { name: 'test-nested-span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, +// async span => { +// await new Promise(resolve => { +// btnEndNestedSpan.addEventListener('click', resolve); +// }); +// span.end(); +// }, +// ); +// }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/template.html new file mode 100644 index 000000000000..59340f55667c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts new file mode 100644 index 000000000000..560570a6fed4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts @@ -0,0 +1,68 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { + type EventAndTraceHeader, + eventAndTraceHeaderRequestParser, + getMultipleSentryEnvelopeRequests, + shouldSkipFeatureFlagsTest, + shouldSkipTracingTest, +} from '../../../../../utils/helpers'; +import { MAX_FLAGS_PER_SPAN } from '../../constants'; + +sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const envelopeRequestPromise = getMultipleSentryEnvelopeRequests( + page, + 1, + {}, // envelopeType: 'transaction' }, + eventAndTraceHeaderRequestParser, // properFullEnvelopeRequestParser + ); + + // withNestedSpans is a util used to start 3 nested spans: root-span (not recorded in transaction_event.spans), span, and nested-span. + await page.evaluate(maxFlags => { + (window as any).withNestedSpans(() => { + const flagsIntegration = (window as any).Sentry.getClient().getIntegrationByName('FeatureFlags'); + for (let i = 1; i <= maxFlags; i++) { + flagsIntegration.addFeatureFlag(`feat${i}`, false); + } + flagsIntegration.addFeatureFlag(`feat${maxFlags + 1}`, true); // dropped flag + flagsIntegration.addFeatureFlag('feat3', true); // update + }); + return true; + }, MAX_FLAGS_PER_SPAN); + const event = (await envelopeRequestPromise)[0][0]; + const innerSpan = event.spans?.[0]; + const outerSpan = event.spans?.[1]; + const outerSpanFlags = Object.entries(outerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + const innerSpanFlags = Object.entries(innerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + + expect(innerSpanFlags).toEqual([]); + + const expectedOuterSpanFlags = []; + for (let i = 1; i <= 2; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + } + for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + } + expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); + expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts index a02cff1b1f17..1c1a04595187 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts @@ -1,6 +1,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/withScope/test.ts index e26c74e67f28..2efb3fdc9ad0 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/withScope/test.ts @@ -1,7 +1,11 @@ import { expect } from '@playwright/test'; import type { Scope } from '@sentry/browser'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts index 5858c6a44c0c..84deca47415d 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts @@ -1,6 +1,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/test.ts index cfba65eb371a..c2de7f54abd7 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/test.ts @@ -1,6 +1,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Flag evaluation error hook', async ({ getLocalTestUrl, page }) => { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/withScope/test.ts index b6540eb7e901..14cc072af30d 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/withScope/test.ts @@ -1,7 +1,11 @@ import { expect } from '@playwright/test'; import type { Scope } from '@sentry/browser'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts index 5ecf56c98ef8..331dbb8ad433 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts @@ -1,6 +1,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/withScope/test.ts index b63a21db894a..e80c6dbfc5fa 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/withScope/test.ts @@ -1,7 +1,11 @@ import { expect } from '@playwright/test'; import type { Scope } from '@sentry/browser'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts index 8c2e698ed79f..341bbbd03e96 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts @@ -1,6 +1,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/withScope/test.ts index 73496719cf90..fe3aec3ff188 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/withScope/test.ts @@ -1,7 +1,11 @@ import { expect } from '@playwright/test'; import type { Scope } from '@sentry/browser'; import { sentryTest } from '../../../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index d4b591849599..ffaadeb15779 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -52,7 +52,7 @@ 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. Default value should always be used in production. -*/ + */ export function insertFlagToScope(name: string, value: unknown, maxSize: number = FLAG_BUFFER_SIZE): void { const scopeContexts = getCurrentScope().getScopeData().contexts; if (!scopeContexts.flags) { @@ -75,7 +75,13 @@ export function insertFlagToScope(name: string, value: unknown, maxSize: number * @param maxSize Max number of flags the buffer should store. Default value should always be used in production. * @param allowEviction If true, the oldest flag is evicted when the buffer is full. Otherwise the new flag is dropped. */ -export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: unknown, maxSize: number, allowEviction: boolean = true): void { +export function insertToFlagBuffer( + flags: FeatureFlag[], + name: string, + value: unknown, + maxSize: number, + allowEviction: boolean = true, +): void { if (typeof value !== 'boolean') { return; } @@ -98,6 +104,7 @@ export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: un // If at capacity, pop the earliest flag - O(n) flags.shift(); } else { + return; } } @@ -143,6 +150,8 @@ export function bufferSpanFeatureFlag( export function freezeSpanFeatureFlags(span: Span): void { const flags = GLOBAL_OBJ._spanToFlagBufferMap?.get(span); if (flags) { - span.setAttributes(Object.fromEntries(flags.map(flag => [`${SPAN_FLAG_ATTRIBUTE_PREFIX}${flag.flag}`, flag.result]))); + span.setAttributes( + Object.fromEntries(flags.map(flag => [`${SPAN_FLAG_ATTRIBUTE_PREFIX}${flag.flag}`, flag.result])), + ); } } diff --git a/packages/browser/test/utils/featureFlags.test.ts b/packages/browser/test/utils/featureFlags.test.ts index 1c0bed312590..e60871832261 100644 --- a/packages/browser/test/utils/featureFlags.test.ts +++ b/packages/browser/test/utils/featureFlags.test.ts @@ -59,6 +59,25 @@ describe('flags', () => { ]); }); + it('drops new entries when allowEviction is false and buffer is full', () => { + const buffer: FeatureFlag[] = []; + const maxSize = 0; + insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); + insertToFlagBuffer(buffer, 'feat2', true, maxSize, false); + insertToFlagBuffer(buffer, 'feat3', true, maxSize, false); + + expect(buffer).toEqual([]); + }); + + it('still updates order and values when allowEviction is false and buffer is full', () => { + const buffer: FeatureFlag[] = []; + const maxSize = 1; + insertToFlagBuffer(buffer, 'feat1', false, maxSize, false); + insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); + + expect(buffer).toEqual([{ flag: 'feat1', result: true }]); + }); + it('does not allocate unnecessary space', () => { const buffer: FeatureFlag[] = []; const maxSize = 1000; diff --git a/packages/core/src/utils-hoist/worldwide.ts b/packages/core/src/utils-hoist/worldwide.ts index 2b0624bbd303..69e64c7ac98d 100644 --- a/packages/core/src/utils-hoist/worldwide.ts +++ b/packages/core/src/utils-hoist/worldwide.ts @@ -61,7 +61,7 @@ export type InternalGlobal = { /** * A map of spans to feature flag buffers. Populated by feature flag integrations. */ - _spanToFlagBufferMap?: WeakMap>; + _spanToFlagBufferMap?: WeakMap; } & Carrier; /** Get's the global object for the current JavaScript runtime */ From e6da588f34821495536de981e9b18f8946df25ff Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 6 Jun 2025 14:24:42 -0400 Subject: [PATCH 09/29] Add ld test --- .../featureFlags/onSpan/subject.js | 12 ---- .../featureFlags/featureFlags/onSpan/test.ts | 5 +- .../featureFlags/launchdarkly/onSpan/init.js | 39 +++++++++++ .../launchdarkly/onSpan/subject.js | 16 +++++ .../launchdarkly/onSpan/template.html | 12 ++++ .../featureFlags/launchdarkly/onSpan/test.ts | 69 +++++++++++++++++++ 6 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/subject.js index 4c04fd07f314..ad874b2bd697 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/subject.js +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/subject.js @@ -14,15 +14,3 @@ window.withNestedSpans = callback => { }); }); }; - -// btnStartNestedSpan.addEventListener('click', () => { -// Sentry.startSpanManual( -// { name: 'test-nested-span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, -// async span => { -// await new Promise(resolve => { -// btnEndNestedSpan.addEventListener('click', resolve); -// }); -// span.end(); -// }, -// ); -// }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts index 560570a6fed4..e1ed9e96b1e5 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts @@ -28,8 +28,8 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a const envelopeRequestPromise = getMultipleSentryEnvelopeRequests( page, 1, - {}, // envelopeType: 'transaction' }, - eventAndTraceHeaderRequestParser, // properFullEnvelopeRequestParser + {}, + eventAndTraceHeaderRequestParser, ); // withNestedSpans is a util used to start 3 nested spans: root-span (not recorded in transaction_event.spans), span, and nested-span. @@ -44,6 +44,7 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a }); return true; }, MAX_FLAGS_PER_SPAN); + const event = (await envelopeRequestPromise)[0][0]; const innerSpan = event.spans?.[0]; const outerSpan = event.spans?.[1]; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/init.js new file mode 100644 index 000000000000..9e4b802f28f3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/init.js @@ -0,0 +1,39 @@ +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, + tracesSampleRate: 1.0, + integrations: [ + Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }), + 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.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/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/subject.js new file mode 100644 index 000000000000..ad874b2bd697 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/subject.js @@ -0,0 +1,16 @@ +const btnStartSpan = document.getElementById('btnStartSpan'); +const btnEndSpan = document.getElementById('btnEndSpan'); +const btnStartNestedSpan = document.getElementById('btnStartNestedSpan'); +const btnEndNestedSpan = document.getElementById('btnEndNestedSpan'); + +window.withNestedSpans = callback => { + window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => { + window.traceId = rootSpan.spanContext().traceId; + + window.Sentry.startSpan({ name: 'test-span' }, _span => { + window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => { + callback(); + }); + }); + }); +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/template.html new file mode 100644 index 000000000000..59340f55667c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts new file mode 100644 index 000000000000..a49191f4d4a3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts @@ -0,0 +1,69 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { + type EventAndTraceHeader, + eventAndTraceHeaderRequestParser, + getMultipleSentryEnvelopeRequests, + shouldSkipFeatureFlagsTest, + shouldSkipTracingTest, +} from '../../../../../utils/helpers'; +import { MAX_FLAGS_PER_SPAN } from '../../constants'; + +sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const envelopeRequestPromise = getMultipleSentryEnvelopeRequests( + page, + 1, + {}, + eventAndTraceHeaderRequestParser, + ); + + // withNestedSpans is a util used to start 3 nested spans: root-span (not recorded in transaction_event.spans), span, and nested-span. + await page.evaluate(maxFlags => { + (window as any).withNestedSpans(() => { + const ldClient = (window as any).initializeLD(); + for (let i = 1; i <= maxFlags; i++) { + ldClient.variation(`feat${i}`, false); + } + ldClient.variation(`feat${maxFlags + 1}`, true); // dropped + ldClient.variation('feat3', true); // update + }); + return true; + }, MAX_FLAGS_PER_SPAN); + + const event = (await envelopeRequestPromise)[0][0]; + const innerSpan = event.spans?.[0]; + const outerSpan = event.spans?.[1]; + const outerSpanFlags = Object.entries(outerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + const innerSpanFlags = Object.entries(innerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + + expect(innerSpanFlags).toEqual([]); + + const expectedOuterSpanFlags = []; + for (let i = 1; i <= 2; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + } + for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + } + expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); + expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); +}); From 2459a11182f12f7076db3b96056b6243f92388ad Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 6 Jun 2025 15:42:17 -0400 Subject: [PATCH 10/29] Add of, stat, unleash tests --- .../featureFlags/openfeature/onSpan/init.js | 25 +++++++ .../openfeature/onSpan/subject.js | 16 ++++ .../openfeature/onSpan/template.html | 12 +++ .../featureFlags/openfeature/onSpan/test.ts | 69 ++++++++++++++++++ .../featureFlags/statsig/onSpan/init.js | 39 ++++++++++ .../featureFlags/statsig/onSpan/subject.js | 16 ++++ .../featureFlags/statsig/onSpan/template.html | 12 +++ .../featureFlags/statsig/onSpan/test.ts | 73 +++++++++++++++++++ .../featureFlags/unleash/onSpan/init.js | 60 +++++++++++++++ .../featureFlags/unleash/onSpan/subject.js | 16 ++++ .../featureFlags/unleash/onSpan/template.html | 12 +++ .../featureFlags/unleash/onSpan/test.ts | 71 ++++++++++++++++++ 12 files changed, 421 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/init.js new file mode 100644 index 000000000000..9de421d19e63 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/init.js @@ -0,0 +1,25 @@ +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, + tracesSampleRate: 1.0, + integrations: [ + window.sentryOpenFeatureIntegration, + Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }), + ], +}); + +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/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/subject.js new file mode 100644 index 000000000000..ad874b2bd697 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/subject.js @@ -0,0 +1,16 @@ +const btnStartSpan = document.getElementById('btnStartSpan'); +const btnEndSpan = document.getElementById('btnEndSpan'); +const btnStartNestedSpan = document.getElementById('btnStartNestedSpan'); +const btnEndNestedSpan = document.getElementById('btnEndNestedSpan'); + +window.withNestedSpans = callback => { + window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => { + window.traceId = rootSpan.spanContext().traceId; + + window.Sentry.startSpan({ name: 'test-span' }, _span => { + window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => { + callback(); + }); + }); + }); +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/template.html new file mode 100644 index 000000000000..59340f55667c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts new file mode 100644 index 000000000000..793b2ed6fffd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts @@ -0,0 +1,69 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { + type EventAndTraceHeader, + eventAndTraceHeaderRequestParser, + getMultipleSentryEnvelopeRequests, + shouldSkipFeatureFlagsTest, + shouldSkipTracingTest, +} from '../../../../../utils/helpers'; +import { MAX_FLAGS_PER_SPAN } from '../../constants'; + +sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const envelopeRequestPromise = getMultipleSentryEnvelopeRequests( + page, + 1, + {}, + eventAndTraceHeaderRequestParser, + ); + + // withNestedSpans is a util used to start 3 nested spans: root-span (not recorded in transaction_event.spans), span, and nested-span. + await page.evaluate(maxFlags => { + (window as any).withNestedSpans(() => { + const client = (window as any).initialize(); + for (let i = 1; i <= maxFlags; i++) { + client.getBooleanValue(`feat${i}`, false); + } + client.getBooleanValue(`feat${maxFlags + 1}`, true); // drop + client.getBooleanValue('feat3', true); // update + }); + return true; + }, MAX_FLAGS_PER_SPAN); + + const event = (await envelopeRequestPromise)[0][0]; + const innerSpan = event.spans?.[0]; + const outerSpan = event.spans?.[1]; + const outerSpanFlags = Object.entries(outerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + const innerSpanFlags = Object.entries(innerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + + expect(innerSpanFlags).toEqual([]); + + const expectedOuterSpanFlags = []; + for (let i = 1; i <= 2; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + } + for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + } + expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); + expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/init.js new file mode 100644 index 000000000000..22f74d2ebd7c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/init.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; + +class MockStatsigClient { + constructor() { + this._gateEvaluationListeners = []; + this._mockGateValues = {}; + } + + on(event, listener) { + this._gateEvaluationListeners.push(listener); + } + + checkGate(name) { + const value = this._mockGateValues[name] || false; // unknown features default to false. + this._gateEvaluationListeners.forEach(listener => { + listener({ gate: { name, value } }); + }); + return value; + } + + setMockGateValue(name, value) { + this._mockGateValues[name] = value; + } +} + +window.statsigClient = new MockStatsigClient(); + +window.Sentry = Sentry; +window.sentryStatsigIntegration = Sentry.statsigIntegration({ featureFlagClient: window.statsigClient }); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + tracesSampleRate: 1.0, + integrations: [ + window.sentryStatsigIntegration, + Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/subject.js new file mode 100644 index 000000000000..ad874b2bd697 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/subject.js @@ -0,0 +1,16 @@ +const btnStartSpan = document.getElementById('btnStartSpan'); +const btnEndSpan = document.getElementById('btnEndSpan'); +const btnStartNestedSpan = document.getElementById('btnStartNestedSpan'); +const btnEndNestedSpan = document.getElementById('btnEndNestedSpan'); + +window.withNestedSpans = callback => { + window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => { + window.traceId = rootSpan.spanContext().traceId; + + window.Sentry.startSpan({ name: 'test-span' }, _span => { + window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => { + callback(); + }); + }); + }); +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/template.html new file mode 100644 index 000000000000..59340f55667c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts new file mode 100644 index 000000000000..384f59620cdf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts @@ -0,0 +1,73 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { + type EventAndTraceHeader, + eventAndTraceHeaderRequestParser, + getMultipleSentryEnvelopeRequests, + shouldSkipFeatureFlagsTest, + shouldSkipTracingTest, +} from '../../../../../utils/helpers'; +import { MAX_FLAGS_PER_SPAN } from '../../constants'; + +sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const envelopeRequestPromise = getMultipleSentryEnvelopeRequests( + page, + 1, + {}, + eventAndTraceHeaderRequestParser, + ); + + // withNestedSpans is a util used to start 3 nested spans: root-span (not recorded in transaction_event.spans), span, and nested-span. + await page.evaluate(maxFlags => { + (window as any).withNestedSpans(() => { + const client = (window as any).statsigClient; + for (let i = 1; i <= maxFlags; i++) { + client.checkGate(`feat${i}`); // values default to false + } + + client.setMockGateValue(`feat${maxFlags + 1}`, true); + client.checkGate(`feat${maxFlags + 1}`); // dropped + + client.setMockGateValue('feat3', true); + client.checkGate('feat3'); // update + }); + return true; + }, MAX_FLAGS_PER_SPAN); + + const event = (await envelopeRequestPromise)[0][0]; + const innerSpan = event.spans?.[0]; + const outerSpan = event.spans?.[1]; + const outerSpanFlags = Object.entries(outerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + const innerSpanFlags = Object.entries(innerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + + expect(innerSpanFlags).toEqual([]); + + const expectedOuterSpanFlags = []; + for (let i = 1; i <= 2; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + } + for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + } + expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); + expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/init.js new file mode 100644 index 000000000000..399ef2fc830a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/init.js @@ -0,0 +1,60 @@ +import * as Sentry from '@sentry/browser'; + +window.UnleashClient = class { + constructor() { + this._featureToVariant = { + strFeat: { name: 'variant1', enabled: true, feature_enabled: true, payload: { type: 'string', value: 'test' } }, + noPayloadFeat: { name: 'eu-west', enabled: true, feature_enabled: true }, + jsonFeat: { + name: 'paid-orgs', + enabled: true, + feature_enabled: true, + payload: { + type: 'json', + value: '{"foo": {"bar": "baz"}, "hello": [1, 2, 3]}', + }, + }, + + // Enabled feature with no configured variants. + noVariantFeat: { name: 'disabled', enabled: false, feature_enabled: true }, + + // Disabled feature. + disabledFeat: { name: 'disabled', enabled: false, feature_enabled: false }, + }; + + // Variant returned for features that don't exist. + // `feature_enabled` may be defined in prod, but we want to test the undefined case. + this._fallbackVariant = { + name: 'disabled', + enabled: false, + }; + } + + isEnabled(toggleName) { + const variant = this._featureToVariant[toggleName] || this._fallbackVariant; + return variant.feature_enabled || false; + } + + getVariant(toggleName) { + return this._featureToVariant[toggleName] || this._fallbackVariant; + } +}; + +// Not a mock UnleashClient class method since it needs to match the signature of the actual UnleashClient. +window.setVariant = (client, featureName, variantName, isEnabled) => { + client._featureToVariant[featureName] = { name: variantName, enabled: isEnabled, feature_enabled: isEnabled }; +} + +window.Sentry = Sentry; +window.sentryUnleashIntegration = Sentry.unleashIntegration({ featureFlagClientClass: window.UnleashClient }); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + tracesSampleRate: 1.0, + integrations: [ + window.sentryUnleashIntegration, + Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }), + ], +}); + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/subject.js new file mode 100644 index 000000000000..ad874b2bd697 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/subject.js @@ -0,0 +1,16 @@ +const btnStartSpan = document.getElementById('btnStartSpan'); +const btnEndSpan = document.getElementById('btnEndSpan'); +const btnStartNestedSpan = document.getElementById('btnStartNestedSpan'); +const btnEndNestedSpan = document.getElementById('btnEndNestedSpan'); + +window.withNestedSpans = callback => { + window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => { + window.traceId = rootSpan.spanContext().traceId; + + window.Sentry.startSpan({ name: 'test-span' }, _span => { + window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => { + callback(); + }); + }); + }); +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/template.html new file mode 100644 index 000000000000..59340f55667c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts new file mode 100644 index 000000000000..f3cf4c624369 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts @@ -0,0 +1,71 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { + type EventAndTraceHeader, + eventAndTraceHeaderRequestParser, + getMultipleSentryEnvelopeRequests, + shouldSkipFeatureFlagsTest, + shouldSkipTracingTest, +} from '../../../../../utils/helpers'; +import { MAX_FLAGS_PER_SPAN } from '../../constants'; + +sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const envelopeRequestPromise = getMultipleSentryEnvelopeRequests( + page, + 1, + {}, + eventAndTraceHeaderRequestParser, + ); + + // withNestedSpans is a util used to start 3 nested spans: root-span (not recorded in transaction_event.spans), span, and nested-span. + await page.evaluate(maxFlags => { + (window as any).withNestedSpans(() => { + const client = new (window as any).UnleashClient(); + for (let i = 1; i <= maxFlags; i++) { + client.isEnabled(`feat${i}`); + } + client.isEnabled(`feat${maxFlags + 1}`); // dropped + + (window as any).setVariant(client, 'feat3', 'var1', true); + client.isEnabled('feat3'); // update + }); + return true; + }, MAX_FLAGS_PER_SPAN); + + const event = (await envelopeRequestPromise)[0][0]; + const innerSpan = event.spans?.[0]; + const outerSpan = event.spans?.[1]; + const outerSpanFlags = Object.entries(outerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + const innerSpanFlags = Object.entries(innerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + + expect(innerSpanFlags).toEqual([]); + + const expectedOuterSpanFlags = []; + for (let i = 1; i <= 2; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + } + for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + } + expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); + expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); +}); From 3c13997bbb8a76129f00bfe2c2944c948274cf4f Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 6 Jun 2025 17:15:00 -0500 Subject: [PATCH 11/29] fmt --- .../integrations/featureFlags/openfeature/onSpan/init.js | 1 - .../suites/integrations/featureFlags/unleash/onSpan/init.js | 3 +-- packages/browser/src/utils/featureFlags.ts | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/init.js index 9de421d19e63..4fc1cace150c 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/init.js @@ -22,4 +22,3 @@ window.initialize = () => { }, }; }; - diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/init.js index 399ef2fc830a..93993d8f6188 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/init.js @@ -43,7 +43,7 @@ window.UnleashClient = class { // Not a mock UnleashClient class method since it needs to match the signature of the actual UnleashClient. window.setVariant = (client, featureName, variantName, isEnabled) => { client._featureToVariant[featureName] = { name: variantName, enabled: isEnabled, feature_enabled: isEnabled }; -} +}; window.Sentry = Sentry; window.sentryUnleashIntegration = Sentry.unleashIntegration({ featureFlagClientClass: window.UnleashClient }); @@ -57,4 +57,3 @@ Sentry.init({ Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }), ], }); - diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index ffaadeb15779..49ee8ee14a48 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -104,7 +104,6 @@ export function insertToFlagBuffer( // If at capacity, pop the earliest flag - O(n) flags.shift(); } else { - return; } } From 70ccac1034c1b23cd86d8d3fcdbc9889db7fd8fe Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 9 Jun 2025 21:13:16 -0500 Subject: [PATCH 12/29] Dup integration and utils to core, todos in index and integration --- packages/browser/src/index.ts | 4 +- packages/core/src/index.ts | 3 + .../featureFlags/featureFlagsIntegration.ts | 60 +++++++ .../src/integrations/featureFlags/index.ts | 1 + packages/core/src/utils/featureFlags.ts | 161 ++++++++++++++++++ .../core/test/lib/utils/featureFlags.test.ts | 128 ++++++++++++++ 6 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts create mode 100644 packages/core/src/integrations/featureFlags/index.ts create mode 100644 packages/core/src/utils/featureFlags.ts create mode 100644 packages/core/test/lib/utils/featureFlags.test.ts diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 2b2279c099b3..963d8ab38546 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -61,13 +61,13 @@ export { instrumentSupabaseClient, zodErrorsIntegration, thirdPartyErrorFilterIntegration, + featureFlagsIntegration, } from '@sentry/core'; -export type { Span } from '@sentry/core'; +export type { Span, FeatureFlagsIntegration } from '@sentry/core'; 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'; export { unleashIntegration } from './integrations/featureFlags/unleash'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 986d18a972d2..07dba32ee0b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,6 +112,7 @@ export { supabaseIntegration, instrumentSupabaseClient } from './integrations/su export { zodErrorsIntegration } from './integrations/zoderrors'; export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter'; export { consoleIntegration } from './integrations/console'; +export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integrations/featureFlags'; export { profiler } from './profiling'; export { instrumentFetchRequest } from './fetch'; @@ -123,6 +124,8 @@ export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSeria export { consoleLoggingIntegration } from './logs/console-integration'; export type { FeatureFlag } from './featureFlags'; +// TODO: ^export from utils? +// TODO: how to export utils without making public? export { applyAggregateErrorsToEvent } from './utils-hoist/aggregate-errors'; export { getBreadcrumbLogLevelFromHttpStatusCode } from './utils-hoist/breadcrumb-log-level'; diff --git a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts new file mode 100644 index 000000000000..7d5f3470d4ac --- /dev/null +++ b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -0,0 +1,60 @@ +import { type Client } from '../../client'; +import { defineIntegration } from '../../integration'; +import { type Event, type EventHint } from '../../types-hoist/event'; +import { type Integration, type IntegrationFn } from '../../types-hoist/integration'; +import { type Span } from '../../types-hoist/span'; +import { + bufferSpanFeatureFlag, + copyFlagsFromScopeToEvent, + freezeSpanFeatureFlags, + insertFlagToScope, +} from '../../utils/featureFlags'; + +export interface FeatureFlagsIntegration extends Integration { + addFeatureFlag: (name: string, value: unknown) => void; +} + +/** + * Sentry integration for buffering feature flag evaluations manually with an API, and + * capturing them on error events and spans. + * + * See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information. + * + * @example + * ``` + * import * as Sentry from '@sentry/*'; //TODO: + * import { type FeatureFlagsIntegration } from '@sentry/*'; + * + * // Setup + * Sentry.init(..., integrations: [Sentry.featureFlagsIntegration()]) + * + * // Verify + * const flagsIntegration = Sentry.getClient()?.getIntegrationByName('FeatureFlags'); + * if (flagsIntegration) { + * flagsIntegration.addFeatureFlag('my-flag', true); + * } else { + * // check your setup + * } + * Sentry.captureException(Exception('broke')); // 'my-flag' should be captured to this Sentry event. + * ``` + */ +export const featureFlagsIntegration = defineIntegration(() => { + return { + name: 'FeatureFlags', + + setup(client: Client) { + client.on('spanEnd', (span: Span) => { + freezeSpanFeatureFlags(span); + }); + }, + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return copyFlagsFromScopeToEvent(event); + }, + + addFeatureFlag(name: string, value: unknown): void { + insertFlagToScope(name, value); + bufferSpanFeatureFlag(name, value); + }, + }; +}) as IntegrationFn; diff --git a/packages/core/src/integrations/featureFlags/index.ts b/packages/core/src/integrations/featureFlags/index.ts new file mode 100644 index 000000000000..2106ee7accf0 --- /dev/null +++ b/packages/core/src/integrations/featureFlags/index.ts @@ -0,0 +1 @@ +export { featureFlagsIntegration, type FeatureFlagsIntegration } from './featureFlagsIntegration'; diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts new file mode 100644 index 000000000000..04afb2fc95fd --- /dev/null +++ b/packages/core/src/utils/featureFlags.ts @@ -0,0 +1,161 @@ +import { getCurrentScope } from '../currentScopes'; +import { DEBUG_BUILD } from '../debug-build'; +import { type FeatureFlag } from '../featureFlags'; +import { type Event } from '../types-hoist/event'; +import { type Span } from '../types-hoist/span'; +import { logger } from '../utils-hoist/logger'; +import { GLOBAL_OBJ } from '../utils-hoist/worldwide'; +import { getActiveSpan } from './spanUtils'; + +/** + * 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; + +/** + * Max number of flag evaluations to record per span. + */ +export const MAX_FLAGS_PER_SPAN = 10; + +// Global map of spans to feature flag buffers. Populated by feature flag integrations. +GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap(); + +const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.'; + +/** + * 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; +} + +/** + * Inserts a flag into the current scope's context while maintaining ordered LRU properties. + * Not thread-safe. After inserting: + * - The flag buffer is sorted in order of recency, with the newest evaluation at the end. + * - The names in the buffer are always unique. + * - The length of the buffer never exceeds `maxSize`. + * + * @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. Default value should always be used in production. + */ +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 only. Currently only accepts boolean values (otherwise no-op). + * Inserts a flag into a FeatureFlag array while maintaining the following properties: + * - Flags are sorted in order of recency, with the newest evaluation at the end. + * - The flag names are always unique. + * - The length of the array never exceeds `maxSize`. + * + * @param flags The buffer to insert the flag 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. Default value should always be used in production. + * @param allowEviction If true, the oldest flag is evicted when the buffer is full. Otherwise the new flag is dropped. + */ +export function insertToFlagBuffer( + flags: FeatureFlag[], + name: string, + value: unknown, + maxSize: number, + allowEviction: boolean = true, +): 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 (allowEviction) { + // If at capacity, pop the earliest flag - O(n) + flags.shift(); + } else { + return; + } + } + + // Push the flag to the end - O(1) + flags.push({ + flag: name, + result: value, + }); +} + +/** + * Records a feature flag evaluation for the active span, adding it to a weak map of flag buffers. This is a no-op for non-boolean values. + * The keys in each buffer are unique. Once the buffer for a span reaches maxFlagsPerSpan, subsequent flags are dropped. + * + * @param name Name of the feature flag. + * @param value Value of the feature flag. Non-boolean values are ignored. + * @param maxFlagsPerSpan Max number of flags a buffer should store. Default value should always be used in production. + */ +export function bufferSpanFeatureFlag( + name: string, + value: unknown, + maxFlagsPerSpan: number = MAX_FLAGS_PER_SPAN, +): void { + const spanFlagMap = GLOBAL_OBJ._spanToFlagBufferMap; + if (!spanFlagMap || typeof value !== 'boolean') { + return; + } + + const span = getActiveSpan(); + if (span) { + const flags = spanFlagMap.get(span) || []; + insertToFlagBuffer(flags, name, value, maxFlagsPerSpan, false); + spanFlagMap.set(span, flags); + } +} + +/** + * Add the buffered feature flags for a span to the span attributes. Call this on span end. + * + * @param span Span to add flags to. + */ +export function freezeSpanFeatureFlags(span: Span): void { + const flags = GLOBAL_OBJ._spanToFlagBufferMap?.get(span); + if (flags) { + span.setAttributes( + Object.fromEntries(flags.map(flag => [`${SPAN_FLAG_ATTRIBUTE_PREFIX}${flag.flag}`, flag.result])), + ); + } +} diff --git a/packages/core/test/lib/utils/featureFlags.test.ts b/packages/core/test/lib/utils/featureFlags.test.ts new file mode 100644 index 000000000000..ded0f5066427 --- /dev/null +++ b/packages/core/test/lib/utils/featureFlags.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getCurrentScope } from '../../../src/currentScopes'; +import { type FeatureFlag } from '../../../src/featureFlags'; +import { insertFlagToScope, insertToFlagBuffer } from '../../../src/utils/featureFlags'; +import { logger } from '../../../src/utils-hoist/logger'; + +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('drops new entries when allowEviction is false and buffer is full', () => { + const buffer: FeatureFlag[] = []; + const maxSize = 0; + insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); + insertToFlagBuffer(buffer, 'feat2', true, maxSize, false); + insertToFlagBuffer(buffer, 'feat3', true, maxSize, false); + + expect(buffer).toEqual([]); + }); + + it('still updates order and values when allowEviction is false and buffer is full', () => { + const buffer: FeatureFlag[] = []; + const maxSize = 1; + insertToFlagBuffer(buffer, 'feat1', false, maxSize, false); + insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); + + expect(buffer).toEqual([{ flag: 'feat1', result: true }]); + }); + + 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 }, + ]); + }); + }); +}); From 8085ee8b73e13d0a18227cf4741ae1f6b6ab1cd0 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 10 Jun 2025 10:43:07 -0700 Subject: [PATCH 13/29] Reset unrelated otel test --- .../test/integration/transactions.test.ts | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts index 0bbb77296a58..3bdf6c113555 100644 --- a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts +++ b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts @@ -548,57 +548,57 @@ describe('Integration | Transactions', () => { expect(finishedSpans.length).toBe(0); }); - it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { - const timeout = 5 * 60 * 1000; - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now); +it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { + const timeout = 5 * 60 * 1000; + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); - const logs: unknown[] = []; - vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); - const transactions: Event[] = []; + const transactions: Event[] = []; - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction: event => { - transactions.push(event); - return null; - }, - }); + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); - const provider = getProvider(); - const spanProcessor = getSpanProcessor(); + const provider = getProvider(); + const spanProcessor = getSpanProcessor(); - const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; - if (!exporter) { - throw new Error('No exporter found, aborting test...'); - } + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } - startSpanManual({ name: 'test name' }, async span => { - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); + startSpanManual({ name: 'test name' }, async span => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); - const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); + const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); - span.end(); + span.end(); - setTimeout(() => { - subSpan2.end(); - }, timeout - 2); - }); + setTimeout(() => { + subSpan2.end(); + }, timeout - 2); + }); - vi.advanceTimersByTime(timeout - 1); + vi.advanceTimersByTime(timeout - 1); - expect(transactions).toHaveLength(2); - expect(transactions[0]?.spans).toHaveLength(1); + expect(transactions).toHaveLength(2); + expect(transactions[0]?.spans).toHaveLength(1); - const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => - bucket ? Array.from(bucket.spans) : [], - ); - expect(finishedSpans.length).toBe(0); - }); + const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => + bucket ? Array.from(bucket.spans) : [], + ); + expect(finishedSpans.length).toBe(0); +}); it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => { const timeout = 5 * 60 * 1000; From 6381f91c743cc647a4d68d61fcd8fecdd8a9d129 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:46:24 -0700 Subject: [PATCH 14/29] Import utils from core with _INTERNAL_ prefix --- .../featureFlags/launchdarkly/integration.ts | 20 ++++---- .../featureFlags/openfeature/integration.ts | 24 ++++----- .../featureFlags/statsig/integration.ts | 20 ++++---- .../featureFlags/unleash/integration.ts | 22 ++++---- packages/core/src/index.ts | 2 +- .../featureFlags/featureFlagsIntegration.ts | 16 +++--- packages/core/src/utils/featureFlags.ts | 18 +++---- .../core/test/lib/utils/featureFlags.test.ts | 50 +++++++++---------- 8 files changed, 87 insertions(+), 85 deletions(-) diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index caa860574ce4..5890c92baf00 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -1,11 +1,11 @@ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; import { - bufferSpanFeatureFlag, - copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, - insertFlagToScope, -} from '../../../utils/featureFlags'; + defineIntegration, + _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_freezeSpanFeatureFlags, + _INTERNAL_insertFlagToScope, +} from '@sentry/core'; import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; /** @@ -29,12 +29,12 @@ export const launchDarklyIntegration = defineIntegration(() => { setup(client: Client) { client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); + _INTERNAL_freezeSpanFeatureFlags(span); }); }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return copyFlagsFromScopeToEvent(event); + return _INTERNAL_copyFlagsFromScopeToEvent(event); }, }; }) satisfies IntegrationFn; @@ -56,8 +56,8 @@ 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) => { - insertFlagToScope(flagKey, flagDetail.value); - bufferSpanFeatureFlag(flagKey, flagDetail.value); + _INTERNAL_insertFlagToScope(flagKey, flagDetail.value); + _INTERNAL_bufferSpanFeatureFlag(flagKey, flagDetail.value); }, }; } diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index bd5efd1e282f..2f80202d6613 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -14,13 +14,13 @@ * ``` */ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; import { - bufferSpanFeatureFlag, - copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, - insertFlagToScope, -} from '../../../utils/featureFlags'; + defineIntegration, + _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_freezeSpanFeatureFlags, + _INTERNAL_insertFlagToScope, +} from '@sentry/core'; import type { EvaluationDetails, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types'; export const openFeatureIntegration = defineIntegration(() => { @@ -29,12 +29,12 @@ export const openFeatureIntegration = defineIntegration(() => { setup(client: Client) { client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); + _INTERNAL_freezeSpanFeatureFlags(span); }); }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return copyFlagsFromScopeToEvent(event); + return _INTERNAL_copyFlagsFromScopeToEvent(event); }, }; }) satisfies IntegrationFn; @@ -47,15 +47,15 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook { * Successful evaluation result. */ public after(_hookContext: Readonly>, evaluationDetails: EvaluationDetails): void { - insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value); - bufferSpanFeatureFlag(evaluationDetails.flagKey, evaluationDetails.value); + _INTERNAL_insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value); + _INTERNAL_bufferSpanFeatureFlag(evaluationDetails.flagKey, evaluationDetails.value); } /** * On error evaluation result. */ public error(hookContext: Readonly>, _error: unknown, _hookHints?: HookHints): void { - insertFlagToScope(hookContext.flagKey, hookContext.defaultValue); - bufferSpanFeatureFlag(hookContext.flagKey, hookContext.defaultValue); + _INTERNAL_insertFlagToScope(hookContext.flagKey, hookContext.defaultValue); + _INTERNAL_bufferSpanFeatureFlag(hookContext.flagKey, hookContext.defaultValue); } } diff --git a/packages/browser/src/integrations/featureFlags/statsig/integration.ts b/packages/browser/src/integrations/featureFlags/statsig/integration.ts index 641e7412e6a8..e7ce439049db 100644 --- a/packages/browser/src/integrations/featureFlags/statsig/integration.ts +++ b/packages/browser/src/integrations/featureFlags/statsig/integration.ts @@ -1,11 +1,11 @@ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; import { - bufferSpanFeatureFlag, - copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, - insertFlagToScope, -} from '../../../utils/featureFlags'; + defineIntegration, + _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_freezeSpanFeatureFlags, + _INTERNAL_insertFlagToScope, +} from '@sentry/core'; import type { FeatureGate, StatsigClient } from './types'; /** @@ -38,17 +38,17 @@ export const statsigIntegration = defineIntegration( setup(client: Client) { client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); + _INTERNAL_freezeSpanFeatureFlags(span); }); statsigClient.on('gate_evaluation', (event: { gate: FeatureGate }) => { - insertFlagToScope(event.gate.name, event.gate.value); - bufferSpanFeatureFlag(event.gate.name, event.gate.value); + _INTERNAL_insertFlagToScope(event.gate.name, event.gate.value); + _INTERNAL_bufferSpanFeatureFlag(event.gate.name, event.gate.value); }); }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return copyFlagsFromScopeToEvent(event); + return _INTERNAL_copyFlagsFromScopeToEvent(event); }, }; }, diff --git a/packages/browser/src/integrations/featureFlags/unleash/integration.ts b/packages/browser/src/integrations/featureFlags/unleash/integration.ts index 75559dd3841b..063569cf8310 100644 --- a/packages/browser/src/integrations/featureFlags/unleash/integration.ts +++ b/packages/browser/src/integrations/featureFlags/unleash/integration.ts @@ -1,12 +1,14 @@ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; -import { defineIntegration, fill, logger } from '@sentry/core'; import { DEBUG_BUILD } from '../../../debug-build'; import { - bufferSpanFeatureFlag, - copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, - insertFlagToScope, -} from '../../../utils/featureFlags'; + defineIntegration, + fill, + logger, + _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_freezeSpanFeatureFlags, + _INTERNAL_insertFlagToScope, +} from '@sentry/core'; import type { UnleashClient, UnleashClientClass } from './types'; type UnleashIntegrationOptions = { @@ -42,7 +44,7 @@ export const unleashIntegration = defineIntegration( setup(client: Client) { client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); + _INTERNAL_freezeSpanFeatureFlags(span); }); }, @@ -52,7 +54,7 @@ export const unleashIntegration = defineIntegration( }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return copyFlagsFromScopeToEvent(event); + return _INTERNAL_copyFlagsFromScopeToEvent(event); }, }; }, @@ -75,8 +77,8 @@ function _wrappedIsEnabled( const result = original.apply(this, args); if (typeof toggleName === 'string' && typeof result === 'boolean') { - insertFlagToScope(toggleName, result); - bufferSpanFeatureFlag(toggleName, result); + _INTERNAL_insertFlagToScope(toggleName, result); + _INTERNAL_bufferSpanFeatureFlag(toggleName, result); } else if (DEBUG_BUILD) { logger.error( `[Feature Flags] UnleashClient.isEnabled does not match expected signature. arg0: ${toggleName} (${typeof toggleName}), result: ${result} (${typeof result})`, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 07dba32ee0b0..f2027370a235 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -125,7 +125,7 @@ export { consoleLoggingIntegration } from './logs/console-integration'; export type { FeatureFlag } from './featureFlags'; // TODO: ^export from utils? -// TODO: how to export utils without making public? +export { _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_freezeSpanFeatureFlags } from './utils/featureFlags'; export { applyAggregateErrorsToEvent } from './utils-hoist/aggregate-errors'; export { getBreadcrumbLogLevelFromHttpStatusCode } from './utils-hoist/breadcrumb-log-level'; diff --git a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts index 7d5f3470d4ac..ad806200adc5 100644 --- a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts +++ b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -4,10 +4,10 @@ import { type Event, type EventHint } from '../../types-hoist/event'; import { type Integration, type IntegrationFn } from '../../types-hoist/integration'; import { type Span } from '../../types-hoist/span'; import { - bufferSpanFeatureFlag, - copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, - insertFlagToScope, + _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_freezeSpanFeatureFlags, + _INTERNAL_insertFlagToScope, } from '../../utils/featureFlags'; export interface FeatureFlagsIntegration extends Integration { @@ -44,17 +44,17 @@ export const featureFlagsIntegration = defineIntegration(() => { setup(client: Client) { client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); + _INTERNAL_freezeSpanFeatureFlags(span); }); }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return copyFlagsFromScopeToEvent(event); + return _INTERNAL_copyFlagsFromScopeToEvent(event); }, addFeatureFlag(name: string, value: unknown): void { - insertFlagToScope(name, value); - bufferSpanFeatureFlag(name, value); + _INTERNAL_insertFlagToScope(name, value); + _INTERNAL_bufferSpanFeatureFlag(name, value); }, }; }) as IntegrationFn; diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts index 04afb2fc95fd..ced582feef43 100644 --- a/packages/core/src/utils/featureFlags.ts +++ b/packages/core/src/utils/featureFlags.ts @@ -16,12 +16,12 @@ import { getActiveSpan } from './spanUtils'; /** * Max size of the LRU flag buffer stored in Sentry scope and event contexts. */ -export const FLAG_BUFFER_SIZE = 100; +const FLAG_BUFFER_SIZE = 100; /** * Max number of flag evaluations to record per span. */ -export const MAX_FLAGS_PER_SPAN = 10; +const MAX_FLAGS_PER_SPAN = 10; // Global map of spans to feature flag buffers. Populated by feature flag integrations. GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap(); @@ -31,7 +31,7 @@ const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.'; /** * Copies feature flags that are in current scope context to the event context */ -export function copyFlagsFromScopeToEvent(event: Event): Event { +export function _INTERNAL_copyFlagsFromScopeToEvent(event: Event): Event { const scope = getCurrentScope(); const flagContext = scope.getScopeData().contexts.flags; const flagBuffer = flagContext ? flagContext.values : []; @@ -58,13 +58,13 @@ export function copyFlagsFromScopeToEvent(event: Event): Event { * @param value Value of the feature flag. * @param maxSize Max number of flags the buffer should store. Default value should always be used in production. */ -export function insertFlagToScope(name: string, value: unknown, maxSize: number = FLAG_BUFFER_SIZE): void { +export function _INTERNAL_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); + _INTERNAL_insertToFlagBuffer(flags, name, value, maxSize); } /** @@ -80,7 +80,7 @@ export function insertFlagToScope(name: string, value: unknown, maxSize: number * @param maxSize Max number of flags the buffer should store. Default value should always be used in production. * @param allowEviction If true, the oldest flag is evicted when the buffer is full. Otherwise the new flag is dropped. */ -export function insertToFlagBuffer( +export function _INTERNAL_insertToFlagBuffer( flags: FeatureFlag[], name: string, value: unknown, @@ -128,7 +128,7 @@ export function insertToFlagBuffer( * @param value Value of the feature flag. Non-boolean values are ignored. * @param maxFlagsPerSpan Max number of flags a buffer should store. Default value should always be used in production. */ -export function bufferSpanFeatureFlag( +export function _INTERNAL_bufferSpanFeatureFlag( name: string, value: unknown, maxFlagsPerSpan: number = MAX_FLAGS_PER_SPAN, @@ -141,7 +141,7 @@ export function bufferSpanFeatureFlag( const span = getActiveSpan(); if (span) { const flags = spanFlagMap.get(span) || []; - insertToFlagBuffer(flags, name, value, maxFlagsPerSpan, false); + _INTERNAL_insertToFlagBuffer(flags, name, value, maxFlagsPerSpan, false); spanFlagMap.set(span, flags); } } @@ -151,7 +151,7 @@ export function bufferSpanFeatureFlag( * * @param span Span to add flags to. */ -export function freezeSpanFeatureFlags(span: Span): void { +export function _INTERNAL_freezeSpanFeatureFlags(span: Span): void { const flags = GLOBAL_OBJ._spanToFlagBufferMap?.get(span); if (flags) { span.setAttributes( diff --git a/packages/core/test/lib/utils/featureFlags.test.ts b/packages/core/test/lib/utils/featureFlags.test.ts index ded0f5066427..c14a9c4240c8 100644 --- a/packages/core/test/lib/utils/featureFlags.test.ts +++ b/packages/core/test/lib/utils/featureFlags.test.ts @@ -1,17 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getCurrentScope } from '../../../src/currentScopes'; import { type FeatureFlag } from '../../../src/featureFlags'; -import { insertFlagToScope, insertToFlagBuffer } from '../../../src/utils/featureFlags'; +import { _INTERNAL_insertFlagToScope, _INTERNAL_insertToFlagBuffer } from '../../../src/utils/featureFlags'; import { logger } from '../../../src/utils-hoist/logger'; 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); + _INTERNAL_insertFlagToScope('feat1', true, maxSize); + _INTERNAL_insertFlagToScope('feat2', true, maxSize); + _INTERNAL_insertFlagToScope('feat3', true, maxSize); + _INTERNAL_insertFlagToScope('feat4', true, maxSize); const scope = getCurrentScope(); expect(scope.getScopeData().contexts.flags?.values).toEqual([ @@ -32,10 +32,10 @@ describe('flags', () => { 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); + _INTERNAL_insertToFlagBuffer(buffer, 'feat1', true, maxSize); + _INTERNAL_insertToFlagBuffer(buffer, 'feat2', true, maxSize); + _INTERNAL_insertToFlagBuffer(buffer, 'feat3', true, maxSize); + _INTERNAL_insertToFlagBuffer(buffer, 'feat4', true, maxSize); expect(buffer).toEqual([ { flag: 'feat2', result: true }, @@ -47,11 +47,11 @@ describe('flags', () => { 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); + _INTERNAL_insertToFlagBuffer(buffer, 'feat1', true, maxSize); + _INTERNAL_insertToFlagBuffer(buffer, 'feat2', true, maxSize); + _INTERNAL_insertToFlagBuffer(buffer, 'feat3', true, maxSize); + _INTERNAL_insertToFlagBuffer(buffer, 'feat3', false, maxSize); + _INTERNAL_insertToFlagBuffer(buffer, 'feat1', false, maxSize); expect(buffer).toEqual([ { flag: 'feat2', result: true }, @@ -63,9 +63,9 @@ describe('flags', () => { it('drops new entries when allowEviction is false and buffer is full', () => { const buffer: FeatureFlag[] = []; const maxSize = 0; - insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); - insertToFlagBuffer(buffer, 'feat2', true, maxSize, false); - insertToFlagBuffer(buffer, 'feat3', true, maxSize, false); + _INTERNAL_insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); + _INTERNAL_insertToFlagBuffer(buffer, 'feat2', true, maxSize, false); + _INTERNAL_insertToFlagBuffer(buffer, 'feat3', true, maxSize, false); expect(buffer).toEqual([]); }); @@ -73,8 +73,8 @@ describe('flags', () => { it('still updates order and values when allowEviction is false and buffer is full', () => { const buffer: FeatureFlag[] = []; const maxSize = 1; - insertToFlagBuffer(buffer, 'feat1', false, maxSize, false); - insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); + _INTERNAL_insertToFlagBuffer(buffer, 'feat1', false, maxSize, false); + _INTERNAL_insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); expect(buffer).toEqual([{ flag: 'feat1', result: true }]); }); @@ -82,8 +82,8 @@ describe('flags', () => { it('does not allocate unnecessary space', () => { const buffer: FeatureFlag[] = []; const maxSize = 1000; - insertToFlagBuffer(buffer, 'feat1', true, maxSize); - insertToFlagBuffer(buffer, 'feat2', true, maxSize); + _INTERNAL_insertToFlagBuffer(buffer, 'feat1', true, maxSize); + _INTERNAL_insertToFlagBuffer(buffer, 'feat2', true, maxSize); expect(buffer).toEqual([ { flag: 'feat1', result: true }, @@ -94,8 +94,8 @@ 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); + _INTERNAL_insertToFlagBuffer(buffer, 'feat1', 1, maxSize); + _INTERNAL_insertToFlagBuffer(buffer, 'feat2', 'string', maxSize); expect(buffer).toEqual([]); }); @@ -106,7 +106,7 @@ describe('flags', () => { { flag: 'feat2', result: true }, ]; - insertToFlagBuffer(buffer, 'feat1', true, 1); + _INTERNAL_insertToFlagBuffer(buffer, 'feat1', true, 1); expect(loggerSpy).toHaveBeenCalledWith( expect.stringContaining('[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize'), ); @@ -115,7 +115,7 @@ describe('flags', () => { { flag: 'feat2', result: true }, ]); - insertToFlagBuffer(buffer, 'feat1', true, -2); + _INTERNAL_insertToFlagBuffer(buffer, 'feat1', true, -2); expect(loggerSpy).toHaveBeenCalledWith( expect.stringContaining('[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize'), ); From a93b0e0237dc4a3758fa5d31e513ed79fba7b3e2 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:52:31 -0700 Subject: [PATCH 15/29] Delete old browser utils and integration and yarn fix --- .../featureFlags/featureFlagsIntegration.ts | 57 ------- .../src/integrations/featureFlags/index.ts | 1 - .../featureFlags/launchdarkly/integration.ts | 2 +- .../featureFlags/openfeature/integration.ts | 2 +- .../featureFlags/statsig/integration.ts | 2 +- .../featureFlags/unleash/integration.ts | 8 +- packages/browser/src/utils/featureFlags.ts | 156 ------------------ .../browser/test/utils/featureFlags.test.ts | 127 -------------- packages/core/src/index.ts | 7 +- 9 files changed, 13 insertions(+), 349 deletions(-) delete mode 100644 packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts delete mode 100644 packages/browser/src/integrations/featureFlags/index.ts delete mode 100644 packages/browser/src/utils/featureFlags.ts delete mode 100644 packages/browser/test/utils/featureFlags.test.ts diff --git a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts deleted file mode 100644 index 549c99af2c13..000000000000 --- a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Client, Event, EventHint, Integration, IntegrationFn, Span } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; -import { - bufferSpanFeatureFlag, - copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, - insertFlagToScope, -} from '../../utils/featureFlags'; - -export interface FeatureFlagsIntegration extends Integration { - addFeatureFlag: (name: string, value: unknown) => void; -} - -/** - * Sentry integration for buffering feature flag evaluations manually with an API, and - * capturing them on error events and spans. - * - * 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('FeatureFlags'); - * if (flagsIntegration) { - * flagsIntegration.addFeatureFlag('my-flag', true); - * } else { - * // check your setup - * } - * Sentry.captureException(Exception('broke')); // 'my-flag' should be captured to this Sentry event. - * ``` - */ -export const featureFlagsIntegration = defineIntegration(() => { - return { - name: 'FeatureFlags', - - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); - }); - }, - - processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return copyFlagsFromScopeToEvent(event); - }, - - addFeatureFlag(name: string, value: unknown): void { - insertFlagToScope(name, value); - bufferSpanFeatureFlag(name, value); - }, - }; -}) as IntegrationFn; diff --git a/packages/browser/src/integrations/featureFlags/index.ts b/packages/browser/src/integrations/featureFlags/index.ts deleted file mode 100644 index 2106ee7accf0..000000000000 --- a/packages/browser/src/integrations/featureFlags/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { featureFlagsIntegration, type FeatureFlagsIntegration } from './featureFlagsIntegration'; diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index 5890c92baf00..c68a8d889bda 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -1,10 +1,10 @@ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { - defineIntegration, _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_freezeSpanFeatureFlags, _INTERNAL_insertFlagToScope, + defineIntegration, } from '@sentry/core'; import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index 2f80202d6613..ffe5bbbbee72 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -15,11 +15,11 @@ */ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { - defineIntegration, _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_freezeSpanFeatureFlags, _INTERNAL_insertFlagToScope, + defineIntegration, } from '@sentry/core'; import type { EvaluationDetails, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types'; diff --git a/packages/browser/src/integrations/featureFlags/statsig/integration.ts b/packages/browser/src/integrations/featureFlags/statsig/integration.ts index e7ce439049db..b57d6befb186 100644 --- a/packages/browser/src/integrations/featureFlags/statsig/integration.ts +++ b/packages/browser/src/integrations/featureFlags/statsig/integration.ts @@ -1,10 +1,10 @@ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { - defineIntegration, _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_freezeSpanFeatureFlags, _INTERNAL_insertFlagToScope, + defineIntegration, } from '@sentry/core'; import type { FeatureGate, StatsigClient } from './types'; diff --git a/packages/browser/src/integrations/featureFlags/unleash/integration.ts b/packages/browser/src/integrations/featureFlags/unleash/integration.ts index 063569cf8310..e861d066ca1c 100644 --- a/packages/browser/src/integrations/featureFlags/unleash/integration.ts +++ b/packages/browser/src/integrations/featureFlags/unleash/integration.ts @@ -1,14 +1,14 @@ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; -import { DEBUG_BUILD } from '../../../debug-build'; import { - defineIntegration, - fill, - logger, _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_freezeSpanFeatureFlags, _INTERNAL_insertFlagToScope, + defineIntegration, + fill, + logger, } from '@sentry/core'; +import { DEBUG_BUILD } from '../../../debug-build'; import type { UnleashClient, UnleashClientClass } from './types'; type UnleashIntegrationOptions = { diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts deleted file mode 100644 index 49ee8ee14a48..000000000000 --- a/packages/browser/src/utils/featureFlags.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { Event, FeatureFlag, Span } from '@sentry/core'; -import { getActiveSpan, getCurrentScope, GLOBAL_OBJ, 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; - -/** - * Max number of flag evaluations to record per span. - */ -export const MAX_FLAGS_PER_SPAN = 10; - -// Global map of spans to feature flag buffers. Populated by feature flag integrations. -GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap(); - -const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.'; - -/** - * 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; -} - -/** - * Inserts a flag into the current scope's context while maintaining ordered LRU properties. - * Not thread-safe. After inserting: - * - The flag buffer is sorted in order of recency, with the newest evaluation at the end. - * - The names in the buffer are always unique. - * - The length of the buffer never exceeds `maxSize`. - * - * @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. Default value should always be used in production. - */ -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 only. Currently only accepts boolean values (otherwise no-op). - * Inserts a flag into a FeatureFlag array while maintaining the following properties: - * - Flags are sorted in order of recency, with the newest evaluation at the end. - * - The flag names are always unique. - * - The length of the array never exceeds `maxSize`. - * - * @param flags The buffer to insert the flag 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. Default value should always be used in production. - * @param allowEviction If true, the oldest flag is evicted when the buffer is full. Otherwise the new flag is dropped. - */ -export function insertToFlagBuffer( - flags: FeatureFlag[], - name: string, - value: unknown, - maxSize: number, - allowEviction: boolean = true, -): 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 (allowEviction) { - // If at capacity, pop the earliest flag - O(n) - flags.shift(); - } else { - return; - } - } - - // Push the flag to the end - O(1) - flags.push({ - flag: name, - result: value, - }); -} - -/** - * Records a feature flag evaluation for the active span, adding it to a weak map of flag buffers. This is a no-op for non-boolean values. - * The keys in each buffer are unique. Once the buffer for a span reaches maxFlagsPerSpan, subsequent flags are dropped. - * - * @param name Name of the feature flag. - * @param value Value of the feature flag. Non-boolean values are ignored. - * @param maxFlagsPerSpan Max number of flags a buffer should store. Default value should always be used in production. - */ -export function bufferSpanFeatureFlag( - name: string, - value: unknown, - maxFlagsPerSpan: number = MAX_FLAGS_PER_SPAN, -): void { - const spanFlagMap = GLOBAL_OBJ._spanToFlagBufferMap; - if (!spanFlagMap || typeof value !== 'boolean') { - return; - } - - const span = getActiveSpan(); - if (span) { - const flags = spanFlagMap.get(span) || []; - insertToFlagBuffer(flags, name, value, maxFlagsPerSpan, false); - spanFlagMap.set(span, flags); - } -} - -/** - * Add the buffered feature flags for a span to the span attributes. Call this on span end. - * - * @param span Span to add flags to. - */ -export function freezeSpanFeatureFlags(span: Span): void { - const flags = GLOBAL_OBJ._spanToFlagBufferMap?.get(span); - if (flags) { - span.setAttributes( - Object.fromEntries(flags.map(flag => [`${SPAN_FLAG_ATTRIBUTE_PREFIX}${flag.flag}`, flag.result])), - ); - } -} diff --git a/packages/browser/test/utils/featureFlags.test.ts b/packages/browser/test/utils/featureFlags.test.ts deleted file mode 100644 index e60871832261..000000000000 --- a/packages/browser/test/utils/featureFlags.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { FeatureFlag } from '@sentry/core'; -import { getCurrentScope, logger } from '@sentry/core'; -import { afterEach, describe, expect, it, 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('drops new entries when allowEviction is false and buffer is full', () => { - const buffer: FeatureFlag[] = []; - const maxSize = 0; - insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); - insertToFlagBuffer(buffer, 'feat2', true, maxSize, false); - insertToFlagBuffer(buffer, 'feat3', true, maxSize, false); - - expect(buffer).toEqual([]); - }); - - it('still updates order and values when allowEviction is false and buffer is full', () => { - const buffer: FeatureFlag[] = []; - const maxSize = 1; - insertToFlagBuffer(buffer, 'feat1', false, maxSize, false); - insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); - - expect(buffer).toEqual([{ flag: 'feat1', result: true }]); - }); - - 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/index.ts b/packages/core/src/index.ts index f2027370a235..3764fe0f30b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -125,7 +125,12 @@ export { consoleLoggingIntegration } from './logs/console-integration'; export type { FeatureFlag } from './featureFlags'; // TODO: ^export from utils? -export { _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_freezeSpanFeatureFlags } from './utils/featureFlags'; +export { + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_insertFlagToScope, + _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_freezeSpanFeatureFlags, +} from './utils/featureFlags'; export { applyAggregateErrorsToEvent } from './utils-hoist/aggregate-errors'; export { getBreadcrumbLogLevelFromHttpStatusCode } from './utils-hoist/breadcrumb-log-level'; From 4366e5a7831024f2c49be71303a836fc5afc8846 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:53:22 -0700 Subject: [PATCH 16/29] Keep browser in docstr example --- .../src/integrations/featureFlags/featureFlagsIntegration.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts index ad806200adc5..21298989d24c 100644 --- a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts +++ b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -22,8 +22,8 @@ export interface FeatureFlagsIntegration extends Integration { * * @example * ``` - * import * as Sentry from '@sentry/*'; //TODO: - * import { type FeatureFlagsIntegration } from '@sentry/*'; + * import * as Sentry from '@sentry/browser'; + * import { type FeatureFlagsIntegration } from '@sentry/browser'; * * // Setup * Sentry.init(..., integrations: [Sentry.featureFlagsIntegration()]) From 868cd71cd987ebe9e7bf91e05870dab26d09a970 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 10 Jun 2025 12:24:35 -0700 Subject: [PATCH 17/29] Export from same pkgs as zodErrorsIntegration, except remix, solidstart, sveltekit (has *) --- packages/astro/src/index.server.ts | 2 ++ packages/aws-serverless/src/index.ts | 2 ++ packages/bun/src/index.ts | 2 ++ packages/cloudflare/src/index.ts | 2 ++ packages/deno/src/index.ts | 2 ++ packages/google-cloud-serverless/src/index.ts | 2 ++ packages/node/src/index.ts | 2 ++ packages/remix/src/cloudflare/index.ts | 2 ++ packages/sveltekit/src/worker/index.ts | 2 ++ packages/vercel-edge/src/index.ts | 2 ++ 10 files changed, 20 insertions(+) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 29105cfb4b18..e828c46af051 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -134,6 +134,8 @@ export { logger, consoleLoggingIntegration, wrapMcpServerWithSentry, + featureFlagsIntegration, + type FeatureFlagsIntegration, } from '@sentry/node'; export { init } from './server/sdk'; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 942951c165da..27ff9e3f1b9c 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -120,6 +120,8 @@ export { logger, consoleLoggingIntegration, wrapMcpServerWithSentry, + featureFlagsIntegration, + type FeatureFlagsIntegration, } from '@sentry/node'; export { diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 70104de6d7c3..14a44e2d38fc 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -15,6 +15,7 @@ export type { Stacktrace, Thread, User, + FeatureFlagsIntegration, } from '@sentry/core'; export { @@ -139,6 +140,7 @@ export { consoleLoggingIntegration, createSentryWinstonTransport, wrapMcpServerWithSentry, + featureFlagsIntegration, } from '@sentry/node'; export { diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 6754ffd04f7c..1a366aeb5dd0 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -8,6 +8,7 @@ export type { EventHint, ErrorEvent, Exception, + FeatureFlagsIntegration, Session, SeverityLevel, Span, @@ -91,6 +92,7 @@ export { updateSpanName, wrapMcpServerWithSentry, consoleLoggingIntegration, + featureFlagsIntegration, } from '@sentry/core'; export * as logger from './logs/exports'; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index f388de7cb5ee..12bcedc35270 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -8,6 +8,7 @@ export type { EventHint, ErrorEvent, Exception, + FeatureFlagsIntegration, Session, SeverityLevel, Span, @@ -87,6 +88,7 @@ export { spanToBaggageHeader, updateSpanName, wrapMcpServerWithSentry, + featureFlagsIntegration, } from '@sentry/core'; export { DenoClient } from './client'; diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index f5d593312743..ff17030ed4a5 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -120,6 +120,8 @@ export { logger, consoleLoggingIntegration, wrapMcpServerWithSentry, + featureFlagsIntegration, + type FeatureFlagsIntegration, } from '@sentry/node'; export { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 5a933002bc23..5992e7f27ea9 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -138,6 +138,7 @@ export { consoleLoggingIntegration, consoleIntegration, wrapMcpServerWithSentry, + featureFlagsIntegration, } from '@sentry/core'; export type { @@ -157,6 +158,7 @@ export type { Thread, User, Span, + FeatureFlagsIntegration, } from '@sentry/core'; export { logger }; diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index 3d3d17e1da27..46c443cac39f 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -32,6 +32,7 @@ export type { EventHint, ErrorEvent, Exception, + FeatureFlagsIntegration, Session, SeverityLevel, Span, @@ -109,4 +110,5 @@ export { spanToTraceHeader, spanToBaggageHeader, updateSpanName, + featureFlagsIntegration, } from '@sentry/core'; diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index 3614922072ec..2dde8c61a1dc 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -82,6 +82,8 @@ export { supabaseIntegration, instrumentSupabaseClient, zodErrorsIntegration, + featureFlagsIntegration, + type FeatureFlagsIntegration, } from '@sentry/cloudflare'; /** diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index ff1231c8a1f8..303d40144ec3 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -8,6 +8,7 @@ export type { EventHint, ErrorEvent, Exception, + FeatureFlagsIntegration, Session, SeverityLevel, Span, @@ -90,6 +91,7 @@ export { spanToBaggageHeader, wrapMcpServerWithSentry, consoleLoggingIntegration, + featureFlagsIntegration, } from '@sentry/core'; export { VercelEdgeClient } from './client'; From 16113300849ca673bcc9d4732d223e0cec88047b Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 10 Jun 2025 16:46:13 -0700 Subject: [PATCH 18/29] Add on error node tests. TODO export/import buffer sizes as _INTERNAL --- .../integrations/featureFlags/constants.ts | 2 +- .../onError/basic/scenario.ts | 20 ++++++++++ .../onError/basic/test.ts | 32 ++++++++++++++++ .../onError/withScope/scenario.ts | 22 +++++++++++ .../onError/withScope/test.ts | 38 +++++++++++++++++++ .../onSpan/scenario.ts | 28 ++++++++++++++ .../featureFlagsIntegration/onSpan/test.ts | 34 +++++++++++++++++ 7 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/test.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/withScope/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/withScope/test.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts index ba3c35a08241..712b7270b52c 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts @@ -1,3 +1,3 @@ -// Corresponds to constants in featureFlags.ts, in browser utils. +// Corresponds to constants in featureFlags.ts, in @sentry/core utils. export const FLAG_BUFFER_SIZE = 100; export const MAX_FLAGS_PER_SPAN = 10; diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts new file mode 100644 index 000000000000..67fd0c3ebfdd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +const FLAG_BUFFER_SIZE = 100; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.featureFlagsIntegration()], +}); + +const flagsIntegration = Sentry.getClient()?.getIntegrationByName('FeatureFlags'); +for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { + flagsIntegration?.addFeatureFlag(`feat${i}`, false); +} +flagsIntegration?.addFeatureFlag(`feat${FLAG_BUFFER_SIZE + 1}`, true); // eviction +flagsIntegration?.addFeatureFlag('feat3', true); // update + +throw new Error('Test error'); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/test.ts new file mode 100644 index 000000000000..6b003bdd7a6e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/test.ts @@ -0,0 +1,32 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../../utils/runner'; + +const FLAG_BUFFER_SIZE = 100; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('Flags captured on error with eviction, update, and no async tasks', async () => { + // Based on scenario.ts. + 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 }); + + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { values: [{ type: 'Error', value: 'Test error' }] }, + contexts: { + flags: { + values: expectedFlags, + }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/withScope/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/withScope/scenario.ts new file mode 100644 index 000000000000..b6537011a87f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/withScope/scenario.ts @@ -0,0 +1,22 @@ +import type { Scope } from '@sentry/node'; +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.featureFlagsIntegration()], +}); + +const flagsIntegration = Sentry.getClient()?.getIntegrationByName('FeatureFlags'); +flagsIntegration?.addFeatureFlag('shared', true); + +Sentry.withScope((_scope: Scope) => { + flagsIntegration?.addFeatureFlag('forked', true); + flagsIntegration?.addFeatureFlag('shared', false); + Sentry.captureException(new Error('Error in forked scope')); +}); + +flagsIntegration?.addFeatureFlag('main', true); +throw new Error('Error in main scope'); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/withScope/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/withScope/test.ts new file mode 100644 index 000000000000..947b299923e7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/withScope/test.ts @@ -0,0 +1,38 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('Flags captured on error are isolated by current scope', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { values: [{ type: 'Error', value: 'Error in forked scope' }] }, + contexts: { + flags: { + values: [ + { flag: 'forked', result: true }, + { flag: 'shared', result: false }, + ], + }, + }, + }, + }) + .expect({ + event: { + exception: { values: [{ type: 'Error', value: 'Error in main scope' }] }, + contexts: { + flags: { + values: [ + { flag: 'shared', result: true }, + { flag: 'main', result: true }, + ], + }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts new file mode 100644 index 000000000000..de2e14c93b68 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +const MAX_FLAGS_PER_SPAN = 10; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + // // disable attaching headers to /test/* endpoints + // tracePropagationTargets: [/^(?!.*test).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.featureFlagsIntegration()], +}); + +const flagsIntegration = Sentry.getClient()?.getIntegrationByName('FeatureFlags'); + +Sentry.startSpan({ name: 'test-root-span' }, () => { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'test-nested-span' }, () => { + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + flagsIntegration?.addFeatureFlag(`feat${i}`, false); + } + flagsIntegration?.addFeatureFlag(`feat${MAX_FLAGS_PER_SPAN + 1}`, true); // dropped flag + flagsIntegration?.addFeatureFlag('feat3', true); // update + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts new file mode 100644 index 000000000000..6828caa83d2e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts @@ -0,0 +1,34 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +const MAX_FLAGS_PER_SPAN = 10; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('Flags captured on span attributes with max limit', async () => { + // Based on scenario.ts. + const expectedFlags: Record = {}; + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedFlags[`flag.evaluation.feat${i}`] = i === 3; + } + + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + spans: [ + expect.objectContaining({ + description: 'test-span', + data: expect.objectContaining({}), + }), + expect.objectContaining({ + description: 'test-nested-span', + data: expect.objectContaining(expectedFlags), + }), + ], + }, + }) + .start() + .completed(); +}); From 9cce949eb820e7e7f28057f3718bbe47cfb835d7 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 12 Jun 2025 13:58:07 -0700 Subject: [PATCH 19/29] Add flags to attrs directly on eval --- .../featureFlags/featureFlags/onSpan/test.ts | 11 +-- .../featureFlags/launchdarkly/onSpan/test.ts | 11 +-- .../featureFlags/openfeature/onSpan/test.ts | 11 +-- .../featureFlags/statsig/onSpan/test.ts | 11 +-- .../featureFlags/unleash/onSpan/test.ts | 11 +-- .../onSpan/scenario.ts | 2 - .../test/integration/transactions.test.ts | 76 +++++++++---------- .../featureFlags/launchdarkly/integration.ts | 9 +-- .../featureFlags/openfeature/integration.ts | 9 +-- .../featureFlags/statsig/integration.ts | 9 +-- .../featureFlags/unleash/integration.ts | 9 +-- packages/core/src/index.ts | 1 - .../featureFlags/featureFlagsIntegration.ts | 8 -- packages/core/src/utils-hoist/worldwide.ts | 5 +- packages/core/src/utils/featureFlags.ts | 30 +++----- 15 files changed, 76 insertions(+), 137 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts index e1ed9e96b1e5..476b76d03475 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts @@ -58,12 +58,9 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a expect(innerSpanFlags).toEqual([]); const expectedOuterSpanFlags = []; - for (let i = 1; i <= 2; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); } - for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); - } - expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); - expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); + // Order agnostic (attribute dict is unordered). + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts index a49191f4d4a3..965f00f91fa0 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts @@ -58,12 +58,9 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a expect(innerSpanFlags).toEqual([]); const expectedOuterSpanFlags = []; - for (let i = 1; i <= 2; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); } - for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); - } - expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); - expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); + // Order agnostic (attribute dict is unordered). + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts index 793b2ed6fffd..f3b43425477f 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts @@ -58,12 +58,9 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a expect(innerSpanFlags).toEqual([]); const expectedOuterSpanFlags = []; - for (let i = 1; i <= 2; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); } - for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); - } - expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); - expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); + // Order agnostic (attribute dict is unordered). + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts index 384f59620cdf..dec534f9ffab 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts @@ -62,12 +62,9 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a expect(innerSpanFlags).toEqual([]); const expectedOuterSpanFlags = []; - for (let i = 1; i <= 2; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); } - for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); - } - expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); - expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); + // Order agnostic (attribute dict is unordered). + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts index f3cf4c624369..b2607ffa4c07 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts @@ -60,12 +60,9 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a expect(innerSpanFlags).toEqual([]); const expectedOuterSpanFlags = []; - for (let i = 1; i <= 2; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); } - for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); - } - expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); - expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); + // Order agnostic (attribute dict is unordered). + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); }); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts index de2e14c93b68..d21fb7a9979e 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts @@ -6,8 +6,6 @@ const MAX_FLAGS_PER_SPAN = 10; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', sampleRate: 1.0, - // // disable attaching headers to /test/* endpoints - // tracePropagationTargets: [/^(?!.*test).*$/], tracesSampleRate: 1.0, transport: loggingTransport, integrations: [Sentry.featureFlagsIntegration()], diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts index 3bdf6c113555..0bbb77296a58 100644 --- a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts +++ b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts @@ -548,57 +548,57 @@ describe('Integration | Transactions', () => { expect(finishedSpans.length).toBe(0); }); -it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { - const timeout = 5 * 60 * 1000; - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now); + it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { + const timeout = 5 * 60 * 1000; + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); - const logs: unknown[] = []; - vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); - const transactions: Event[] = []; + const transactions: Event[] = []; - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction: event => { - transactions.push(event); - return null; - }, - }); + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); - const provider = getProvider(); - const spanProcessor = getSpanProcessor(); + const provider = getProvider(); + const spanProcessor = getSpanProcessor(); - const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; - if (!exporter) { - throw new Error('No exporter found, aborting test...'); - } + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } - startSpanManual({ name: 'test name' }, async span => { - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); + startSpanManual({ name: 'test name' }, async span => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); - const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); + const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); - span.end(); + span.end(); - setTimeout(() => { - subSpan2.end(); - }, timeout - 2); - }); + setTimeout(() => { + subSpan2.end(); + }, timeout - 2); + }); - vi.advanceTimersByTime(timeout - 1); + vi.advanceTimersByTime(timeout - 1); - expect(transactions).toHaveLength(2); - expect(transactions[0]?.spans).toHaveLength(1); + expect(transactions).toHaveLength(2); + expect(transactions[0]?.spans).toHaveLength(1); - const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => - bucket ? Array.from(bucket.spans) : [], - ); - expect(finishedSpans.length).toBe(0); -}); + const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => + bucket ? Array.from(bucket.spans) : [], + ); + expect(finishedSpans.length).toBe(0); + }); it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => { const timeout = 5 * 60 * 1000; diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index c68a8d889bda..568dc23fd21d 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -1,8 +1,7 @@ -import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_copyFlagsFromScopeToEvent, - _INTERNAL_freezeSpanFeatureFlags, _INTERNAL_insertFlagToScope, defineIntegration, } from '@sentry/core'; @@ -27,12 +26,6 @@ export const launchDarklyIntegration = defineIntegration(() => { return { name: 'LaunchDarkly', - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - _INTERNAL_freezeSpanFeatureFlags(span); - }); - }, - processEvent(event: Event, _hint: EventHint, _client: Client): Event { return _INTERNAL_copyFlagsFromScopeToEvent(event); }, diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index ffe5bbbbee72..962e462b9df7 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -13,11 +13,10 @@ * OpenFeature.addHooks(new Sentry.OpenFeatureIntegrationHook()); * ``` */ -import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_copyFlagsFromScopeToEvent, - _INTERNAL_freezeSpanFeatureFlags, _INTERNAL_insertFlagToScope, defineIntegration, } from '@sentry/core'; @@ -27,12 +26,6 @@ export const openFeatureIntegration = defineIntegration(() => { return { name: 'OpenFeature', - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - _INTERNAL_freezeSpanFeatureFlags(span); - }); - }, - processEvent(event: Event, _hint: EventHint, _client: Client): Event { return _INTERNAL_copyFlagsFromScopeToEvent(event); }, diff --git a/packages/browser/src/integrations/featureFlags/statsig/integration.ts b/packages/browser/src/integrations/featureFlags/statsig/integration.ts index b57d6befb186..897fe193e515 100644 --- a/packages/browser/src/integrations/featureFlags/statsig/integration.ts +++ b/packages/browser/src/integrations/featureFlags/statsig/integration.ts @@ -1,8 +1,7 @@ -import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_copyFlagsFromScopeToEvent, - _INTERNAL_freezeSpanFeatureFlags, _INTERNAL_insertFlagToScope, defineIntegration, } from '@sentry/core'; @@ -36,11 +35,7 @@ export const statsigIntegration = defineIntegration( return { name: 'Statsig', - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - _INTERNAL_freezeSpanFeatureFlags(span); - }); - + setup(_client: Client) { statsigClient.on('gate_evaluation', (event: { gate: FeatureGate }) => { _INTERNAL_insertFlagToScope(event.gate.name, event.gate.value); _INTERNAL_bufferSpanFeatureFlag(event.gate.name, event.gate.value); diff --git a/packages/browser/src/integrations/featureFlags/unleash/integration.ts b/packages/browser/src/integrations/featureFlags/unleash/integration.ts index e861d066ca1c..014f26a9d4bb 100644 --- a/packages/browser/src/integrations/featureFlags/unleash/integration.ts +++ b/packages/browser/src/integrations/featureFlags/unleash/integration.ts @@ -1,8 +1,7 @@ -import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_copyFlagsFromScopeToEvent, - _INTERNAL_freezeSpanFeatureFlags, _INTERNAL_insertFlagToScope, defineIntegration, fill, @@ -42,12 +41,6 @@ export const unleashIntegration = defineIntegration( return { name: 'Unleash', - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - _INTERNAL_freezeSpanFeatureFlags(span); - }); - }, - setupOnce() { const unleashClientPrototype = unleashClientClass.prototype as UnleashClient; fill(unleashClientPrototype, 'isEnabled', _wrappedIsEnabled); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3764fe0f30b0..9f78873ecfb7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -129,7 +129,6 @@ export { _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, _INTERNAL_bufferSpanFeatureFlag, - _INTERNAL_freezeSpanFeatureFlags, } from './utils/featureFlags'; export { applyAggregateErrorsToEvent } from './utils-hoist/aggregate-errors'; diff --git a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts index 21298989d24c..880bfc033cef 100644 --- a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts +++ b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -2,11 +2,9 @@ import { type Client } from '../../client'; import { defineIntegration } from '../../integration'; import { type Event, type EventHint } from '../../types-hoist/event'; import { type Integration, type IntegrationFn } from '../../types-hoist/integration'; -import { type Span } from '../../types-hoist/span'; import { _INTERNAL_bufferSpanFeatureFlag, _INTERNAL_copyFlagsFromScopeToEvent, - _INTERNAL_freezeSpanFeatureFlags, _INTERNAL_insertFlagToScope, } from '../../utils/featureFlags'; @@ -42,12 +40,6 @@ export const featureFlagsIntegration = defineIntegration(() => { return { name: 'FeatureFlags', - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - _INTERNAL_freezeSpanFeatureFlags(span); - }); - }, - processEvent(event: Event, _hint: EventHint, _client: Client): Event { return _INTERNAL_copyFlagsFromScopeToEvent(event); }, diff --git a/packages/core/src/utils-hoist/worldwide.ts b/packages/core/src/utils-hoist/worldwide.ts index 69e64c7ac98d..70196e4b0c8b 100644 --- a/packages/core/src/utils-hoist/worldwide.ts +++ b/packages/core/src/utils-hoist/worldwide.ts @@ -14,7 +14,6 @@ import type { Carrier } from '../carrier'; import type { Client } from '../client'; -import type { FeatureFlag } from '../featureFlags'; import type { SerializedLog } from '../types-hoist/log'; import type { Span } from '../types-hoist/span'; import type { SdkSource } from './env'; @@ -59,9 +58,9 @@ export type InternalGlobal = { _sentryModuleMetadata?: Record; _sentryEsmLoaderHookRegistered?: boolean; /** - * A map of spans to feature flag buffers. Populated by feature flag integrations. + * A map of spans to evaluated feature flags. Populated by feature flag integrations. */ - _spanToFlagBufferMap?: WeakMap; + _spanToFlagBufferMap?: WeakMap>; } & Carrier; /** Get's the global object for the current JavaScript runtime */ diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts index ced582feef43..618b879d65cf 100644 --- a/packages/core/src/utils/featureFlags.ts +++ b/packages/core/src/utils/featureFlags.ts @@ -24,7 +24,7 @@ const FLAG_BUFFER_SIZE = 100; const MAX_FLAGS_PER_SPAN = 10; // Global map of spans to feature flag buffers. Populated by feature flag integrations. -GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap(); +GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap>(); const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.'; @@ -121,8 +121,9 @@ export function _INTERNAL_insertToFlagBuffer( } /** - * Records a feature flag evaluation for the active span, adding it to a weak map of flag buffers. This is a no-op for non-boolean values. - * The keys in each buffer are unique. Once the buffer for a span reaches maxFlagsPerSpan, subsequent flags are dropped. + * Records a feature flag evaluation for the active span. This is a no-op for non-boolean values. + * The flag and its value is stored in span attributes with the `flag.evaluation` prefix. Once the + * unique flags for a span reaches maxFlagsPerSpan, subsequent flags are dropped. * * @param name Name of the feature flag. * @param value Value of the feature flag. Non-boolean values are ignored. @@ -140,22 +141,13 @@ export function _INTERNAL_bufferSpanFeatureFlag( const span = getActiveSpan(); if (span) { - const flags = spanFlagMap.get(span) || []; - _INTERNAL_insertToFlagBuffer(flags, name, value, maxFlagsPerSpan, false); + const flags = spanFlagMap.get(span) || new Set(); + if (flags.has(name)) { + span.setAttribute(`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}`, value); + } else if (flags.size < maxFlagsPerSpan) { + flags.add(name); + span.setAttribute(`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}`, value); + } spanFlagMap.set(span, flags); } } - -/** - * Add the buffered feature flags for a span to the span attributes. Call this on span end. - * - * @param span Span to add flags to. - */ -export function _INTERNAL_freezeSpanFeatureFlags(span: Span): void { - const flags = GLOBAL_OBJ._spanToFlagBufferMap?.get(span); - if (flags) { - span.setAttributes( - Object.fromEntries(flags.map(flag => [`${SPAN_FLAG_ATTRIBUTE_PREFIX}${flag.flag}`, flag.result])), - ); - } -} From 52385ee2de1cbde80815f5df560d8bc612eb53c4 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:00:09 -0700 Subject: [PATCH 20/29] Rename util --- .../integrations/featureFlags/launchdarkly/integration.ts | 4 ++-- .../integrations/featureFlags/openfeature/integration.ts | 6 +++--- .../src/integrations/featureFlags/statsig/integration.ts | 4 ++-- .../src/integrations/featureFlags/unleash/integration.ts | 4 ++-- packages/core/src/index.ts | 2 +- .../integrations/featureFlags/featureFlagsIntegration.ts | 4 ++-- packages/core/src/utils/featureFlags.ts | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index 568dc23fd21d..822e4b1d7f80 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -1,6 +1,6 @@ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { - _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_addFeatureFlagToActiveSpan, _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, defineIntegration, @@ -50,7 +50,7 @@ export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler */ method: (flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext) => { _INTERNAL_insertFlagToScope(flagKey, flagDetail.value); - _INTERNAL_bufferSpanFeatureFlag(flagKey, flagDetail.value); + _INTERNAL_addFeatureFlagToActiveSpan(flagKey, flagDetail.value); }, }; } diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index 962e462b9df7..85aedbf779f9 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -15,7 +15,7 @@ */ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { - _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_addFeatureFlagToActiveSpan, _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, defineIntegration, @@ -41,7 +41,7 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook { */ public after(_hookContext: Readonly>, evaluationDetails: EvaluationDetails): void { _INTERNAL_insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value); - _INTERNAL_bufferSpanFeatureFlag(evaluationDetails.flagKey, evaluationDetails.value); + _INTERNAL_addFeatureFlagToActiveSpan(evaluationDetails.flagKey, evaluationDetails.value); } /** @@ -49,6 +49,6 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook { */ public error(hookContext: Readonly>, _error: unknown, _hookHints?: HookHints): void { _INTERNAL_insertFlagToScope(hookContext.flagKey, hookContext.defaultValue); - _INTERNAL_bufferSpanFeatureFlag(hookContext.flagKey, hookContext.defaultValue); + _INTERNAL_addFeatureFlagToActiveSpan(hookContext.flagKey, hookContext.defaultValue); } } diff --git a/packages/browser/src/integrations/featureFlags/statsig/integration.ts b/packages/browser/src/integrations/featureFlags/statsig/integration.ts index 897fe193e515..9aef234045b5 100644 --- a/packages/browser/src/integrations/featureFlags/statsig/integration.ts +++ b/packages/browser/src/integrations/featureFlags/statsig/integration.ts @@ -1,6 +1,6 @@ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { - _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_addFeatureFlagToActiveSpan, _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, defineIntegration, @@ -38,7 +38,7 @@ export const statsigIntegration = defineIntegration( setup(_client: Client) { statsigClient.on('gate_evaluation', (event: { gate: FeatureGate }) => { _INTERNAL_insertFlagToScope(event.gate.name, event.gate.value); - _INTERNAL_bufferSpanFeatureFlag(event.gate.name, event.gate.value); + _INTERNAL_addFeatureFlagToActiveSpan(event.gate.name, event.gate.value); }); }, diff --git a/packages/browser/src/integrations/featureFlags/unleash/integration.ts b/packages/browser/src/integrations/featureFlags/unleash/integration.ts index 014f26a9d4bb..699c797edecf 100644 --- a/packages/browser/src/integrations/featureFlags/unleash/integration.ts +++ b/packages/browser/src/integrations/featureFlags/unleash/integration.ts @@ -1,6 +1,6 @@ import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { - _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_addFeatureFlagToActiveSpan, _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, defineIntegration, @@ -71,7 +71,7 @@ function _wrappedIsEnabled( if (typeof toggleName === 'string' && typeof result === 'boolean') { _INTERNAL_insertFlagToScope(toggleName, result); - _INTERNAL_bufferSpanFeatureFlag(toggleName, result); + _INTERNAL_addFeatureFlagToActiveSpan(toggleName, result); } else if (DEBUG_BUILD) { logger.error( `[Feature Flags] UnleashClient.isEnabled does not match expected signature. arg0: ${toggleName} (${typeof toggleName}), result: ${result} (${typeof result})`, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f78873ecfb7..3d87729f4639 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -128,7 +128,7 @@ export type { FeatureFlag } from './featureFlags'; export { _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, - _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_addFeatureFlagToActiveSpan, } from './utils/featureFlags'; export { applyAggregateErrorsToEvent } from './utils-hoist/aggregate-errors'; diff --git a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts index 880bfc033cef..fddc1944ceab 100644 --- a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts +++ b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -3,7 +3,7 @@ import { defineIntegration } from '../../integration'; import { type Event, type EventHint } from '../../types-hoist/event'; import { type Integration, type IntegrationFn } from '../../types-hoist/integration'; import { - _INTERNAL_bufferSpanFeatureFlag, + _INTERNAL_addFeatureFlagToActiveSpan, _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, } from '../../utils/featureFlags'; @@ -46,7 +46,7 @@ export const featureFlagsIntegration = defineIntegration(() => { addFeatureFlag(name: string, value: unknown): void { _INTERNAL_insertFlagToScope(name, value); - _INTERNAL_bufferSpanFeatureFlag(name, value); + _INTERNAL_addFeatureFlagToActiveSpan(name, value); }, }; }) as IntegrationFn; diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts index 618b879d65cf..ee9146e630b5 100644 --- a/packages/core/src/utils/featureFlags.ts +++ b/packages/core/src/utils/featureFlags.ts @@ -129,7 +129,7 @@ export function _INTERNAL_insertToFlagBuffer( * @param value Value of the feature flag. Non-boolean values are ignored. * @param maxFlagsPerSpan Max number of flags a buffer should store. Default value should always be used in production. */ -export function _INTERNAL_bufferSpanFeatureFlag( +export function _INTERNAL_addFeatureFlagToActiveSpan( name: string, value: unknown, maxFlagsPerSpan: number = MAX_FLAGS_PER_SPAN, From 90d9289b7b14753c947c1ac52006252ffbb57124 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:05:26 -0700 Subject: [PATCH 21/29] Move FF type to core utils file --- packages/core/src/featureFlags.ts | 1 - packages/core/src/index.ts | 3 +-- packages/core/src/types-hoist/context.ts | 2 +- packages/core/src/utils/featureFlags.ts | 3 ++- 4 files changed, 4 insertions(+), 5 deletions(-) delete mode 100644 packages/core/src/featureFlags.ts diff --git a/packages/core/src/featureFlags.ts b/packages/core/src/featureFlags.ts deleted file mode 100644 index f80e17ef7f9d..000000000000 --- a/packages/core/src/featureFlags.ts +++ /dev/null @@ -1 +0,0 @@ -export type FeatureFlag = { readonly flag: string; readonly result: boolean }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3d87729f4639..4bbbf67f6e32 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -123,8 +123,7 @@ export type { ReportDialogOptions } from './report-dialog'; export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/exports'; export { consoleLoggingIntegration } from './logs/console-integration'; -export type { FeatureFlag } from './featureFlags'; -// TODO: ^export from utils? +export type { FeatureFlag } from './utils/featureFlags'; export { _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, diff --git a/packages/core/src/types-hoist/context.ts b/packages/core/src/types-hoist/context.ts index 0ad6eebf6ac3..a3673f84a968 100644 --- a/packages/core/src/types-hoist/context.ts +++ b/packages/core/src/types-hoist/context.ts @@ -1,4 +1,4 @@ -import type { FeatureFlag } from '../featureFlags'; +import type { FeatureFlag } from '../utils/featureFlags'; import type { SpanLinkJSON } from './link'; import type { Primitive } from './misc'; import type { SpanOrigin } from './span'; diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts index ee9146e630b5..ba9145c316e8 100644 --- a/packages/core/src/utils/featureFlags.ts +++ b/packages/core/src/utils/featureFlags.ts @@ -1,6 +1,5 @@ import { getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import { type FeatureFlag } from '../featureFlags'; import { type Event } from '../types-hoist/event'; import { type Span } from '../types-hoist/span'; import { logger } from '../utils-hoist/logger'; @@ -13,6 +12,8 @@ import { getActiveSpan } from './spanUtils'; * from oldest to newest. */ +export type FeatureFlag = { readonly flag: string; readonly result: boolean }; + /** * Max size of the LRU flag buffer stored in Sentry scope and event contexts. */ From bca65ddea6b69d6f79a7023b626c1f8d143be3e2 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:55:47 -0700 Subject: [PATCH 22/29] Export buffer sizes from core as _INTERNAL_ --- .../suites/integrations/featureFlags/constants.ts | 3 --- .../featureFlags/featureFlags/onError/basic/test.ts | 2 +- .../featureFlags/featureFlags/onSpan/test.ts | 2 +- .../featureFlags/launchdarkly/onError/basic/test.ts | 2 +- .../featureFlags/launchdarkly/onSpan/test.ts | 2 +- .../featureFlags/openfeature/onError/basic/test.ts | 2 +- .../openfeature/onError/errorHook/test.ts | 2 +- .../featureFlags/openfeature/onSpan/test.ts | 2 +- .../featureFlags/statsig/onError/basic/test.ts | 2 +- .../integrations/featureFlags/statsig/onSpan/test.ts | 2 +- .../featureFlags/unleash/onError/basic/test.ts | 2 +- .../integrations/featureFlags/unleash/onSpan/test.ts | 2 +- .../featureFlagsIntegration/onError/basic/test.ts | 3 +-- .../featureFlagsIntegration/onSpan/test.ts | 3 +-- packages/core/src/index.ts | 2 ++ packages/core/src/utils/featureFlags.ts | 12 ++++++++---- 16 files changed, 23 insertions(+), 22 deletions(-) delete mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts deleted file mode 100644 index 712b7270b52c..000000000000 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Corresponds to constants in featureFlags.ts, in @sentry/core utils. -export const FLAG_BUFFER_SIZE = 100; -export const MAX_FLAGS_PER_SPAN = 10; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts index 742cdd42109b..3233c9047649 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts @@ -1,11 +1,11 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest, } from '../../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts index 476b76d03475..6516d8e36abb 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; import { sentryTest } from '../../../../../utils/fixtures'; import { type EventAndTraceHeader, @@ -7,7 +8,6 @@ import { shouldSkipFeatureFlagsTest, shouldSkipTracingTest, } from '../../../../../utils/helpers'; -import { MAX_FLAGS_PER_SPAN } from '../../constants'; sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts index 1c1a04595187..bc3e0afdc292 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onError/basic/test.ts @@ -1,11 +1,11 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest, } from '../../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts index 965f00f91fa0..eb7eb003c838 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; import { sentryTest } from '../../../../../utils/fixtures'; import { type EventAndTraceHeader, @@ -7,7 +8,6 @@ import { shouldSkipFeatureFlagsTest, shouldSkipTracingTest, } from '../../../../../utils/helpers'; -import { MAX_FLAGS_PER_SPAN } from '../../constants'; sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts index 84deca47415d..5953f1e0b087 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/basic/test.ts @@ -1,11 +1,11 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest, } from '../../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/test.ts index c2de7f54abd7..89654c82eda1 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onError/errorHook/test.ts @@ -1,11 +1,11 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest, } from '../../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Flag evaluation error hook', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts index f3b43425477f..5ade5d01b3d5 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; import { sentryTest } from '../../../../../utils/fixtures'; import { type EventAndTraceHeader, @@ -7,7 +8,6 @@ import { shouldSkipFeatureFlagsTest, shouldSkipTracingTest, } from '../../../../../utils/helpers'; -import { MAX_FLAGS_PER_SPAN } from '../../constants'; sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts index 331dbb8ad433..134b29417d53 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onError/basic/test.ts @@ -1,11 +1,11 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest, } from '../../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts index dec534f9ffab..1ea192f98850 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; import { sentryTest } from '../../../../../utils/fixtures'; import { type EventAndTraceHeader, @@ -7,7 +8,6 @@ import { shouldSkipFeatureFlagsTest, shouldSkipTracingTest, } from '../../../../../utils/helpers'; -import { MAX_FLAGS_PER_SPAN } from '../../constants'; sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts index 341bbbd03e96..6e2760b69600 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onError/basic/test.ts @@ -1,11 +1,11 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest, } from '../../../../../../utils/helpers'; -import { FLAG_BUFFER_SIZE } from '../../../constants'; sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts index b2607ffa4c07..984bba3bc0e3 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; import { sentryTest } from '../../../../../utils/fixtures'; import { type EventAndTraceHeader, @@ -7,7 +8,6 @@ import { shouldSkipFeatureFlagsTest, shouldSkipTracingTest, } from '../../../../../utils/helpers'; -import { MAX_FLAGS_PER_SPAN } from '../../constants'; sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/test.ts index 6b003bdd7a6e..74ff1c125b45 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/test.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/test.ts @@ -1,8 +1,7 @@ +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; import { afterAll, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../../utils/runner'; -const FLAG_BUFFER_SIZE = 100; - afterAll(() => { cleanupChildProcesses(); }); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts index 6828caa83d2e..4a417a3c3959 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts @@ -1,8 +1,7 @@ +import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; import { afterAll, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -const MAX_FLAGS_PER_SPAN = 10; - afterAll(() => { cleanupChildProcesses(); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4bbbf67f6e32..d880773a7891 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -128,6 +128,8 @@ export { _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, _INTERNAL_addFeatureFlagToActiveSpan, + _INTERNAL_FLAG_BUFFER_SIZE, + _INTERNAL_MAX_FLAGS_PER_SPAN, } from './utils/featureFlags'; export { applyAggregateErrorsToEvent } from './utils-hoist/aggregate-errors'; diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts index ba9145c316e8..302885bf3792 100644 --- a/packages/core/src/utils/featureFlags.ts +++ b/packages/core/src/utils/featureFlags.ts @@ -17,12 +17,12 @@ export type FeatureFlag = { readonly flag: string; readonly result: boolean }; /** * Max size of the LRU flag buffer stored in Sentry scope and event contexts. */ -const FLAG_BUFFER_SIZE = 100; +export const _INTERNAL_FLAG_BUFFER_SIZE = 100; /** * Max number of flag evaluations to record per span. */ -const MAX_FLAGS_PER_SPAN = 10; +export const _INTERNAL_MAX_FLAGS_PER_SPAN = 10; // Global map of spans to feature flag buffers. Populated by feature flag integrations. GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap>(); @@ -59,7 +59,11 @@ export function _INTERNAL_copyFlagsFromScopeToEvent(event: Event): Event { * @param value Value of the feature flag. * @param maxSize Max number of flags the buffer should store. Default value should always be used in production. */ -export function _INTERNAL_insertFlagToScope(name: string, value: unknown, maxSize: number = FLAG_BUFFER_SIZE): void { +export function _INTERNAL_insertFlagToScope( + name: string, + value: unknown, + maxSize: number = _INTERNAL_FLAG_BUFFER_SIZE, +): void { const scopeContexts = getCurrentScope().getScopeData().contexts; if (!scopeContexts.flags) { scopeContexts.flags = { values: [] }; @@ -133,7 +137,7 @@ export function _INTERNAL_insertToFlagBuffer( export function _INTERNAL_addFeatureFlagToActiveSpan( name: string, value: unknown, - maxFlagsPerSpan: number = MAX_FLAGS_PER_SPAN, + maxFlagsPerSpan: number = _INTERNAL_MAX_FLAGS_PER_SPAN, ): void { const spanFlagMap = GLOBAL_OBJ._spanToFlagBufferMap; if (!spanFlagMap || typeof value !== 'boolean') { From db2fa33f6e5e20c8c7b98c4a51d22aaf8295ea13 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 14 Jun 2025 17:31:40 -0700 Subject: [PATCH 23/29] Remove allowEviction --- packages/core/src/utils/featureFlags.ts | 10 ++----- .../core/test/lib/utils/featureFlags.test.ts | 26 ++++--------------- 2 files changed, 7 insertions(+), 29 deletions(-) diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts index 302885bf3792..388613394148 100644 --- a/packages/core/src/utils/featureFlags.ts +++ b/packages/core/src/utils/featureFlags.ts @@ -83,14 +83,12 @@ export function _INTERNAL_insertFlagToScope( * @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. Default value should always be used in production. - * @param allowEviction If true, the oldest flag is evicted when the buffer is full. Otherwise the new flag is dropped. */ export function _INTERNAL_insertToFlagBuffer( flags: FeatureFlag[], name: string, value: unknown, maxSize: number, - allowEviction: boolean = true, ): void { if (typeof value !== 'boolean') { return; @@ -110,12 +108,8 @@ export function _INTERNAL_insertToFlagBuffer( } if (flags.length === maxSize) { - if (allowEviction) { - // If at capacity, pop the earliest flag - O(n) - flags.shift(); - } else { - return; - } + // If at capacity, pop the earliest flag - O(n) + flags.shift(); } // Push the flag to the end - O(1) diff --git a/packages/core/test/lib/utils/featureFlags.test.ts b/packages/core/test/lib/utils/featureFlags.test.ts index c14a9c4240c8..14258c2caf36 100644 --- a/packages/core/test/lib/utils/featureFlags.test.ts +++ b/packages/core/test/lib/utils/featureFlags.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getCurrentScope } from '../../../src/currentScopes'; -import { type FeatureFlag } from '../../../src/featureFlags'; -import { _INTERNAL_insertFlagToScope, _INTERNAL_insertToFlagBuffer } from '../../../src/utils/featureFlags'; +import { + type FeatureFlag, + _INTERNAL_insertFlagToScope, + _INTERNAL_insertToFlagBuffer, +} from '../../../src/utils/featureFlags'; import { logger } from '../../../src/utils-hoist/logger'; describe('flags', () => { @@ -60,25 +63,6 @@ describe('flags', () => { ]); }); - it('drops new entries when allowEviction is false and buffer is full', () => { - const buffer: FeatureFlag[] = []; - const maxSize = 0; - _INTERNAL_insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); - _INTERNAL_insertToFlagBuffer(buffer, 'feat2', true, maxSize, false); - _INTERNAL_insertToFlagBuffer(buffer, 'feat3', true, maxSize, false); - - expect(buffer).toEqual([]); - }); - - it('still updates order and values when allowEviction is false and buffer is full', () => { - const buffer: FeatureFlag[] = []; - const maxSize = 1; - _INTERNAL_insertToFlagBuffer(buffer, 'feat1', false, maxSize, false); - _INTERNAL_insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); - - expect(buffer).toEqual([{ flag: 'feat1', result: true }]); - }); - it('does not allocate unnecessary space', () => { const buffer: FeatureFlag[] = []; const maxSize = 1000; From 2b385a47c8f7897a8938f285c100213b10b736a6 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 14 Jun 2025 17:46:29 -0700 Subject: [PATCH 24/29] Set attr directly on eval instead of in hook --- .../featureFlags/featureFlags/onSpan/test.ts | 11 +++---- .../featureFlags/launchdarkly/onSpan/test.ts | 11 +++---- .../featureFlags/openfeature/onSpan/test.ts | 11 +++---- .../featureFlags/statsig/onSpan/test.ts | 11 +++---- .../featureFlags/unleash/onSpan/test.ts | 11 +++---- .../featureFlags/featureFlagsIntegration.ts | 15 ++------- .../featureFlags/launchdarkly/integration.ts | 11 ++----- .../featureFlags/openfeature/integration.ts | 13 ++------ .../featureFlags/statsig/integration.ts | 15 ++------- .../featureFlags/unleash/integration.ts | 11 ++----- packages/browser/src/utils/featureFlags.ts | 32 +++++++------------ packages/core/src/utils-hoist/worldwide.ts | 4 +-- 12 files changed, 46 insertions(+), 110 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts index e1ed9e96b1e5..476b76d03475 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts @@ -58,12 +58,9 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a expect(innerSpanFlags).toEqual([]); const expectedOuterSpanFlags = []; - for (let i = 1; i <= 2; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); } - for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); - } - expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); - expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); + // Order agnostic (attribute dict is unordered). + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts index a49191f4d4a3..965f00f91fa0 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts @@ -58,12 +58,9 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a expect(innerSpanFlags).toEqual([]); const expectedOuterSpanFlags = []; - for (let i = 1; i <= 2; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); } - for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); - } - expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); - expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); + // Order agnostic (attribute dict is unordered). + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts index 793b2ed6fffd..f3b43425477f 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts @@ -58,12 +58,9 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a expect(innerSpanFlags).toEqual([]); const expectedOuterSpanFlags = []; - for (let i = 1; i <= 2; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); } - for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); - } - expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); - expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); + // Order agnostic (attribute dict is unordered). + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts index 384f59620cdf..dec534f9ffab 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts @@ -62,12 +62,9 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a expect(innerSpanFlags).toEqual([]); const expectedOuterSpanFlags = []; - for (let i = 1; i <= 2; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); } - for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); - } - expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); - expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); + // Order agnostic (attribute dict is unordered). + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts index f3cf4c624369..b2607ffa4c07 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/test.ts @@ -60,12 +60,9 @@ sentryTest("Feature flags are added to active span's attributes on span end.", a expect(innerSpanFlags).toEqual([]); const expectedOuterSpanFlags = []; - for (let i = 1; i <= 2; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); } - for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) { - expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]); - } - expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]); - expect(outerSpanFlags).toEqual(expectedOuterSpanFlags); + // Order agnostic (attribute dict is unordered). + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); }); diff --git a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts index 549c99af2c13..f7a1e0bfd1c3 100644 --- a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts +++ b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -1,11 +1,6 @@ import type { Client, Event, EventHint, Integration, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { - bufferSpanFeatureFlag, - copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, - insertFlagToScope, -} from '../../utils/featureFlags'; +import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../utils/featureFlags'; export interface FeatureFlagsIntegration extends Integration { addFeatureFlag: (name: string, value: unknown) => void; @@ -39,19 +34,13 @@ export const featureFlagsIntegration = defineIntegration(() => { return { name: 'FeatureFlags', - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); - }); - }, - processEvent(event: Event, _hint: EventHint, _client: Client): Event { return copyFlagsFromScopeToEvent(event); }, addFeatureFlag(name: string, value: unknown): void { insertFlagToScope(name, value); - bufferSpanFeatureFlag(name, value); + addFeatureFlagToActiveSpan(name, value); }, }; }) as IntegrationFn; diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index caa860574ce4..91e06e77d18f 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -1,9 +1,8 @@ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { - bufferSpanFeatureFlag, + addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, insertFlagToScope, } from '../../../utils/featureFlags'; import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; @@ -27,12 +26,6 @@ export const launchDarklyIntegration = defineIntegration(() => { return { name: 'LaunchDarkly', - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); - }); - }, - processEvent(event: Event, _hint: EventHint, _client: Client): Event { return copyFlagsFromScopeToEvent(event); }, @@ -57,7 +50,7 @@ export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler */ method: (flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext) => { insertFlagToScope(flagKey, flagDetail.value); - bufferSpanFeatureFlag(flagKey, flagDetail.value); + addFeatureFlagToActiveSpan(flagKey, flagDetail.value); }, }; } diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index bd5efd1e282f..108fadfe7146 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -16,9 +16,8 @@ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { - bufferSpanFeatureFlag, + addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, insertFlagToScope, } from '../../../utils/featureFlags'; import type { EvaluationDetails, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types'; @@ -27,12 +26,6 @@ export const openFeatureIntegration = defineIntegration(() => { return { name: 'OpenFeature', - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); - }); - }, - processEvent(event: Event, _hint: EventHint, _client: Client): Event { return copyFlagsFromScopeToEvent(event); }, @@ -48,7 +41,7 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook { */ public after(_hookContext: Readonly>, evaluationDetails: EvaluationDetails): void { insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value); - bufferSpanFeatureFlag(evaluationDetails.flagKey, evaluationDetails.value); + addFeatureFlagToActiveSpan(evaluationDetails.flagKey, evaluationDetails.value); } /** @@ -56,6 +49,6 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook { */ public error(hookContext: Readonly>, _error: unknown, _hookHints?: HookHints): void { insertFlagToScope(hookContext.flagKey, hookContext.defaultValue); - bufferSpanFeatureFlag(hookContext.flagKey, hookContext.defaultValue); + addFeatureFlagToActiveSpan(hookContext.flagKey, hookContext.defaultValue); } } diff --git a/packages/browser/src/integrations/featureFlags/statsig/integration.ts b/packages/browser/src/integrations/featureFlags/statsig/integration.ts index 641e7412e6a8..082b028f92cb 100644 --- a/packages/browser/src/integrations/featureFlags/statsig/integration.ts +++ b/packages/browser/src/integrations/featureFlags/statsig/integration.ts @@ -1,11 +1,6 @@ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { - bufferSpanFeatureFlag, - copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, - insertFlagToScope, -} from '../../../utils/featureFlags'; +import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; import type { FeatureGate, StatsigClient } from './types'; /** @@ -36,14 +31,10 @@ export const statsigIntegration = defineIntegration( return { name: 'Statsig', - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); - }); - + setup(_client: Client) { statsigClient.on('gate_evaluation', (event: { gate: FeatureGate }) => { insertFlagToScope(event.gate.name, event.gate.value); - bufferSpanFeatureFlag(event.gate.name, event.gate.value); + addFeatureFlagToActiveSpan(event.gate.name, event.gate.value); }); }, diff --git a/packages/browser/src/integrations/featureFlags/unleash/integration.ts b/packages/browser/src/integrations/featureFlags/unleash/integration.ts index 75559dd3841b..b24a27e8c1bc 100644 --- a/packages/browser/src/integrations/featureFlags/unleash/integration.ts +++ b/packages/browser/src/integrations/featureFlags/unleash/integration.ts @@ -2,9 +2,8 @@ import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core import { defineIntegration, fill, logger } from '@sentry/core'; import { DEBUG_BUILD } from '../../../debug-build'; import { - bufferSpanFeatureFlag, + addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, - freezeSpanFeatureFlags, insertFlagToScope, } from '../../../utils/featureFlags'; import type { UnleashClient, UnleashClientClass } from './types'; @@ -40,12 +39,6 @@ export const unleashIntegration = defineIntegration( return { name: 'Unleash', - setup(client: Client) { - client.on('spanEnd', (span: Span) => { - freezeSpanFeatureFlags(span); - }); - }, - setupOnce() { const unleashClientPrototype = unleashClientClass.prototype as UnleashClient; fill(unleashClientPrototype, 'isEnabled', _wrappedIsEnabled); @@ -76,7 +69,7 @@ function _wrappedIsEnabled( if (typeof toggleName === 'string' && typeof result === 'boolean') { insertFlagToScope(toggleName, result); - bufferSpanFeatureFlag(toggleName, result); + addFeatureFlagToActiveSpan(toggleName, result); } else if (DEBUG_BUILD) { logger.error( `[Feature Flags] UnleashClient.isEnabled does not match expected signature. arg0: ${toggleName} (${typeof toggleName}), result: ${result} (${typeof result})`, diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index 49ee8ee14a48..83120daef930 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -19,7 +19,7 @@ export const FLAG_BUFFER_SIZE = 100; export const MAX_FLAGS_PER_SPAN = 10; // Global map of spans to feature flag buffers. Populated by feature flag integrations. -GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap(); +GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap>(); const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.'; @@ -116,14 +116,15 @@ export function insertToFlagBuffer( } /** - * Records a feature flag evaluation for the active span, adding it to a weak map of flag buffers. This is a no-op for non-boolean values. - * The keys in each buffer are unique. Once the buffer for a span reaches maxFlagsPerSpan, subsequent flags are dropped. + * Records a feature flag evaluation for the active span. This is a no-op for non-boolean values. + * The flag and its value is stored in span attributes with the `flag.evaluation` prefix. Once the + * unique flags for a span reaches maxFlagsPerSpan, subsequent flags are dropped. * * @param name Name of the feature flag. * @param value Value of the feature flag. Non-boolean values are ignored. * @param maxFlagsPerSpan Max number of flags a buffer should store. Default value should always be used in production. */ -export function bufferSpanFeatureFlag( +export function addFeatureFlagToActiveSpan( name: string, value: unknown, maxFlagsPerSpan: number = MAX_FLAGS_PER_SPAN, @@ -135,22 +136,13 @@ export function bufferSpanFeatureFlag( const span = getActiveSpan(); if (span) { - const flags = spanFlagMap.get(span) || []; - insertToFlagBuffer(flags, name, value, maxFlagsPerSpan, false); + const flags = spanFlagMap.get(span) || new Set(); + if (flags.has(name)) { + span.setAttribute(`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}`, value); + } else if (flags.size < maxFlagsPerSpan) { + flags.add(name); + span.setAttribute(`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}`, value); + } spanFlagMap.set(span, flags); } } - -/** - * Add the buffered feature flags for a span to the span attributes. Call this on span end. - * - * @param span Span to add flags to. - */ -export function freezeSpanFeatureFlags(span: Span): void { - const flags = GLOBAL_OBJ._spanToFlagBufferMap?.get(span); - if (flags) { - span.setAttributes( - Object.fromEntries(flags.map(flag => [`${SPAN_FLAG_ATTRIBUTE_PREFIX}${flag.flag}`, flag.result])), - ); - } -} diff --git a/packages/core/src/utils-hoist/worldwide.ts b/packages/core/src/utils-hoist/worldwide.ts index 69e64c7ac98d..7b3ca9d49707 100644 --- a/packages/core/src/utils-hoist/worldwide.ts +++ b/packages/core/src/utils-hoist/worldwide.ts @@ -59,9 +59,9 @@ export type InternalGlobal = { _sentryModuleMetadata?: Record; _sentryEsmLoaderHookRegistered?: boolean; /** - * A map of spans to feature flag buffers. Populated by feature flag integrations. + * A map of spans to evaluated feature flags. Populated by feature flag integrations. */ - _spanToFlagBufferMap?: WeakMap; + _spanToFlagBufferMap?: WeakMap>; } & Carrier; /** Get's the global object for the current JavaScript runtime */ From 44324cc56458a078e42a4fed914812cd49ec8c7f Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 14 Jun 2025 17:48:02 -0700 Subject: [PATCH 25/29] Remove allowEviction --- packages/browser/src/utils/featureFlags.ts | 10 ++-------- .../browser/test/utils/featureFlags.test.ts | 19 ------------------- 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index 83120daef930..236cb73a2b32 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -73,14 +73,12 @@ export function insertFlagToScope(name: string, value: unknown, maxSize: number * @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. Default value should always be used in production. - * @param allowEviction If true, the oldest flag is evicted when the buffer is full. Otherwise the new flag is dropped. */ export function insertToFlagBuffer( flags: FeatureFlag[], name: string, value: unknown, maxSize: number, - allowEviction: boolean = true, ): void { if (typeof value !== 'boolean') { return; @@ -100,12 +98,8 @@ export function insertToFlagBuffer( } if (flags.length === maxSize) { - if (allowEviction) { - // If at capacity, pop the earliest flag - O(n) - flags.shift(); - } else { - return; - } + // If at capacity, pop the earliest flag - O(n) + flags.shift(); } // Push the flag to the end - O(1) diff --git a/packages/browser/test/utils/featureFlags.test.ts b/packages/browser/test/utils/featureFlags.test.ts index e60871832261..1c0bed312590 100644 --- a/packages/browser/test/utils/featureFlags.test.ts +++ b/packages/browser/test/utils/featureFlags.test.ts @@ -59,25 +59,6 @@ describe('flags', () => { ]); }); - it('drops new entries when allowEviction is false and buffer is full', () => { - const buffer: FeatureFlag[] = []; - const maxSize = 0; - insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); - insertToFlagBuffer(buffer, 'feat2', true, maxSize, false); - insertToFlagBuffer(buffer, 'feat3', true, maxSize, false); - - expect(buffer).toEqual([]); - }); - - it('still updates order and values when allowEviction is false and buffer is full', () => { - const buffer: FeatureFlag[] = []; - const maxSize = 1; - insertToFlagBuffer(buffer, 'feat1', false, maxSize, false); - insertToFlagBuffer(buffer, 'feat1', true, maxSize, false); - - expect(buffer).toEqual([{ flag: 'feat1', result: true }]); - }); - it('does not allocate unnecessary space', () => { const buffer: FeatureFlag[] = []; const maxSize = 1000; From ea6872a3ce6d44a74a8b29e25717d238306adc02 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 14 Jun 2025 17:52:49 -0700 Subject: [PATCH 26/29] Fix --- .../test/integration/transactions.test.ts | 76 +++++++++---------- .../featureFlags/featureFlagsIntegration.ts | 2 +- .../featureFlags/launchdarkly/integration.ts | 8 +- .../featureFlags/openfeature/integration.ts | 8 +- .../featureFlags/statsig/integration.ts | 2 +- .../featureFlags/unleash/integration.ts | 8 +- packages/browser/src/utils/featureFlags.ts | 7 +- packages/core/src/utils-hoist/worldwide.ts | 1 - 8 files changed, 47 insertions(+), 65 deletions(-) diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts index 3bdf6c113555..0bbb77296a58 100644 --- a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts +++ b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts @@ -548,57 +548,57 @@ describe('Integration | Transactions', () => { expect(finishedSpans.length).toBe(0); }); -it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { - const timeout = 5 * 60 * 1000; - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now); + it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { + const timeout = 5 * 60 * 1000; + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); - const logs: unknown[] = []; - vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); - const transactions: Event[] = []; + const transactions: Event[] = []; - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction: event => { - transactions.push(event); - return null; - }, - }); + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); - const provider = getProvider(); - const spanProcessor = getSpanProcessor(); + const provider = getProvider(); + const spanProcessor = getSpanProcessor(); - const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; - if (!exporter) { - throw new Error('No exporter found, aborting test...'); - } + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } - startSpanManual({ name: 'test name' }, async span => { - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); + startSpanManual({ name: 'test name' }, async span => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); - const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); + const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); - span.end(); + span.end(); - setTimeout(() => { - subSpan2.end(); - }, timeout - 2); - }); + setTimeout(() => { + subSpan2.end(); + }, timeout - 2); + }); - vi.advanceTimersByTime(timeout - 1); + vi.advanceTimersByTime(timeout - 1); - expect(transactions).toHaveLength(2); - expect(transactions[0]?.spans).toHaveLength(1); + expect(transactions).toHaveLength(2); + expect(transactions[0]?.spans).toHaveLength(1); - const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => - bucket ? Array.from(bucket.spans) : [], - ); - expect(finishedSpans.length).toBe(0); -}); + const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => + bucket ? Array.from(bucket.spans) : [], + ); + expect(finishedSpans.length).toBe(0); + }); it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => { const timeout = 5 * 60 * 1000; diff --git a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts index f7a1e0bfd1c3..e11084c84c2d 100644 --- a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts +++ b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -1,4 +1,4 @@ -import type { Client, Event, EventHint, Integration, IntegrationFn, Span } from '@sentry/core'; +import type { Client, Event, EventHint, Integration, IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../utils/featureFlags'; diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index 91e06e77d18f..eeb20dc07cf9 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -1,10 +1,6 @@ -import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { - addFeatureFlagToActiveSpan, - copyFlagsFromScopeToEvent, - insertFlagToScope, -} from '../../../utils/featureFlags'; +import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types'; /** diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index 108fadfe7146..79dc97394cce 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -13,13 +13,9 @@ * OpenFeature.addHooks(new Sentry.OpenFeatureIntegrationHook()); * ``` */ -import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { - addFeatureFlagToActiveSpan, - copyFlagsFromScopeToEvent, - insertFlagToScope, -} from '../../../utils/featureFlags'; +import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; import type { EvaluationDetails, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types'; export const openFeatureIntegration = defineIntegration(() => { diff --git a/packages/browser/src/integrations/featureFlags/statsig/integration.ts b/packages/browser/src/integrations/featureFlags/statsig/integration.ts index 082b028f92cb..ee472d77669a 100644 --- a/packages/browser/src/integrations/featureFlags/statsig/integration.ts +++ b/packages/browser/src/integrations/featureFlags/statsig/integration.ts @@ -1,4 +1,4 @@ -import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; import type { FeatureGate, StatsigClient } from './types'; diff --git a/packages/browser/src/integrations/featureFlags/unleash/integration.ts b/packages/browser/src/integrations/featureFlags/unleash/integration.ts index b24a27e8c1bc..ee7e2a3a0d4d 100644 --- a/packages/browser/src/integrations/featureFlags/unleash/integration.ts +++ b/packages/browser/src/integrations/featureFlags/unleash/integration.ts @@ -1,11 +1,7 @@ -import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core'; +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; import { defineIntegration, fill, logger } from '@sentry/core'; import { DEBUG_BUILD } from '../../../debug-build'; -import { - addFeatureFlagToActiveSpan, - copyFlagsFromScopeToEvent, - insertFlagToScope, -} from '../../../utils/featureFlags'; +import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags'; import type { UnleashClient, UnleashClientClass } from './types'; type UnleashIntegrationOptions = { diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts index 236cb73a2b32..9ae389773bcd 100644 --- a/packages/browser/src/utils/featureFlags.ts +++ b/packages/browser/src/utils/featureFlags.ts @@ -74,12 +74,7 @@ export function insertFlagToScope(name: string, value: unknown, maxSize: number * @param value Value of the feature flag. * @param maxSize Max number of flags the buffer should store. Default value should always be used in production. */ -export function insertToFlagBuffer( - flags: FeatureFlag[], - name: string, - value: unknown, - maxSize: number, -): void { +export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: unknown, maxSize: number): void { if (typeof value !== 'boolean') { return; } diff --git a/packages/core/src/utils-hoist/worldwide.ts b/packages/core/src/utils-hoist/worldwide.ts index 7b3ca9d49707..70196e4b0c8b 100644 --- a/packages/core/src/utils-hoist/worldwide.ts +++ b/packages/core/src/utils-hoist/worldwide.ts @@ -14,7 +14,6 @@ import type { Carrier } from '../carrier'; import type { Client } from '../client'; -import type { FeatureFlag } from '../featureFlags'; import type { SerializedLog } from '../types-hoist/log'; import type { Span } from '../types-hoist/span'; import type { SdkSource } from './env'; From 2ace2aecd7127dcd4e2abac3f1f0d95d88172401 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 14 Jun 2025 18:12:45 -0700 Subject: [PATCH 27/29] Fix merge --- .../featureFlags/launchdarkly/integration.ts | 5 ----- .../featureFlags/openfeature/integration.ts | 10 ---------- .../integrations/featureFlags/statsig/integration.ts | 5 ----- .../integrations/featureFlags/unleash/integration.ts | 5 ----- .../featureFlags/featureFlagsIntegration.ts | 5 ----- 5 files changed, 30 deletions(-) diff --git a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts index 01fc363d3c00..822e4b1d7f80 100644 --- a/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts +++ b/packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts @@ -49,13 +49,8 @@ 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) => { -<<<<<<< HEAD _INTERNAL_insertFlagToScope(flagKey, flagDetail.value); _INTERNAL_addFeatureFlagToActiveSpan(flagKey, flagDetail.value); -======= - insertFlagToScope(flagKey, flagDetail.value); - addFeatureFlagToActiveSpan(flagKey, flagDetail.value); ->>>>>>> a323c38aad748683232bdae557dd0afc4e682c74 }, }; } diff --git a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts index 69196d78318f..85aedbf779f9 100644 --- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts +++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts @@ -40,25 +40,15 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook { * Successful evaluation result. */ public after(_hookContext: Readonly>, evaluationDetails: EvaluationDetails): void { -<<<<<<< HEAD _INTERNAL_insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value); _INTERNAL_addFeatureFlagToActiveSpan(evaluationDetails.flagKey, evaluationDetails.value); -======= - insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value); - addFeatureFlagToActiveSpan(evaluationDetails.flagKey, evaluationDetails.value); ->>>>>>> a323c38aad748683232bdae557dd0afc4e682c74 } /** * On error evaluation result. */ public error(hookContext: Readonly>, _error: unknown, _hookHints?: HookHints): void { -<<<<<<< HEAD _INTERNAL_insertFlagToScope(hookContext.flagKey, hookContext.defaultValue); _INTERNAL_addFeatureFlagToActiveSpan(hookContext.flagKey, hookContext.defaultValue); -======= - insertFlagToScope(hookContext.flagKey, hookContext.defaultValue); - addFeatureFlagToActiveSpan(hookContext.flagKey, hookContext.defaultValue); ->>>>>>> a323c38aad748683232bdae557dd0afc4e682c74 } } diff --git a/packages/browser/src/integrations/featureFlags/statsig/integration.ts b/packages/browser/src/integrations/featureFlags/statsig/integration.ts index 96cd0675d01e..9aef234045b5 100644 --- a/packages/browser/src/integrations/featureFlags/statsig/integration.ts +++ b/packages/browser/src/integrations/featureFlags/statsig/integration.ts @@ -37,13 +37,8 @@ export const statsigIntegration = defineIntegration( setup(_client: Client) { statsigClient.on('gate_evaluation', (event: { gate: FeatureGate }) => { -<<<<<<< HEAD _INTERNAL_insertFlagToScope(event.gate.name, event.gate.value); _INTERNAL_addFeatureFlagToActiveSpan(event.gate.name, event.gate.value); -======= - insertFlagToScope(event.gate.name, event.gate.value); - addFeatureFlagToActiveSpan(event.gate.name, event.gate.value); ->>>>>>> a323c38aad748683232bdae557dd0afc4e682c74 }); }, diff --git a/packages/browser/src/integrations/featureFlags/unleash/integration.ts b/packages/browser/src/integrations/featureFlags/unleash/integration.ts index 0c196c6f5120..699c797edecf 100644 --- a/packages/browser/src/integrations/featureFlags/unleash/integration.ts +++ b/packages/browser/src/integrations/featureFlags/unleash/integration.ts @@ -70,13 +70,8 @@ function _wrappedIsEnabled( const result = original.apply(this, args); if (typeof toggleName === 'string' && typeof result === 'boolean') { -<<<<<<< HEAD _INTERNAL_insertFlagToScope(toggleName, result); _INTERNAL_addFeatureFlagToActiveSpan(toggleName, result); -======= - insertFlagToScope(toggleName, result); - addFeatureFlagToActiveSpan(toggleName, result); ->>>>>>> a323c38aad748683232bdae557dd0afc4e682c74 } else if (DEBUG_BUILD) { logger.error( `[Feature Flags] UnleashClient.isEnabled does not match expected signature. arg0: ${toggleName} (${typeof toggleName}), result: ${result} (${typeof result})`, diff --git a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts index c1c456238b5c..fddc1944ceab 100644 --- a/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts +++ b/packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts @@ -45,13 +45,8 @@ export const featureFlagsIntegration = defineIntegration(() => { }, addFeatureFlag(name: string, value: unknown): void { -<<<<<<< HEAD:packages/core/src/integrations/featureFlags/featureFlagsIntegration.ts _INTERNAL_insertFlagToScope(name, value); _INTERNAL_addFeatureFlagToActiveSpan(name, value); -======= - insertFlagToScope(name, value); - addFeatureFlagToActiveSpan(name, value); ->>>>>>> a323c38aad748683232bdae557dd0afc4e682c74:packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts }, }; }) as IntegrationFn; From 60f713ba7e020630d7254e9fdd487166964f67f6 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 17 Jun 2025 11:56:37 -0700 Subject: [PATCH 28/29] Redel deleted files after merge, import buf sizes in node tests --- .../integrations/featureFlags/constants.ts | 3 - .../onError/basic/scenario.ts | 3 +- .../onSpan/scenario.ts | 3 +- packages/browser/src/utils/featureFlags.ts | 137 ------------------ 4 files changed, 2 insertions(+), 144 deletions(-) delete mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts delete mode 100644 packages/browser/src/utils/featureFlags.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts deleted file mode 100644 index ba3c35a08241..000000000000 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Corresponds to constants in featureFlags.ts, in browser utils. -export const FLAG_BUFFER_SIZE = 100; -export const MAX_FLAGS_PER_SPAN = 10; diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts index 67fd0c3ebfdd..8c48b68d1663 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -const FLAG_BUFFER_SIZE = 100; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts index d21fb7a9979e..755acb63d1ab 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -const MAX_FLAGS_PER_SPAN = 10; +import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', diff --git a/packages/browser/src/utils/featureFlags.ts b/packages/browser/src/utils/featureFlags.ts deleted file mode 100644 index 9ae389773bcd..000000000000 --- a/packages/browser/src/utils/featureFlags.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { Event, FeatureFlag, Span } from '@sentry/core'; -import { getActiveSpan, getCurrentScope, GLOBAL_OBJ, 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; - -/** - * Max number of flag evaluations to record per span. - */ -export const MAX_FLAGS_PER_SPAN = 10; - -// Global map of spans to feature flag buffers. Populated by feature flag integrations. -GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap>(); - -const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.'; - -/** - * 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; -} - -/** - * Inserts a flag into the current scope's context while maintaining ordered LRU properties. - * Not thread-safe. After inserting: - * - The flag buffer is sorted in order of recency, with the newest evaluation at the end. - * - The names in the buffer are always unique. - * - The length of the buffer never exceeds `maxSize`. - * - * @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. Default value should always be used in production. - */ -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 only. Currently only accepts boolean values (otherwise no-op). - * Inserts a flag into a FeatureFlag array while maintaining the following properties: - * - Flags are sorted in order of recency, with the newest evaluation at the end. - * - The flag names are always unique. - * - The length of the array never exceeds `maxSize`. - * - * @param flags The buffer to insert the flag 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. Default value should always be used in production. - */ -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, - }); -} - -/** - * Records a feature flag evaluation for the active span. This is a no-op for non-boolean values. - * The flag and its value is stored in span attributes with the `flag.evaluation` prefix. Once the - * unique flags for a span reaches maxFlagsPerSpan, subsequent flags are dropped. - * - * @param name Name of the feature flag. - * @param value Value of the feature flag. Non-boolean values are ignored. - * @param maxFlagsPerSpan Max number of flags a buffer should store. Default value should always be used in production. - */ -export function addFeatureFlagToActiveSpan( - name: string, - value: unknown, - maxFlagsPerSpan: number = MAX_FLAGS_PER_SPAN, -): void { - const spanFlagMap = GLOBAL_OBJ._spanToFlagBufferMap; - if (!spanFlagMap || typeof value !== 'boolean') { - return; - } - - const span = getActiveSpan(); - if (span) { - const flags = spanFlagMap.get(span) || new Set(); - if (flags.has(name)) { - span.setAttribute(`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}`, value); - } else if (flags.size < maxFlagsPerSpan) { - flags.add(name); - span.setAttribute(`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}`, value); - } - spanFlagMap.set(span, flags); - } -} From d69591014a38d4764217fb2316a902afba7f959e Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 17 Jun 2025 12:29:28 -0700 Subject: [PATCH 29/29] Lint --- .../featureFlagsIntegration/onError/basic/scenario.ts | 2 +- .../featureFlags/featureFlagsIntegration/onSpan/scenario.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts index 8c48b68d1663..6d1b88137b87 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onError/basic/scenario.ts @@ -1,6 +1,6 @@ +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; -import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts index 755acb63d1ab..2c07e46b40ed 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/scenario.ts @@ -1,6 +1,6 @@ +import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; -import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337',