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/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onError/basic/test.ts
similarity index 86%
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..742cdd42109b 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,11 @@
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 91%
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..fecc762d4c99 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,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 { 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/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..ad874b2bd697
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/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/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..476b76d03475
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/featureFlags/onSpan/test.ts
@@ -0,0 +1,66 @@
+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 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 <= MAX_FLAGS_PER_SPAN; i++) {
+ expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]);
+ }
+ // Order agnostic (attribute dict is unordered).
+ expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort());
+});
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 85%
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..1c1a04595187 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,11 @@
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 90%
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..2efb3fdc9ad0 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,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 { 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/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..965f00f91fa0
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/launchdarkly/onSpan/test.ts
@@ -0,0 +1,66 @@
+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 <= MAX_FLAGS_PER_SPAN; i++) {
+ expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]);
+ }
+ // Order agnostic (attribute dict is unordered).
+ expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort());
+});
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 85%
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..84deca47415d 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,11 @@
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 85%
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..c2de7f54abd7 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,11 @@
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 90%
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..14cc072af30d 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,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 { 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/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/init.js
new file mode 100644
index 000000000000..4fc1cace150c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/init.js
@@ -0,0 +1,24 @@
+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..f3b43425477f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/openfeature/onSpan/test.ts
@@ -0,0 +1,66 @@
+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 <= MAX_FLAGS_PER_SPAN; i++) {
+ expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]);
+ }
+ // Order agnostic (attribute dict is unordered).
+ expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort());
+});
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 85%
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
index cb434e49e86e..331dbb8ad433 100644
--- 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
@@ -1,7 +1,11 @@
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/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 91%
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
index 42ee35e4604d..e80c6dbfc5fa 100644
--- 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
@@ -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 { 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/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..dec534f9ffab
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/statsig/onSpan/test.ts
@@ -0,0 +1,70 @@
+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 <= MAX_FLAGS_PER_SPAN; i++) {
+ expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]);
+ }
+ // Order agnostic (attribute dict is unordered).
+ expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort());
+});
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 88%
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
index b2e522fc78f4..341bbbd03e96 100644
--- 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
@@ -1,7 +1,11 @@
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/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 90%
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
index a512882b568a..fe3aec3ff188 100644
--- 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
@@ -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 { 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/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/init.js
new file mode 100644
index 000000000000..93993d8f6188
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/onSpan/init.js
@@ -0,0 +1,59 @@
+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..b2607ffa4c07
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/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,
+ {},
+ 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 <= MAX_FLAGS_PER_SPAN; i++) {
+ expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]);
+ }
+ // 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 54b5680cccd1..e11084c84c2d 100644
--- a/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts
+++ b/packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts
@@ -1,15 +1,14 @@
import type { Client, Event, EventHint, Integration, IntegrationFn } from '@sentry/core';
import { defineIntegration } from '@sentry/core';
-import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../utils/featureFlags';
+import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../utils/featureFlags';
export interface FeatureFlagsIntegration extends Integration {
addFeatureFlag: (name: string, value: unknown) => void;
}
/**
- * Sentry integration for buffering feature flags manually with an API, and
- * capturing them on error events. We recommend you do this on each flag
- * evaluation. Flags are buffered per Sentry scope and limited to 100 per event.
+ * 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.
*
@@ -41,6 +40,7 @@ export const featureFlagsIntegration = defineIntegration(() => {
addFeatureFlag(name: string, value: unknown): void {
insertFlagToScope(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 f96b8deb8fa0..eeb20dc07cf9 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 } from '@sentry/core';
import { defineIntegration } from '@sentry/core';
-import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
+import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
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.
*
@@ -29,10 +29,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 {
@@ -46,6 +46,7 @@ export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler
*/
method: (flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext) => {
insertFlagToScope(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 b1963e9964e6..79dc97394cce 100644
--- a/packages/browser/src/integrations/featureFlags/openfeature/integration.ts
+++ b/packages/browser/src/integrations/featureFlags/openfeature/integration.ts
@@ -1,13 +1,21 @@
/**
- * 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 } from '@sentry/core';
import { defineIntegration } from '@sentry/core';
-import { 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(() => {
@@ -29,6 +37,7 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook {
*/
public after(_hookContext: Readonly>, evaluationDetails: EvaluationDetails): void {
insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value);
+ addFeatureFlagToActiveSpan(evaluationDetails.flagKey, evaluationDetails.value);
}
/**
@@ -36,5 +45,6 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook {
*/
public error(hookContext: Readonly>, _error: unknown, _hookHints?: HookHints): void {
insertFlagToScope(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 54600458cfb9..ee472d77669a 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 { defineIntegration } from '@sentry/core';
-import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
+import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
import type { FeatureGate, StatsigClient } from './types';
/**
@@ -31,15 +31,16 @@ export const statsigIntegration = defineIntegration(
return {
name: 'Statsig',
- processEvent(event: Event, _hint: EventHint, _client: Client): Event {
- return copyFlagsFromScopeToEvent(event);
- },
-
- setup() {
+ setup(_client: Client) {
statsigClient.on('gate_evaluation', (event: { gate: FeatureGate }) => {
insertFlagToScope(event.gate.name, event.gate.value);
+ addFeatureFlagToActiveSpan(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..ee7e2a3a0d4d 100644
--- a/packages/browser/src/integrations/featureFlags/unleash/integration.ts
+++ b/packages/browser/src/integrations/featureFlags/unleash/integration.ts
@@ -1,7 +1,7 @@
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core';
import { defineIntegration, fill, logger } from '@sentry/core';
import { DEBUG_BUILD } from '../../../debug-build';
-import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
+import { addFeatureFlagToActiveSpan, copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
import type { UnleashClient, UnleashClientClass } from './types';
type UnleashIntegrationOptions = {
@@ -35,14 +35,14 @@ export const unleashIntegration = defineIntegration(
return {
name: 'Unleash',
- processEvent(event: Event, _hint: EventHint, _client: Client): Event {
- return copyFlagsFromScopeToEvent(event);
- },
-
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 +65,7 @@ function _wrappedIsEnabled(
if (typeof toggleName === 'string' && typeof result === 'boolean') {
insertFlagToScope(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 a71e7233fe75..9ae389773bcd 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,16 @@ 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 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
*/
@@ -33,18 +43,15 @@ 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. 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;
@@ -56,7 +63,16 @@ 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.
*/
export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: unknown, maxSize: number): void {
if (typeof value !== 'boolean') {
@@ -87,3 +103,35 @@ export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: un
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);
+ }
+}
diff --git a/packages/core/src/utils-hoist/worldwide.ts b/packages/core/src/utils-hoist/worldwide.ts
index 3a396d96f809..70196e4b0c8b 100644
--- a/packages/core/src/utils-hoist/worldwide.ts
+++ b/packages/core/src/utils-hoist/worldwide.ts
@@ -15,6 +15,7 @@
import type { Carrier } from '../carrier';
import type { Client } from '../client';
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 +57,10 @@ export type InternalGlobal = {
*/
_sentryModuleMetadata?: Record;
_sentryEsmLoaderHookRegistered?: boolean;
+ /**
+ * A map of spans to evaluated feature flags. Populated by feature flag integrations.
+ */
+ _spanToFlagBufferMap?: WeakMap>;
} & Carrier;
/** Get's the global object for the current JavaScript runtime */