From e6ce870011cda12e9bdcdb47e2172275bd6716ea Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 11:43:51 -0700 Subject: [PATCH 01/48] add growthbook integration --- packages/browser/src/index.ts | 1 + packages/node/src/integrations/featureFlagShims/index.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 0bc523506454..1788bf20fa06 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -71,6 +71,7 @@ export { browserSessionIntegration } from './integrations/browsersession'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly'; export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature'; export { unleashIntegration } from './integrations/featureFlags/unleash'; +export { growthbookIntegration } from './integrations/featureFlags/growthbook'; export { statsigIntegration } from './integrations/featureFlags/statsig'; export { diagnoseSdkConnectivity } from './diagnose-sdk'; export { webWorkerIntegration, registerWebWorker } from './integrations/webWorker'; diff --git a/packages/node/src/integrations/featureFlagShims/index.ts b/packages/node/src/integrations/featureFlagShims/index.ts index 230dbaeeb7e8..ef90a562983f 100644 --- a/packages/node/src/integrations/featureFlagShims/index.ts +++ b/packages/node/src/integrations/featureFlagShims/index.ts @@ -11,3 +11,5 @@ export { export { statsigIntegrationShim as statsigIntegration } from './statsig'; export { unleashIntegrationShim as unleashIntegration } from './unleash'; + +export { growthbookIntegrationShim as growthbookIntegration } from './growthbook'; From 7d2bec8eda5a74174a7a2213e4047bbdcc4bc79f Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 11:44:42 -0700 Subject: [PATCH 02/48] add handler --- .../browser/src/integrations/featureFlags/growthbook/index.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/browser/src/integrations/featureFlags/growthbook/index.ts diff --git a/packages/browser/src/integrations/featureFlags/growthbook/index.ts b/packages/browser/src/integrations/featureFlags/growthbook/index.ts new file mode 100644 index 000000000000..f5f98c94c4bb --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/index.ts @@ -0,0 +1,3 @@ +export { growthbookIntegration } from './integration'; + + From ff684eb0ca4caee98ced5dc61cc753ecf6a7629f Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 11:45:20 -0700 Subject: [PATCH 03/48] add handler, types and shim --- .../featureFlags/growthbook/integration.ts | 69 +++++++++++++++++++ .../featureFlags/growthbook/types.ts | 9 +++ .../featureFlagShims/growthbook.ts | 19 +++++ 3 files changed, 97 insertions(+) create mode 100644 packages/browser/src/integrations/featureFlags/growthbook/integration.ts create mode 100644 packages/browser/src/integrations/featureFlags/growthbook/types.ts create mode 100644 packages/node/src/integrations/featureFlagShims/growthbook.ts diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts new file mode 100644 index 000000000000..70a327a132b9 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -0,0 +1,69 @@ +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; +import { + _INTERNAL_addFeatureFlagToActiveSpan, + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_insertFlagToScope, + defineIntegration, + fill, +} from '@sentry/core'; + +import type { GrowthBook, GrowthBookClass } from './types'; + +/** + * Sentry integration for capturing feature flag evaluations from GrowthBook. + * + * See the feature flag documentation: https://develop.sentry.dev/sdk/expected-features/#feature-flags + * + * @example + * ``` + * import { GrowthBook } from '@growthbook/growthbook'; + * import * as Sentry from '@sentry/browser'; + * + * Sentry.init({ + * dsn: '___PUBLIC_DSN___', + * integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBook })], + * }); + * + * const gb = new GrowthBook(); + * gb.isOn('my-feature'); + * Sentry.captureException(new Error('something went wrong')); + * ``` + */ +export const growthbookIntegration = defineIntegration(({ growthbookClass }: { growthbookClass: GrowthBookClass }) => { + return { + name: 'GrowthBook', + + setupOnce() { + const proto = growthbookClass.prototype as GrowthBook; + fill(proto, 'isOn', _wrapBooleanReturningMethod); + fill(proto, 'getFeatureValue', _wrapBooleanReturningMethod); + }, + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return _INTERNAL_copyFlagsFromScopeToEvent(event); + }, + }; +}) satisfies IntegrationFn; + +function _wrapBooleanReturningMethod( + original: (this: GrowthBook, ...args: unknown[]) => unknown, +): (this: GrowthBook, ...args: unknown[]) => unknown { + return function (this: GrowthBook, ...args: unknown[]): unknown { + const flagName = args[0]; + const result = original.apply(this, args); + // Capture any JSON-serializable result (booleans, strings, numbers, null, plain objects/arrays). + // Skip functions/symbols/undefined. + if ( + typeof flagName === 'string' && + typeof result !== 'undefined' && + typeof result !== 'function' && + typeof result !== 'symbol' + ) { + _INTERNAL_insertFlagToScope(flagName, result); + _INTERNAL_addFeatureFlagToActiveSpan(flagName, result); + } + return result; + }; +} + + diff --git a/packages/browser/src/integrations/featureFlags/growthbook/types.ts b/packages/browser/src/integrations/featureFlags/growthbook/types.ts new file mode 100644 index 000000000000..46270b8efa8f --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/types.ts @@ -0,0 +1,9 @@ +export interface GrowthBook { + isOn(this: GrowthBook, featureKey: string): boolean; + getFeatureValue(this: GrowthBook, featureKey: string, defaultValue: unknown): unknown; +} + +// We only depend on the surface we wrap; constructor args are irrelevant here. +export type GrowthBookClass = new (...args: unknown[]) => GrowthBook; + + diff --git a/packages/node/src/integrations/featureFlagShims/growthbook.ts b/packages/node/src/integrations/featureFlagShims/growthbook.ts new file mode 100644 index 000000000000..3e4845046efd --- /dev/null +++ b/packages/node/src/integrations/featureFlagShims/growthbook.ts @@ -0,0 +1,19 @@ +import { consoleSandbox, defineIntegration, isBrowser } from '@sentry/core'; + +/** + * Shim for the GrowthBook integration to avoid runtime errors when imported on the server. + */ +export const growthbookIntegrationShim = defineIntegration((_options?: unknown) => { + if (!isBrowser()) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('The growthbookIntegration() can only be used in the browser.'); + }); + } + + return { + name: 'GrowthBook', + }; +}); + + From b50ef20ed83578db8d6e7d17da7225cca825e0bc Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 12:02:11 -0700 Subject: [PATCH 04/48] capture evalFeature method --- .../src/integrations/featureFlags/growthbook/integration.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts index 70a327a132b9..1fb19a5697e3 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -37,6 +37,10 @@ export const growthbookIntegration = defineIntegration(({ growthbookClass }: { g const proto = growthbookClass.prototype as GrowthBook; fill(proto, 'isOn', _wrapBooleanReturningMethod); fill(proto, 'getFeatureValue', _wrapBooleanReturningMethod); + // Also capture evalFeature when present. Not all versions have it, so guard. + if (typeof (proto as unknown as Record).evalFeature === 'function') { + fill(proto as any, 'evalFeature', _wrapBooleanReturningMethod as any); + } }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { From a6c3d73cb94dd263e2375fc83a34cb3960c57a7f Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 14:51:47 -0700 Subject: [PATCH 05/48] add integration tests --- .../growthbook/onError/basic/test.ts | 57 ++++++++++++++++ .../featureFlags/growthbook/onError/init.js | 37 +++++++++++ .../growthbook/onError/subject.js | 3 + .../growthbook/onError/template.html | 12 ++++ .../growthbook/onError/withScope/test.ts | 61 +++++++++++++++++ .../featureFlags/growthbook/onSpan/init.js | 39 +++++++++++ .../featureFlags/growthbook/onSpan/subject.js | 3 + .../growthbook/onSpan/template.html | 12 ++++ .../featureFlags/growthbook/onSpan/test.ts | 65 +++++++++++++++++++ .../featureFlags/growthbook/index.ts | 2 - .../featureFlags/growthbook/integration.ts | 3 - .../featureFlags/growthbook/types.ts | 2 - .../featureFlagShims/growthbook.ts | 2 - 13 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts new file mode 100644 index 000000000000..9e1a3698aa3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -0,0 +1,57 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; +import { FLAG_BUFFER_SIZE } from '../../constants'; + +sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + await page.evaluate(bufferSize => { + const gb = new (window as any).GrowthBook(); + + gb.__setOn('onTrue', true); + gb.__setOn('onFalse', false); + gb.__setFeatureValue('strVal', 'hello'); + gb.__setFeatureValue('numVal', 42); + gb.__setFeatureValue('objVal', { a: 1, b: 'c' }); + + gb.isOn('onTrue'); + gb.isOn('onFalse'); + gb.getFeatureValue('strVal', ''); + gb.getFeatureValue('numVal', 0); + gb.getFeatureValue('objVal', {}); + + for (let i = 1; i <= bufferSize; i++) { + gb.isOn(`feat${i}`); + } + + gb.__setOn(`feat${bufferSize + 1}`, true); + gb.isOn(`feat${bufferSize + 1}`); + gb.isOn('feat3'); + }, FLAG_BUFFER_SIZE); + + const reqPromise = waitForErrorRequest(page); + await page.locator('#error').click(); + const req = await reqPromise; + const event = envelopeRequestParser(req); + + const values = event.contexts?.flags?.values || []; + expect(values).toEqual( + expect.arrayContaining([ + { flag: 'onTrue', result: true }, + { flag: 'onFalse', result: false }, + { flag: 'strVal', result: 'hello' }, + { flag: 'numVal', result: 42 }, + { flag: 'objVal', result: { a: 1, b: 'c' } }, + ]), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js new file mode 100644 index 000000000000..e7831a1c2c0b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/browser'; + +// Minimal mock GrowthBook class for tests +window.GrowthBook = class { + constructor() { + this._onFlags = Object.create(null); + this._featureValues = Object.create(null); + } + + isOn(featureKey) { + return !!this._onFlags[featureKey]; + } + + getFeatureValue(featureKey, defaultValue) { + return Object.prototype.hasOwnProperty.call(this._featureValues, featureKey) + ? this._featureValues[featureKey] + : defaultValue; + } + + // Helpers for tests + __setOn(featureKey, value) { + this._onFlags[featureKey] = !!value; + } + + __setFeatureValue(featureKey, value) { + this._featureValues[featureKey] = value; + } +}; + +window.Sentry = Sentry; +window.sentryGrowthBookIntegration = Sentry.growthbookIntegration({ growthbookClass: window.GrowthBook }); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + integrations: [window.sentryGrowthBookIntegration], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js new file mode 100644 index 000000000000..e6697408128c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js @@ -0,0 +1,3 @@ +document.getElementById('error').addEventListener('click', () => { + throw new Error('Button triggered error'); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html new file mode 100644 index 000000000000..da7d69a24c97 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts new file mode 100644 index 000000000000..a5a48b48690e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts @@ -0,0 +1,61 @@ +import { expect } from '@playwright/test'; +import type { Scope } from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; + +sentryTest('GrowthBook onError: forked scopes are isolated', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === true); + const mainReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === false); + + await page.evaluate(() => { + const Sentry = (window as any).Sentry; + const errorButton = document.querySelector('#error') as HTMLButtonElement; + const gb = new (window as any).GrowthBook(); + + gb.__setOn('shared', true); + gb.__setOn('main', true); + + gb.isOn('shared'); + + Sentry.withScope((scope: Scope) => { + gb.__setOn('forked', true); + gb.__setOn('shared', false); + gb.isOn('forked'); + gb.isOn('shared'); + scope.setTag('isForked', true); + errorButton.click(); + }); + + gb.isOn('main'); + Sentry.getCurrentScope().setTag('isForked', false); + errorButton.click(); + return true; + }); + + const forkedReq = await forkedReqPromise; + const forkedEvent = envelopeRequestParser(forkedReq); + + const mainReq = await mainReqPromise; + const mainEvent = envelopeRequestParser(mainReq); + + expect(forkedEvent.contexts?.flags?.values).toEqual([ + { flag: 'forked', result: true }, + { flag: 'shared', result: false }, + ]); + + expect(mainEvent.contexts?.flags?.values).toEqual([ + { flag: 'shared', result: true }, + { flag: 'main', result: true }, + ]); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js new file mode 100644 index 000000000000..d755d7a1d972 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; + +window.GrowthBook = class { + constructor() { + this._onFlags = Object.create(null); + this._featureValues = Object.create(null); + } + + isOn(featureKey) { + return !!this._onFlags[featureKey]; + } + + getFeatureValue(featureKey, defaultValue) { + return Object.prototype.hasOwnProperty.call(this._featureValues, featureKey) + ? this._featureValues[featureKey] + : defaultValue; + } + + __setOn(featureKey, value) { + this._onFlags[featureKey] = !!value; + } + + __setFeatureValue(featureKey, value) { + this._featureValues[featureKey] = value; + } +}; + +window.Sentry = Sentry; +window.sentryGrowthBookIntegration = Sentry.growthbookIntegration({ growthbookClass: window.GrowthBook }); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + tracesSampleRate: 1.0, + integrations: [ + window.sentryGrowthBookIntegration, + Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js new file mode 100644 index 000000000000..a036abf76c2e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js @@ -0,0 +1,3 @@ +document.getElementById('error').addEventListener('click', () => { + // no-op +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html new file mode 100644 index 000000000000..da7d69a24c97 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts new file mode 100644 index 000000000000..6661edc9723d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts @@ -0,0 +1,65 @@ +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, + eventAndTraceHeaderRequestParser, + getMultipleSentryEnvelopeRequests, + shouldSkipFeatureFlagsTest, + shouldSkipTracingTest, +} from '../../../../../utils/helpers'; + +sentryTest( + "GrowthBook onSpan: 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, + ); + + await page.evaluate(maxFlags => { + (window as any).withNestedSpans(() => { + const gb = new (window as any).GrowthBook(); + for (let i = 1; i <= maxFlags; i++) { + gb.isOn(`feat${i}`); + } + gb.__setOn(`feat${maxFlags + 1}`, true); + gb.isOn(`feat${maxFlags + 1}`); // dropped + gb.__setOn('feat3', true); + gb.isOn('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 = [] as Array<[string, unknown]>; + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); + } + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); + }, +); diff --git a/packages/browser/src/integrations/featureFlags/growthbook/index.ts b/packages/browser/src/integrations/featureFlags/growthbook/index.ts index f5f98c94c4bb..a931e2376ab7 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/index.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/index.ts @@ -1,3 +1 @@ export { growthbookIntegration } from './integration'; - - diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts index 1fb19a5697e3..8a71267a1c0c 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -6,7 +6,6 @@ import { defineIntegration, fill, } from '@sentry/core'; - import type { GrowthBook, GrowthBookClass } from './types'; /** @@ -69,5 +68,3 @@ function _wrapBooleanReturningMethod( return result; }; } - - diff --git a/packages/browser/src/integrations/featureFlags/growthbook/types.ts b/packages/browser/src/integrations/featureFlags/growthbook/types.ts index 46270b8efa8f..df0971235078 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/types.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/types.ts @@ -5,5 +5,3 @@ export interface GrowthBook { // We only depend on the surface we wrap; constructor args are irrelevant here. export type GrowthBookClass = new (...args: unknown[]) => GrowthBook; - - diff --git a/packages/node/src/integrations/featureFlagShims/growthbook.ts b/packages/node/src/integrations/featureFlagShims/growthbook.ts index 3e4845046efd..a36bf6ba18c8 100644 --- a/packages/node/src/integrations/featureFlagShims/growthbook.ts +++ b/packages/node/src/integrations/featureFlagShims/growthbook.ts @@ -15,5 +15,3 @@ export const growthbookIntegrationShim = defineIntegration((_options?: unknown) name: 'GrowthBook', }; }); - - From 92ba49cdce2bf35dd7c294cfe2cbc7b7ca3de91d Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 15:35:03 -0700 Subject: [PATCH 06/48] fix fixture imports --- .../growthbook/onError/basic/test.ts | 32 +++++++++++-------- .../growthbook/onError/withScope/test.ts | 8 +++-- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts index 9e1a3698aa3a..6cb35265b982 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/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 { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { @@ -18,6 +22,15 @@ sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ( await page.evaluate(bufferSize => { const gb = new (window as any).GrowthBook(); + for (let i = 1; i <= bufferSize; i++) { + gb.isOn(`feat${i}`); + } + + gb.__setOn(`feat${bufferSize + 1}`, true); + gb.isOn(`feat${bufferSize + 1}`); + gb.isOn('feat3'); + + // Add typed flags at the end so they are not evicted gb.__setOn('onTrue', true); gb.__setOn('onFalse', false); gb.__setFeatureValue('strVal', 'hello'); @@ -29,14 +42,6 @@ sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ( gb.getFeatureValue('strVal', ''); gb.getFeatureValue('numVal', 0); gb.getFeatureValue('objVal', {}); - - for (let i = 1; i <= bufferSize; i++) { - gb.isOn(`feat${i}`); - } - - gb.__setOn(`feat${bufferSize + 1}`, true); - gb.isOn(`feat${bufferSize + 1}`); - gb.isOn('feat3'); }, FLAG_BUFFER_SIZE); const reqPromise = waitForErrorRequest(page); @@ -45,13 +50,12 @@ sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ( const event = envelopeRequestParser(req); const values = event.contexts?.flags?.values || []; + // Only assert presence when buffer wasn't fully overwritten by filler flags + // just check capture of some typed values. expect(values).toEqual( expect.arrayContaining([ { flag: 'onTrue', result: true }, { flag: 'onFalse', result: false }, - { flag: 'strVal', result: 'hello' }, - { flag: 'numVal', result: 42 }, - { flag: 'objVal', result: { a: 1, b: 'c' } }, ]), ); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts index a5a48b48690e..48fa4718b856 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/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('GrowthBook onError: forked scopes are isolated', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { From 0b38a7b138f66839494ea243219f0a2424790d79 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 15:35:42 -0700 Subject: [PATCH 07/48] use withNestedSpans --- .../featureFlags/growthbook/onSpan/subject.js | 19 ++++++++++++++++--- .../growthbook/onSpan/template.html | 5 ++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js index a036abf76c2e..ad874b2bd697 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js @@ -1,3 +1,16 @@ -document.getElementById('error').addEventListener('click', () => { - // no-op -}); +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/growthbook/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html index da7d69a24c97..4efb91e75451 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html @@ -4,7 +4,10 @@ - + + + + From 819b5cbe0013108567c492fff1bf8538563dff09 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 16:09:53 -0700 Subject: [PATCH 08/48] fill buffer and assert ordered list --- .../growthbook/onError/basic/test.ts | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts index 6cb35265b982..77bbee30ea32 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -7,7 +7,7 @@ import { waitForErrorRequest, } from '../../../../../../utils/helpers'; -sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ({ getLocalTestUrl, page }) => { +sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { sentryTest.skip(); } @@ -27,21 +27,10 @@ sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ( } gb.__setOn(`feat${bufferSize + 1}`, true); - gb.isOn(`feat${bufferSize + 1}`); - gb.isOn('feat3'); - - // Add typed flags at the end so they are not evicted - gb.__setOn('onTrue', true); - gb.__setOn('onFalse', false); - gb.__setFeatureValue('strVal', 'hello'); - gb.__setFeatureValue('numVal', 42); - gb.__setFeatureValue('objVal', { a: 1, b: 'c' }); - - gb.isOn('onTrue'); - gb.isOn('onFalse'); - gb.getFeatureValue('strVal', ''); - gb.getFeatureValue('numVal', 0); - gb.getFeatureValue('objVal', {}); + gb.isOn(`feat${bufferSize + 1}`); // eviction + + gb.__setOn('feat3', true); + gb.isOn('feat3'); // update }, FLAG_BUFFER_SIZE); const reqPromise = waitForErrorRequest(page); @@ -50,12 +39,12 @@ sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ( const event = envelopeRequestParser(req); const values = event.contexts?.flags?.values || []; - // Only assert presence when buffer wasn't fully overwritten by filler flags - // just check capture of some typed values. - expect(values).toEqual( - expect.arrayContaining([ - { flag: 'onTrue', result: true }, - { flag: 'onFalse', result: false }, - ]), - ); + const expectedFlags = [{ flag: 'feat2', result: false }]; + for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) { + expectedFlags.push({ flag: `feat${i}`, result: false }); + } + expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true }); + expectedFlags.push({ flag: 'feat3', result: true }); + + expect(values).toEqual(expectedFlags); }); From a79d0edeb47740a89ddd5163e973a59444662565 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 23 Sep 2025 14:39:40 -0700 Subject: [PATCH 09/48] move gb integration to core --- .../featureFlags/growthbook/integration.ts | 54 ++------------ .../featureFlags/growthbook/types.ts | 4 +- packages/core/src/index.ts | 1 + .../integrations/featureFlags/growthbook.ts | 70 +++++++++++++++++++ .../src/integrations/featureFlags/index.ts | 1 + .../featureFlagShims/growthbook.ts | 27 +++---- 6 files changed, 95 insertions(+), 62 deletions(-) create mode 100644 packages/core/src/integrations/featureFlags/growthbook.ts diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts index 8a71267a1c0c..b05aebf97fab 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -1,12 +1,6 @@ -import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; -import { - _INTERNAL_addFeatureFlagToActiveSpan, - _INTERNAL_copyFlagsFromScopeToEvent, - _INTERNAL_insertFlagToScope, - defineIntegration, - fill, -} from '@sentry/core'; -import type { GrowthBook, GrowthBookClass } from './types'; +import type { IntegrationFn } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import type { GrowthBookClass } from './types'; /** * Sentry integration for capturing feature flag evaluations from GrowthBook. @@ -28,43 +22,7 @@ import type { GrowthBook, GrowthBookClass } from './types'; * Sentry.captureException(new Error('something went wrong')); * ``` */ -export const growthbookIntegration = defineIntegration(({ growthbookClass }: { growthbookClass: GrowthBookClass }) => { - return { - name: 'GrowthBook', +const _coreAny = SentryCore as unknown as Record; - setupOnce() { - const proto = growthbookClass.prototype as GrowthBook; - fill(proto, 'isOn', _wrapBooleanReturningMethod); - fill(proto, 'getFeatureValue', _wrapBooleanReturningMethod); - // Also capture evalFeature when present. Not all versions have it, so guard. - if (typeof (proto as unknown as Record).evalFeature === 'function') { - fill(proto as any, 'evalFeature', _wrapBooleanReturningMethod as any); - } - }, - - processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return _INTERNAL_copyFlagsFromScopeToEvent(event); - }, - }; -}) satisfies IntegrationFn; - -function _wrapBooleanReturningMethod( - original: (this: GrowthBook, ...args: unknown[]) => unknown, -): (this: GrowthBook, ...args: unknown[]) => unknown { - return function (this: GrowthBook, ...args: unknown[]): unknown { - const flagName = args[0]; - const result = original.apply(this, args); - // Capture any JSON-serializable result (booleans, strings, numbers, null, plain objects/arrays). - // Skip functions/symbols/undefined. - if ( - typeof flagName === 'string' && - typeof result !== 'undefined' && - typeof result !== 'function' && - typeof result !== 'symbol' - ) { - _INTERNAL_insertFlagToScope(flagName, result); - _INTERNAL_addFeatureFlagToActiveSpan(flagName, result); - } - return result; - }; -} +export const growthbookIntegration = (({ growthbookClass }: { growthbookClass: GrowthBookClass }) => + _coreAny.growthbookIntegration({ growthbookClass })) satisfies IntegrationFn; diff --git a/packages/browser/src/integrations/featureFlags/growthbook/types.ts b/packages/browser/src/integrations/featureFlags/growthbook/types.ts index df0971235078..5a852d633da9 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/types.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/types.ts @@ -1,6 +1,6 @@ export interface GrowthBook { - isOn(this: GrowthBook, featureKey: string): boolean; - getFeatureValue(this: GrowthBook, featureKey: string, defaultValue: unknown): unknown; + isOn(this: GrowthBook, featureKey: string, ...rest: unknown[]): boolean; + getFeatureValue(this: GrowthBook, featureKey: string, defaultValue: unknown, ...rest: unknown[]): unknown; } // We only depend on the surface we wrap; constructor args are irrelevant here. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6385a75687f7..63846c87d964 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -111,6 +111,7 @@ 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 { growthbookIntegration } from './integrations/featureFlags'; export { profiler } from './profiling'; export { instrumentFetchRequest } from './fetch'; diff --git a/packages/core/src/integrations/featureFlags/growthbook.ts b/packages/core/src/integrations/featureFlags/growthbook.ts new file mode 100644 index 000000000000..24162d279c52 --- /dev/null +++ b/packages/core/src/integrations/featureFlags/growthbook.ts @@ -0,0 +1,70 @@ +import type { Client } from '../../client'; +import { defineIntegration } from '../../integration'; +import type { Event, EventHint } from '../../types-hoist/event'; +import type { IntegrationFn } from '../../types-hoist/integration'; +import { + _INTERNAL_addFeatureFlagToActiveSpan, + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_insertFlagToScope, +} from '../../utils/featureFlags'; +import { fill } from '../../utils/object'; + +interface GrowthBookLike { + isOn(this: GrowthBookLike, featureKey: string, ...rest: unknown[]): boolean; + getFeatureValue(this: GrowthBookLike, featureKey: string, defaultValue: unknown, ...rest: unknown[]): unknown; +} + +export type GrowthBookClassLike = new (...args: unknown[]) => GrowthBookLike; + +/** + * Sentry integration for capturing feature flag evaluations from GrowthBook. + * + * Only boolean results are captured at this time. + */ +export const growthbookIntegration = defineIntegration( + ({ growthbookClass }: { growthbookClass: GrowthBookClassLike }) => { + return { + name: 'GrowthBook', + + setupOnce() { + const proto = growthbookClass.prototype as GrowthBookLike; + + // Type guard and wrap isOn + if (typeof proto.isOn === 'function') { + fill(proto, 'isOn', _wrapAndCaptureBooleanResult); + } + + // Type guard and wrap getFeatureValue + if (typeof proto.getFeatureValue === 'function') { + fill(proto, 'getFeatureValue', _wrapAndCaptureBooleanResult); + } + + // Type guard and wrap evalFeature if present + const maybeEval = (proto as unknown as Record).evalFeature; + if (typeof maybeEval === 'function') { + fill(proto as unknown as Record, 'evalFeature', _wrapAndCaptureBooleanResult as any); + } + }, + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return _INTERNAL_copyFlagsFromScopeToEvent(event); + }, + }; + }, +) satisfies IntegrationFn; + +function _wrapAndCaptureBooleanResult( + original: (this: GrowthBookLike, ...args: unknown[]) => unknown, +): (this: GrowthBookLike, ...args: unknown[]) => unknown { + return function (this: GrowthBookLike, ...args: unknown[]): unknown { + const flagName = args[0]; + const result = original.apply(this, args); + + if (typeof flagName === 'string' && typeof result === 'boolean') { + _INTERNAL_insertFlagToScope(flagName, result); + _INTERNAL_addFeatureFlagToActiveSpan(flagName, result); + } + + return result; + }; +} diff --git a/packages/core/src/integrations/featureFlags/index.ts b/packages/core/src/integrations/featureFlags/index.ts index 2106ee7accf0..f0ee5ece65b2 100644 --- a/packages/core/src/integrations/featureFlags/index.ts +++ b/packages/core/src/integrations/featureFlags/index.ts @@ -1 +1,2 @@ export { featureFlagsIntegration, type FeatureFlagsIntegration } from './featureFlagsIntegration'; +export { growthbookIntegration } from './growthbook'; diff --git a/packages/node/src/integrations/featureFlagShims/growthbook.ts b/packages/node/src/integrations/featureFlagShims/growthbook.ts index a36bf6ba18c8..bc7aa49f77f2 100644 --- a/packages/node/src/integrations/featureFlagShims/growthbook.ts +++ b/packages/node/src/integrations/featureFlagShims/growthbook.ts @@ -1,17 +1,20 @@ -import { consoleSandbox, defineIntegration, isBrowser } from '@sentry/core'; +import { + defineIntegration, + growthbookIntegration as coreGrowthbookIntegration, + isBrowser, +} from '@sentry/core'; /** * Shim for the GrowthBook integration to avoid runtime errors when imported on the server. */ -export const growthbookIntegrationShim = defineIntegration((_options?: unknown) => { - if (!isBrowser()) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn('The growthbookIntegration() can only be used in the browser.'); - }); - } +export const growthbookIntegrationShim = defineIntegration( + (options: Parameters[0]) => { + if (!isBrowser()) { + // On Node, just return the core integration so Node SDKs can also use it. + return coreGrowthbookIntegration(options); + } - return { - name: 'GrowthBook', - }; -}); + // In browser, still return the integration to preserve behavior. + return coreGrowthbookIntegration(options); + }, +); From 8b2914f7c81baff1af787eab0c72f9bece5849f5 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 23 Sep 2025 14:39:51 -0700 Subject: [PATCH 10/48] update tests --- .../featureFlags/growthbook/onError/basic/test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts index 77bbee30ea32..5b3cd7ac35b7 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -31,6 +31,16 @@ sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async gb.__setOn('feat3', true); gb.isOn('feat3'); // update + + // Test getFeatureValue with boolean values (should be captured) + gb.__setFeatureValue('bool-feat', true); + gb.getFeatureValue('bool-feat', false); + + // Test getFeatureValue with non-boolean values (should be ignored) + gb.__setFeatureValue('string-feat', 'hello'); + gb.getFeatureValue('string-feat', 'default'); + gb.__setFeatureValue('number-feat', 42); + gb.getFeatureValue('number-feat', 0); }, FLAG_BUFFER_SIZE); const reqPromise = waitForErrorRequest(page); @@ -45,6 +55,7 @@ sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async } expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true }); expectedFlags.push({ flag: 'feat3', result: true }); + expectedFlags.push({ flag: 'bool-feat', result: true }); // Only boolean getFeatureValue should be captured expect(values).toEqual(expectedFlags); }); From dfdae98d8ffb24224562771500972e1716eb529e Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 23 Sep 2025 18:49:15 -0700 Subject: [PATCH 11/48] remove evalFunction --- .../integrations/featureFlags/growthbook.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/src/integrations/featureFlags/growthbook.ts b/packages/core/src/integrations/featureFlags/growthbook.ts index 24162d279c52..8fb5cc49cc0a 100644 --- a/packages/core/src/integrations/featureFlags/growthbook.ts +++ b/packages/core/src/integrations/featureFlags/growthbook.ts @@ -20,6 +20,19 @@ export type GrowthBookClassLike = new (...args: unknown[]) => GrowthBookLike; * Sentry integration for capturing feature flag evaluations from GrowthBook. * * Only boolean results are captured at this time. + * + * @example + * ```typescript + * import { GrowthBook } from '@growthbook/growthbook'; + * import * as Sentry from '@sentry/browser'; // or '@sentry/node' + * + * Sentry.init({ + * dsn: 'your-dsn', + * integrations: [ + * Sentry.growthbookIntegration({ growthbookClass: GrowthBook }) + * ] + * }); + * ``` */ export const growthbookIntegration = defineIntegration( ({ growthbookClass }: { growthbookClass: GrowthBookClassLike }) => { @@ -39,11 +52,6 @@ export const growthbookIntegration = defineIntegration( fill(proto, 'getFeatureValue', _wrapAndCaptureBooleanResult); } - // Type guard and wrap evalFeature if present - const maybeEval = (proto as unknown as Record).evalFeature; - if (typeof maybeEval === 'function') { - fill(proto as unknown as Record, 'evalFeature', _wrapAndCaptureBooleanResult as any); - } }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { From 996a37d1b8a45a08a3a10cf9eaf38f59c9c6df07 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 23 Sep 2025 19:00:32 -0700 Subject: [PATCH 12/48] fix shim --- .../integrations/featureFlags/growthbook.ts | 1 - .../featureFlagShims/growthbook.ts | 21 ++++--------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/core/src/integrations/featureFlags/growthbook.ts b/packages/core/src/integrations/featureFlags/growthbook.ts index 8fb5cc49cc0a..a72e48e0f596 100644 --- a/packages/core/src/integrations/featureFlags/growthbook.ts +++ b/packages/core/src/integrations/featureFlags/growthbook.ts @@ -51,7 +51,6 @@ export const growthbookIntegration = defineIntegration( if (typeof proto.getFeatureValue === 'function') { fill(proto, 'getFeatureValue', _wrapAndCaptureBooleanResult); } - }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { diff --git a/packages/node/src/integrations/featureFlagShims/growthbook.ts b/packages/node/src/integrations/featureFlagShims/growthbook.ts index bc7aa49f77f2..d86f3a0349bc 100644 --- a/packages/node/src/integrations/featureFlagShims/growthbook.ts +++ b/packages/node/src/integrations/featureFlagShims/growthbook.ts @@ -1,20 +1,7 @@ -import { - defineIntegration, - growthbookIntegration as coreGrowthbookIntegration, - isBrowser, -} from '@sentry/core'; +import { growthbookIntegration as coreGrowthbookIntegration } from '@sentry/core'; /** - * Shim for the GrowthBook integration to avoid runtime errors when imported on the server. + * Re-export the core GrowthBook integration for Node.js usage. + * The core integration is runtime-agnostic and works in both browser and Node environments. */ -export const growthbookIntegrationShim = defineIntegration( - (options: Parameters[0]) => { - if (!isBrowser()) { - // On Node, just return the core integration so Node SDKs can also use it. - return coreGrowthbookIntegration(options); - } - - // In browser, still return the integration to preserve behavior. - return coreGrowthbookIntegration(options); - }, -); +export const growthbookIntegrationShim = coreGrowthbookIntegration; From bbb6c8d78db8a16ebdff6b867432d5a1e6aaa16a Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 16:08:45 -0700 Subject: [PATCH 13/48] add node integration tests --- .../growthbook/onError/basic/scenario.ts | 59 +++++++++++++++++++ .../growthbook/onError/basic/test.ts | 32 ++++++++++ .../growthbook/onSpan/scenario.ts | 50 ++++++++++++++++ .../featureFlags/growthbook/onSpan/test.ts | 34 +++++++++++ packages/node/src/index.ts | 1 + 5 files changed, 176 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts new file mode 100644 index 000000000000..2f37507f61d5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts @@ -0,0 +1,59 @@ +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'; + +// Minimal GrowthBook-like class that matches the real API for testing +// This is necessary since we don't want to add @growthbook/growthbook as a dependency +// just for integration tests, but we want to test the actual integration behavior +class GrowthBookLike { + private _features: Record = {}; + + public isOn(featureKey: string): boolean { + const feature = this._features[featureKey]; + return feature ? !!feature.value : false; + } + + public getFeatureValue(featureKey: string, defaultValue: unknown): unknown { + const feature = this._features[featureKey]; + return feature ? feature.value : defaultValue; + } + + // Helper method to set feature values for testing + public setFeature(featureKey: string, value: unknown): void { + this._features[featureKey] = { value }; + } +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookLike })], +}); + +const gb = new GrowthBookLike(); + +// Fill buffer with flags 1-100 (all false by default) +for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { + gb.isOn(`feat${i}`); +} + +// Add feat101 (true), which should evict feat1 +gb.setFeature(`feat${FLAG_BUFFER_SIZE + 1}`, true); +gb.isOn(`feat${FLAG_BUFFER_SIZE + 1}`); + +// Update feat3 to true, which should move it to the end +gb.setFeature('feat3', true); +gb.isOn('feat3'); + +// Test getFeatureValue with boolean values (should be captured) +gb.setFeature('bool-feat', true); +gb.getFeatureValue('bool-feat', false); + +// Test getFeatureValue with non-boolean values (should NOT be captured) +gb.setFeature('string-feat', 'hello'); +gb.getFeatureValue('string-feat', 'default'); +gb.setFeature('number-feat', 42); +gb.getFeatureValue('number-feat', 0); + +throw new Error('Test error'); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts new file mode 100644 index 000000000000..a709c6329c93 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts @@ -0,0 +1,32 @@ +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('GrowthBook flags captured on error with eviction, update, and no async tasks', async () => { + + const expectedFlags = []; + 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 }); + expectedFlags.push({ flag: 'bool-feat', result: true }); // Only boolean getFeatureValue should be captured + + 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/growthbook/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts new file mode 100644 index 000000000000..c66f340a4b63 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts @@ -0,0 +1,50 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Minimal GrowthBook-like class that matches the real API for testing +class GrowthBookLike { + private _features: Record = {}; + + public isOn(featureKey: string): boolean { + const feature = this._features[featureKey]; + return feature ? !!feature.value : false; + } + + public getFeatureValue(featureKey: string, defaultValue: unknown): unknown { + const feature = this._features[featureKey]; + return feature ? feature.value : defaultValue; + } + + // Helper method to set feature values for testing + public setFeature(featureKey: string, value: unknown): void { + this._features[featureKey] = { value }; + } +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookLike })], +}); + +const gb = new GrowthBookLike(); + +// Set up feature flags +gb.setFeature('feat1', true); +gb.setFeature('feat2', false); +gb.setFeature('bool-feat', true); + +Sentry.startSpan({ name: 'test-span', op: 'function' }, () => { + // Evaluate feature flags during the span + gb.isOn('feat1'); + gb.isOn('feat2'); + + // Test getFeatureValue with boolean values (should be captured) + gb.getFeatureValue('bool-feat', false); + + // Test getFeatureValue with non-boolean values (should NOT be captured) + gb.setFeature('string-feat', 'hello'); + gb.getFeatureValue('string-feat', 'default'); +}); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts new file mode 100644 index 000000000000..65446ed24289 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts @@ -0,0 +1,34 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('GrowthBook flags are added to active span attributes on span end', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + contexts: { + trace: { + data: { + 'flag.evaluation.feat1': true, + 'flag.evaluation.feat2': false, + 'flag.evaluation.bool-feat': true, + // string-feat should NOT be here since it's not boolean + }, + op: 'function', + origin: 'manual', + status: 'ok', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/) + }, + }, + spans: [], + transaction: 'test-span', + type: 'transaction', + }, + }) + .start() + .completed(); +}); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 67e00660c2a1..f7d11410f708 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -34,6 +34,7 @@ export { OpenFeatureIntegrationHook, statsigIntegration, unleashIntegration, + growthbookIntegration, } from './integrations/featureFlagShims'; export { firebaseIntegration } from './integrations/tracing/firebase'; From ce4e27ec376b8d86308b43318787fba05332d2c8 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 16:10:40 -0700 Subject: [PATCH 14/48] add exports to astro --- packages/astro/src/index.server.ts | 1 + packages/astro/src/index.types.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index d39cb5e4484d..dc3c8dc87b0e 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -156,6 +156,7 @@ export { OpenFeatureIntegrationHook, statsigIntegration, unleashIntegration, + growthbookIntegration, } from '@sentry/node'; export { init } from './server/sdk'; diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts index ceb4fc6d8a51..b09a1cfa09d5 100644 --- a/packages/astro/src/index.types.ts +++ b/packages/astro/src/index.types.ts @@ -35,5 +35,6 @@ export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegra export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook; export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export default sentryAstro; From b266010f0dd0fc9bb046960c2d9208c932a38724 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 16:11:06 -0700 Subject: [PATCH 15/48] export growthbook integration --- packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/cloudflare/src/index.ts | 1 + packages/deno/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + 5 files changed, 5 insertions(+) diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index cfab7b72754b..0594d5dc3915 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -142,6 +142,7 @@ export { OpenFeatureIntegrationHook, statsigIntegration, unleashIntegration, + growthbookIntegration, } from '@sentry/node'; export { diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 68a1e2b6d6ff..dac61c440e09 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -154,6 +154,7 @@ export { wrapMcpServerWithSentry, featureFlagsIntegration, launchDarklyIntegration, + growthbookIntegration, buildLaunchDarklyFlagUsedHandler, openFeatureIntegration, OpenFeatureIntegrationHook, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index d4afd80313b1..6f731cb8d980 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -97,6 +97,7 @@ export { consoleLoggingIntegration, createConsolaReporter, featureFlagsIntegration, + growthbookIntegration, logger, } from '@sentry/core'; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 5f987b4459aa..57da7927b11b 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -89,6 +89,7 @@ export { updateSpanName, wrapMcpServerWithSentry, featureFlagsIntegration, + growthbookIntegration, } 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 ac0f41079017..a3b555dbf9f6 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -137,6 +137,7 @@ export { featureFlagsIntegration, type FeatureFlagsIntegration, launchDarklyIntegration, + growthbookIntegration, buildLaunchDarklyFlagUsedHandler, openFeatureIntegration, OpenFeatureIntegrationHook, From f75e3f15376200c9d14bc4d18f8a3e4f745e5542 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 16:11:23 -0700 Subject: [PATCH 16/48] fix race condition --- .../featureFlags/growthbook/onError/basic/test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts index 5b3cd7ac35b7..fc23f80927ff 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -49,7 +49,14 @@ sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async const event = envelopeRequestParser(req); const values = event.contexts?.flags?.values || []; - const expectedFlags = [{ flag: 'feat2', result: false }]; + + // After the sequence of operations: + // 1. feat1-feat100 are added (100 items) + // 2. feat101 is added, evicts feat1 (100 items: feat2-feat100, feat101) + // 3. feat3 is updated to true, moves to end (100 items: feat2, feat4-feat100, feat101, feat3) + // 4. bool-feat is added, evicts feat2 (100 items: feat4-feat100, feat101, feat3, bool-feat) + + const expectedFlags = []; for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) { expectedFlags.push({ flag: `feat${i}`, result: false }); } From 6f2e0aa172578ee824891d94464d3c0f1539fda7 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 18:44:42 -0700 Subject: [PATCH 17/48] fix lint issues --- .../suites/featureFlags/growthbook/onError/basic/test.ts | 1 - .../suites/featureFlags/growthbook/onSpan/test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts index a709c6329c93..82e39eb62364 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts @@ -7,7 +7,6 @@ afterAll(() => { }); test('GrowthBook flags captured on error with eviction, update, and no async tasks', async () => { - const expectedFlags = []; for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) { expectedFlags.push({ flag: `feat${i}`, result: false }); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts index 65446ed24289..fbb084b98928 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts @@ -21,7 +21,7 @@ test('GrowthBook flags are added to active span attributes on span end', async ( origin: 'manual', status: 'ok', span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/) + trace_id: expect.stringMatching(/[a-f0-9]{32}/), }, }, spans: [], From 7b5af7c238c2f2e5ee89a598eeb1f848a687d0a5 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 18:45:01 -0700 Subject: [PATCH 18/48] fix nuxt build issue --- packages/nuxt/src/index.types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index 4f006e0b5b07..7abb16d197e3 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -19,6 +19,7 @@ export declare const defaultStackParser: StackParser; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; From b351da2d9e2ece543486140349826c45c7867252 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 19:23:22 -0700 Subject: [PATCH 19/48] resolve import conflict --- packages/nextjs/src/index.types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index fe5a75bd5c8b..d982ebbc7559 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -142,6 +142,7 @@ export declare function wrapPageComponentWithSentry(WrappingTarget: C): C; export { captureRequestError } from './common/captureRequestError'; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; From 2a5d25f6ffa41676ba22f31c21647fba5d97bad4 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 22:27:38 -0700 Subject: [PATCH 20/48] fix type cast warning --- .../src/integrations/featureFlags/growthbook/integration.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts index b05aebf97fab..560918535cce 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -1,5 +1,5 @@ import type { IntegrationFn } from '@sentry/core'; -import * as SentryCore from '@sentry/core'; +import { growthbookIntegration as coreGrowthbookIntegration } from '@sentry/core'; import type { GrowthBookClass } from './types'; /** @@ -22,7 +22,5 @@ import type { GrowthBookClass } from './types'; * Sentry.captureException(new Error('something went wrong')); * ``` */ -const _coreAny = SentryCore as unknown as Record; - export const growthbookIntegration = (({ growthbookClass }: { growthbookClass: GrowthBookClass }) => - _coreAny.growthbookIntegration({ growthbookClass })) satisfies IntegrationFn; + coreGrowthbookIntegration({ growthbookClass })) satisfies IntegrationFn; From 9fbd55862f4c16040f17f48fb46b38058e902b20 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Mon, 29 Sep 2025 14:06:09 -0700 Subject: [PATCH 21/48] export growthbook integration --- packages/deno/src/index.ts | 1 - packages/react-router/src/index.types.ts | 1 + packages/remix/src/index.types.ts | 1 + packages/solidstart/src/index.types.ts | 1 + packages/sveltekit/src/index.types.ts | 1 + packages/tanstackstart-react/src/index.types.ts | 1 + 6 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 57da7927b11b..5f987b4459aa 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -89,7 +89,6 @@ export { updateSpanName, wrapMcpServerWithSentry, featureFlagsIntegration, - growthbookIntegration, } from '@sentry/core'; export { DenoClient } from './client'; diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index 150fc45a1e63..58566ba214fe 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -19,6 +19,7 @@ export declare const getDefaultIntegrations: (options: Options) => Integration[] export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts index d0df7397f612..cacbac00e591 100644 --- a/packages/remix/src/index.types.ts +++ b/packages/remix/src/index.types.ts @@ -33,6 +33,7 @@ export const close = runtime === 'client' ? clientSdk.close : serverSdk.close; export const flush = runtime === 'client' ? clientSdk.flush : serverSdk.flush; export const lastEventId = runtime === 'client' ? clientSdk.lastEventId : serverSdk.lastEventId; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts index 7725d1ad3d3c..7f7528a0dddb 100644 --- a/packages/solidstart/src/index.types.ts +++ b/packages/solidstart/src/index.types.ts @@ -26,6 +26,7 @@ export declare function lastEventId(): string | undefined; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index 108e262f9992..40c2f5ff848e 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -60,6 +60,7 @@ export declare function trackComponent(options: clientSdk.TrackingOptions): Retu export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 448ea35f637b..5a44af1b59d4 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -29,6 +29,7 @@ export declare const withErrorBoundary: typeof clientSdk.withErrorBoundary; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; From 03437b1e8eb20f0903a42e34c605ee0fe0496aab Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Mon, 29 Sep 2025 14:22:51 -0700 Subject: [PATCH 22/48] Use actual growthbook instance for testing --- .../node-integration-tests/package.json | 1 + .../growthbook/onError/basic/scenario.ts | 72 +++++++++++++------ .../growthbook/onSpan/scenario.ts | 56 +++++++++------ 3 files changed, 87 insertions(+), 42 deletions(-) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index b9069e7b6c58..3884609740a0 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -26,6 +26,7 @@ "@anthropic-ai/sdk": "0.63.0", "@aws-sdk/client-s3": "^3.552.0", "@google/genai": "^1.20.0", + "@growthbook/growthbook": "^1.6.1", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", "@nestjs/common": "11.1.3", diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts index 2f37507f61d5..3dadfade8713 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts @@ -1,26 +1,60 @@ +import type { ClientOptions, UserContext } from '@growthbook/growthbook'; +import { GrowthBookClient } from '@growthbook/growthbook'; 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'; -// Minimal GrowthBook-like class that matches the real API for testing -// This is necessary since we don't want to add @growthbook/growthbook as a dependency -// just for integration tests, but we want to test the actual integration behavior -class GrowthBookLike { - private _features: Record = {}; +// Wrapper class to instantiate GrowthBookClient +class GrowthBookWrapper { + private _gbClient: GrowthBookClient; + private _userContext: UserContext = { attributes: { id: 'test-user-123' } }; - public isOn(featureKey: string): boolean { - const feature = this._features[featureKey]; - return feature ? !!feature.value : false; + public constructor(..._args: unknown[]) { + // Create GrowthBookClient with proper configuration + const clientOptions: ClientOptions = { + apiHost: 'https://cdn.growthbook.io', + clientKey: 'sdk-abc123' + }; + this._gbClient = new GrowthBookClient(clientOptions); + + // Create features for testing + const features = this._createTestFeatures(); + + this._gbClient.initSync({ + payload: { features } + }); } - public getFeatureValue(featureKey: string, defaultValue: unknown): unknown { - const feature = this._features[featureKey]; - return feature ? feature.value : defaultValue; + public isOn(featureKey: string, ..._rest: unknown[]): boolean { + return this._gbClient.isOn(featureKey, this._userContext); } - // Helper method to set feature values for testing - public setFeature(featureKey: string, value: unknown): void { - this._features[featureKey] = { value }; + public getFeatureValue(featureKey: string, defaultValue: unknown, ..._rest: unknown[]): unknown { + return this._gbClient.getFeatureValue(featureKey, defaultValue as boolean | string | number, this._userContext); + } + + private _createTestFeatures(): Record { + const features: Record = {}; + + // Fill buffer with flags 1-100 (all false by default) + for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { + features[`feat${i}`] = { defaultValue: false }; + } + + // Add feat101 (true), which should evict feat1 + features[`feat${FLAG_BUFFER_SIZE + 1}`] = { defaultValue: true }; + + // Update feat3 to true, which should move it to the end + features['feat3'] = { defaultValue: true }; + + // Test features with boolean values (should be captured) + features['bool-feat'] = { defaultValue: true }; + + // Test features with non-boolean values (should NOT be captured) + features['string-feat'] = { defaultValue: 'hello' }; + features['number-feat'] = { defaultValue: 42 }; + + return features; } } @@ -28,10 +62,11 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', sampleRate: 1.0, transport: loggingTransport, - integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookLike })], + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookWrapper })], }); -const gb = new GrowthBookLike(); +// Create GrowthBookWrapper instance +const gb = new GrowthBookWrapper(); // Fill buffer with flags 1-100 (all false by default) for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { @@ -39,21 +74,16 @@ for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { } // Add feat101 (true), which should evict feat1 -gb.setFeature(`feat${FLAG_BUFFER_SIZE + 1}`, true); gb.isOn(`feat${FLAG_BUFFER_SIZE + 1}`); // Update feat3 to true, which should move it to the end -gb.setFeature('feat3', true); gb.isOn('feat3'); // Test getFeatureValue with boolean values (should be captured) -gb.setFeature('bool-feat', true); gb.getFeatureValue('bool-feat', false); // Test getFeatureValue with non-boolean values (should NOT be captured) -gb.setFeature('string-feat', 'hello'); gb.getFeatureValue('string-feat', 'default'); -gb.setFeature('number-feat', 42); gb.getFeatureValue('number-feat', 0); throw new Error('Test error'); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts index c66f340a4b63..8800711b9901 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts @@ -1,41 +1,56 @@ +import type { ClientOptions, InitSyncOptions, UserContext } from '@growthbook/growthbook'; +import { GrowthBookClient } from '@growthbook/growthbook'; import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; -// Minimal GrowthBook-like class that matches the real API for testing -class GrowthBookLike { - private _features: Record = {}; +// Wrapper class to instantiate GrowthBookClient +class GrowthBookWrapper { + private _gbClient: GrowthBookClient; + private _userContext: UserContext = { attributes: { id: 'test-user-123' } }; - public isOn(featureKey: string): boolean { - const feature = this._features[featureKey]; - return feature ? !!feature.value : false; + public constructor(..._args: unknown[]) { + // Create GrowthBookClient and initialize it synchronously with payload + const clientOptions: ClientOptions = { + apiHost: 'https://cdn.growthbook.io', + clientKey: 'sdk-abc123' + }; + this._gbClient = new GrowthBookClient(clientOptions); + + // Create test features + const features = { + 'feat1': { defaultValue: true }, + 'feat2': { defaultValue: false }, + 'bool-feat': { defaultValue: true }, + 'string-feat': { defaultValue: 'hello' } + }; + + // Initialize synchronously with payload + const initOptions: InitSyncOptions = { + payload: { features } + }; + + this._gbClient.initSync(initOptions); } - public getFeatureValue(featureKey: string, defaultValue: unknown): unknown { - const feature = this._features[featureKey]; - return feature ? feature.value : defaultValue; + public isOn(featureKey: string, ..._rest: unknown[]): boolean { + return this._gbClient.isOn(featureKey, this._userContext); } - // Helper method to set feature values for testing - public setFeature(featureKey: string, value: unknown): void { - this._features[featureKey] = { value }; + public getFeatureValue(featureKey: string, defaultValue: unknown, ..._rest: unknown[]): unknown { + return this._gbClient.getFeatureValue(featureKey, defaultValue as boolean | string | number, this._userContext); } } +const gb = new GrowthBookWrapper(); + Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', sampleRate: 1.0, tracesSampleRate: 1.0, transport: loggingTransport, - integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookLike })], + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookWrapper })], }); -const gb = new GrowthBookLike(); - -// Set up feature flags -gb.setFeature('feat1', true); -gb.setFeature('feat2', false); -gb.setFeature('bool-feat', true); - Sentry.startSpan({ name: 'test-span', op: 'function' }, () => { // Evaluate feature flags during the span gb.isOn('feat1'); @@ -45,6 +60,5 @@ Sentry.startSpan({ name: 'test-span', op: 'function' }, () => { gb.getFeatureValue('bool-feat', false); // Test getFeatureValue with non-boolean values (should NOT be captured) - gb.setFeature('string-feat', 'hello'); gb.getFeatureValue('string-feat', 'default'); }); From 0ffa0a6f9801141f626c009a50b5691ac107fa35 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Mon, 29 Sep 2025 22:12:10 -0700 Subject: [PATCH 23/48] fix linting issues --- .../featureFlags/growthbook/onError/basic/scenario.ts | 4 ++-- .../suites/featureFlags/growthbook/onSpan/scenario.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts index 3dadfade8713..f907e320696d 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts @@ -13,7 +13,7 @@ class GrowthBookWrapper { // Create GrowthBookClient with proper configuration const clientOptions: ClientOptions = { apiHost: 'https://cdn.growthbook.io', - clientKey: 'sdk-abc123' + clientKey: 'sdk-abc123', }; this._gbClient = new GrowthBookClient(clientOptions); @@ -21,7 +21,7 @@ class GrowthBookWrapper { const features = this._createTestFeatures(); this._gbClient.initSync({ - payload: { features } + payload: { features }, }); } diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts index 8800711b9901..b25f36f00951 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts @@ -12,21 +12,21 @@ class GrowthBookWrapper { // Create GrowthBookClient and initialize it synchronously with payload const clientOptions: ClientOptions = { apiHost: 'https://cdn.growthbook.io', - clientKey: 'sdk-abc123' + clientKey: 'sdk-abc123', }; this._gbClient = new GrowthBookClient(clientOptions); // Create test features const features = { - 'feat1': { defaultValue: true }, - 'feat2': { defaultValue: false }, + feat1: { defaultValue: true }, + feat2: { defaultValue: false }, 'bool-feat': { defaultValue: true }, - 'string-feat': { defaultValue: 'hello' } + 'string-feat': { defaultValue: 'hello' }, }; // Initialize synchronously with payload const initOptions: InitSyncOptions = { - payload: { features } + payload: { features }, }; this._gbClient.initSync(initOptions); From dec04770556880dd8f0890b5d5801899801c8838 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 11:43:51 -0700 Subject: [PATCH 24/48] add growthbook integration --- packages/browser/src/index.ts | 1 + packages/node/src/integrations/featureFlagShims/index.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 5e9924fe6da5..308caea7305f 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -73,6 +73,7 @@ export { browserSessionIntegration } from './integrations/browsersession'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly'; export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature'; export { unleashIntegration } from './integrations/featureFlags/unleash'; +export { growthbookIntegration } from './integrations/featureFlags/growthbook'; export { statsigIntegration } from './integrations/featureFlags/statsig'; export { diagnoseSdkConnectivity } from './diagnose-sdk'; export { webWorkerIntegration, registerWebWorker } from './integrations/webWorker'; diff --git a/packages/node/src/integrations/featureFlagShims/index.ts b/packages/node/src/integrations/featureFlagShims/index.ts index 230dbaeeb7e8..ef90a562983f 100644 --- a/packages/node/src/integrations/featureFlagShims/index.ts +++ b/packages/node/src/integrations/featureFlagShims/index.ts @@ -11,3 +11,5 @@ export { export { statsigIntegrationShim as statsigIntegration } from './statsig'; export { unleashIntegrationShim as unleashIntegration } from './unleash'; + +export { growthbookIntegrationShim as growthbookIntegration } from './growthbook'; From 26f7d8d807d2f9995ac658e9d042dedf13bbd89b Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 11:44:42 -0700 Subject: [PATCH 25/48] add handler --- .../browser/src/integrations/featureFlags/growthbook/index.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/browser/src/integrations/featureFlags/growthbook/index.ts diff --git a/packages/browser/src/integrations/featureFlags/growthbook/index.ts b/packages/browser/src/integrations/featureFlags/growthbook/index.ts new file mode 100644 index 000000000000..f5f98c94c4bb --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/index.ts @@ -0,0 +1,3 @@ +export { growthbookIntegration } from './integration'; + + From 440b42617ffa7b68a78e55b5e89f1c1631b7fa8d Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 11:45:20 -0700 Subject: [PATCH 26/48] add handler, types and shim --- .../featureFlags/growthbook/integration.ts | 69 +++++++++++++++++++ .../featureFlags/growthbook/types.ts | 9 +++ .../featureFlagShims/growthbook.ts | 19 +++++ 3 files changed, 97 insertions(+) create mode 100644 packages/browser/src/integrations/featureFlags/growthbook/integration.ts create mode 100644 packages/browser/src/integrations/featureFlags/growthbook/types.ts create mode 100644 packages/node/src/integrations/featureFlagShims/growthbook.ts diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts new file mode 100644 index 000000000000..70a327a132b9 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -0,0 +1,69 @@ +import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; +import { + _INTERNAL_addFeatureFlagToActiveSpan, + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_insertFlagToScope, + defineIntegration, + fill, +} from '@sentry/core'; + +import type { GrowthBook, GrowthBookClass } from './types'; + +/** + * Sentry integration for capturing feature flag evaluations from GrowthBook. + * + * See the feature flag documentation: https://develop.sentry.dev/sdk/expected-features/#feature-flags + * + * @example + * ``` + * import { GrowthBook } from '@growthbook/growthbook'; + * import * as Sentry from '@sentry/browser'; + * + * Sentry.init({ + * dsn: '___PUBLIC_DSN___', + * integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBook })], + * }); + * + * const gb = new GrowthBook(); + * gb.isOn('my-feature'); + * Sentry.captureException(new Error('something went wrong')); + * ``` + */ +export const growthbookIntegration = defineIntegration(({ growthbookClass }: { growthbookClass: GrowthBookClass }) => { + return { + name: 'GrowthBook', + + setupOnce() { + const proto = growthbookClass.prototype as GrowthBook; + fill(proto, 'isOn', _wrapBooleanReturningMethod); + fill(proto, 'getFeatureValue', _wrapBooleanReturningMethod); + }, + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return _INTERNAL_copyFlagsFromScopeToEvent(event); + }, + }; +}) satisfies IntegrationFn; + +function _wrapBooleanReturningMethod( + original: (this: GrowthBook, ...args: unknown[]) => unknown, +): (this: GrowthBook, ...args: unknown[]) => unknown { + return function (this: GrowthBook, ...args: unknown[]): unknown { + const flagName = args[0]; + const result = original.apply(this, args); + // Capture any JSON-serializable result (booleans, strings, numbers, null, plain objects/arrays). + // Skip functions/symbols/undefined. + if ( + typeof flagName === 'string' && + typeof result !== 'undefined' && + typeof result !== 'function' && + typeof result !== 'symbol' + ) { + _INTERNAL_insertFlagToScope(flagName, result); + _INTERNAL_addFeatureFlagToActiveSpan(flagName, result); + } + return result; + }; +} + + diff --git a/packages/browser/src/integrations/featureFlags/growthbook/types.ts b/packages/browser/src/integrations/featureFlags/growthbook/types.ts new file mode 100644 index 000000000000..46270b8efa8f --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/types.ts @@ -0,0 +1,9 @@ +export interface GrowthBook { + isOn(this: GrowthBook, featureKey: string): boolean; + getFeatureValue(this: GrowthBook, featureKey: string, defaultValue: unknown): unknown; +} + +// We only depend on the surface we wrap; constructor args are irrelevant here. +export type GrowthBookClass = new (...args: unknown[]) => GrowthBook; + + diff --git a/packages/node/src/integrations/featureFlagShims/growthbook.ts b/packages/node/src/integrations/featureFlagShims/growthbook.ts new file mode 100644 index 000000000000..3e4845046efd --- /dev/null +++ b/packages/node/src/integrations/featureFlagShims/growthbook.ts @@ -0,0 +1,19 @@ +import { consoleSandbox, defineIntegration, isBrowser } from '@sentry/core'; + +/** + * Shim for the GrowthBook integration to avoid runtime errors when imported on the server. + */ +export const growthbookIntegrationShim = defineIntegration((_options?: unknown) => { + if (!isBrowser()) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('The growthbookIntegration() can only be used in the browser.'); + }); + } + + return { + name: 'GrowthBook', + }; +}); + + From f04b046cf9e87275526392273d5f8269bcd5ed55 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 12:02:11 -0700 Subject: [PATCH 27/48] capture evalFeature method --- .../src/integrations/featureFlags/growthbook/integration.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts index 70a327a132b9..1fb19a5697e3 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -37,6 +37,10 @@ export const growthbookIntegration = defineIntegration(({ growthbookClass }: { g const proto = growthbookClass.prototype as GrowthBook; fill(proto, 'isOn', _wrapBooleanReturningMethod); fill(proto, 'getFeatureValue', _wrapBooleanReturningMethod); + // Also capture evalFeature when present. Not all versions have it, so guard. + if (typeof (proto as unknown as Record).evalFeature === 'function') { + fill(proto as any, 'evalFeature', _wrapBooleanReturningMethod as any); + } }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { From 4256177ba77bbe51052d09ca3ba144ec81adbc72 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 14:51:47 -0700 Subject: [PATCH 28/48] add integration tests --- .../growthbook/onError/basic/test.ts | 57 ++++++++++++++++ .../featureFlags/growthbook/onError/init.js | 37 +++++++++++ .../growthbook/onError/subject.js | 3 + .../growthbook/onError/template.html | 12 ++++ .../growthbook/onError/withScope/test.ts | 61 +++++++++++++++++ .../featureFlags/growthbook/onSpan/init.js | 39 +++++++++++ .../featureFlags/growthbook/onSpan/subject.js | 3 + .../growthbook/onSpan/template.html | 12 ++++ .../featureFlags/growthbook/onSpan/test.ts | 65 +++++++++++++++++++ .../featureFlags/growthbook/index.ts | 2 - .../featureFlags/growthbook/integration.ts | 3 - .../featureFlags/growthbook/types.ts | 2 - .../featureFlagShims/growthbook.ts | 2 - 13 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts new file mode 100644 index 000000000000..9e1a3698aa3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -0,0 +1,57 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; +import { FLAG_BUFFER_SIZE } from '../../constants'; + +sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + await page.evaluate(bufferSize => { + const gb = new (window as any).GrowthBook(); + + gb.__setOn('onTrue', true); + gb.__setOn('onFalse', false); + gb.__setFeatureValue('strVal', 'hello'); + gb.__setFeatureValue('numVal', 42); + gb.__setFeatureValue('objVal', { a: 1, b: 'c' }); + + gb.isOn('onTrue'); + gb.isOn('onFalse'); + gb.getFeatureValue('strVal', ''); + gb.getFeatureValue('numVal', 0); + gb.getFeatureValue('objVal', {}); + + for (let i = 1; i <= bufferSize; i++) { + gb.isOn(`feat${i}`); + } + + gb.__setOn(`feat${bufferSize + 1}`, true); + gb.isOn(`feat${bufferSize + 1}`); + gb.isOn('feat3'); + }, FLAG_BUFFER_SIZE); + + const reqPromise = waitForErrorRequest(page); + await page.locator('#error').click(); + const req = await reqPromise; + const event = envelopeRequestParser(req); + + const values = event.contexts?.flags?.values || []; + expect(values).toEqual( + expect.arrayContaining([ + { flag: 'onTrue', result: true }, + { flag: 'onFalse', result: false }, + { flag: 'strVal', result: 'hello' }, + { flag: 'numVal', result: 42 }, + { flag: 'objVal', result: { a: 1, b: 'c' } }, + ]), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js new file mode 100644 index 000000000000..e7831a1c2c0b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/browser'; + +// Minimal mock GrowthBook class for tests +window.GrowthBook = class { + constructor() { + this._onFlags = Object.create(null); + this._featureValues = Object.create(null); + } + + isOn(featureKey) { + return !!this._onFlags[featureKey]; + } + + getFeatureValue(featureKey, defaultValue) { + return Object.prototype.hasOwnProperty.call(this._featureValues, featureKey) + ? this._featureValues[featureKey] + : defaultValue; + } + + // Helpers for tests + __setOn(featureKey, value) { + this._onFlags[featureKey] = !!value; + } + + __setFeatureValue(featureKey, value) { + this._featureValues[featureKey] = value; + } +}; + +window.Sentry = Sentry; +window.sentryGrowthBookIntegration = Sentry.growthbookIntegration({ growthbookClass: window.GrowthBook }); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + integrations: [window.sentryGrowthBookIntegration], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js new file mode 100644 index 000000000000..e6697408128c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js @@ -0,0 +1,3 @@ +document.getElementById('error').addEventListener('click', () => { + throw new Error('Button triggered error'); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html new file mode 100644 index 000000000000..da7d69a24c97 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts new file mode 100644 index 000000000000..a5a48b48690e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts @@ -0,0 +1,61 @@ +import { expect } from '@playwright/test'; +import type { Scope } from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers'; + +sentryTest('GrowthBook onError: forked scopes are isolated', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === true); + const mainReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === false); + + await page.evaluate(() => { + const Sentry = (window as any).Sentry; + const errorButton = document.querySelector('#error') as HTMLButtonElement; + const gb = new (window as any).GrowthBook(); + + gb.__setOn('shared', true); + gb.__setOn('main', true); + + gb.isOn('shared'); + + Sentry.withScope((scope: Scope) => { + gb.__setOn('forked', true); + gb.__setOn('shared', false); + gb.isOn('forked'); + gb.isOn('shared'); + scope.setTag('isForked', true); + errorButton.click(); + }); + + gb.isOn('main'); + Sentry.getCurrentScope().setTag('isForked', false); + errorButton.click(); + return true; + }); + + const forkedReq = await forkedReqPromise; + const forkedEvent = envelopeRequestParser(forkedReq); + + const mainReq = await mainReqPromise; + const mainEvent = envelopeRequestParser(mainReq); + + expect(forkedEvent.contexts?.flags?.values).toEqual([ + { flag: 'forked', result: true }, + { flag: 'shared', result: false }, + ]); + + expect(mainEvent.contexts?.flags?.values).toEqual([ + { flag: 'shared', result: true }, + { flag: 'main', result: true }, + ]); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js new file mode 100644 index 000000000000..d755d7a1d972 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; + +window.GrowthBook = class { + constructor() { + this._onFlags = Object.create(null); + this._featureValues = Object.create(null); + } + + isOn(featureKey) { + return !!this._onFlags[featureKey]; + } + + getFeatureValue(featureKey, defaultValue) { + return Object.prototype.hasOwnProperty.call(this._featureValues, featureKey) + ? this._featureValues[featureKey] + : defaultValue; + } + + __setOn(featureKey, value) { + this._onFlags[featureKey] = !!value; + } + + __setFeatureValue(featureKey, value) { + this._featureValues[featureKey] = value; + } +}; + +window.Sentry = Sentry; +window.sentryGrowthBookIntegration = Sentry.growthbookIntegration({ growthbookClass: window.GrowthBook }); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + tracesSampleRate: 1.0, + integrations: [ + window.sentryGrowthBookIntegration, + Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js new file mode 100644 index 000000000000..a036abf76c2e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js @@ -0,0 +1,3 @@ +document.getElementById('error').addEventListener('click', () => { + // no-op +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html new file mode 100644 index 000000000000..da7d69a24c97 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts new file mode 100644 index 000000000000..6661edc9723d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts @@ -0,0 +1,65 @@ +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, + eventAndTraceHeaderRequestParser, + getMultipleSentryEnvelopeRequests, + shouldSkipFeatureFlagsTest, + shouldSkipTracingTest, +} from '../../../../../utils/helpers'; + +sentryTest( + "GrowthBook onSpan: 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, + ); + + await page.evaluate(maxFlags => { + (window as any).withNestedSpans(() => { + const gb = new (window as any).GrowthBook(); + for (let i = 1; i <= maxFlags; i++) { + gb.isOn(`feat${i}`); + } + gb.__setOn(`feat${maxFlags + 1}`, true); + gb.isOn(`feat${maxFlags + 1}`); // dropped + gb.__setOn('feat3', true); + gb.isOn('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 = [] as Array<[string, unknown]>; + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); + } + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); + }, +); diff --git a/packages/browser/src/integrations/featureFlags/growthbook/index.ts b/packages/browser/src/integrations/featureFlags/growthbook/index.ts index f5f98c94c4bb..a931e2376ab7 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/index.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/index.ts @@ -1,3 +1 @@ export { growthbookIntegration } from './integration'; - - diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts index 1fb19a5697e3..8a71267a1c0c 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -6,7 +6,6 @@ import { defineIntegration, fill, } from '@sentry/core'; - import type { GrowthBook, GrowthBookClass } from './types'; /** @@ -69,5 +68,3 @@ function _wrapBooleanReturningMethod( return result; }; } - - diff --git a/packages/browser/src/integrations/featureFlags/growthbook/types.ts b/packages/browser/src/integrations/featureFlags/growthbook/types.ts index 46270b8efa8f..df0971235078 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/types.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/types.ts @@ -5,5 +5,3 @@ export interface GrowthBook { // We only depend on the surface we wrap; constructor args are irrelevant here. export type GrowthBookClass = new (...args: unknown[]) => GrowthBook; - - diff --git a/packages/node/src/integrations/featureFlagShims/growthbook.ts b/packages/node/src/integrations/featureFlagShims/growthbook.ts index 3e4845046efd..a36bf6ba18c8 100644 --- a/packages/node/src/integrations/featureFlagShims/growthbook.ts +++ b/packages/node/src/integrations/featureFlagShims/growthbook.ts @@ -15,5 +15,3 @@ export const growthbookIntegrationShim = defineIntegration((_options?: unknown) name: 'GrowthBook', }; }); - - From 7018fe77417209d9cc0c75394306d652a0ee4bf6 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 15:35:03 -0700 Subject: [PATCH 29/48] fix fixture imports --- .../growthbook/onError/basic/test.ts | 32 +++++++++++-------- .../growthbook/onError/withScope/test.ts | 8 +++-- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts index 9e1a3698aa3a..6cb35265b982 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/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 { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { @@ -18,6 +22,15 @@ sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ( await page.evaluate(bufferSize => { const gb = new (window as any).GrowthBook(); + for (let i = 1; i <= bufferSize; i++) { + gb.isOn(`feat${i}`); + } + + gb.__setOn(`feat${bufferSize + 1}`, true); + gb.isOn(`feat${bufferSize + 1}`); + gb.isOn('feat3'); + + // Add typed flags at the end so they are not evicted gb.__setOn('onTrue', true); gb.__setOn('onFalse', false); gb.__setFeatureValue('strVal', 'hello'); @@ -29,14 +42,6 @@ sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ( gb.getFeatureValue('strVal', ''); gb.getFeatureValue('numVal', 0); gb.getFeatureValue('objVal', {}); - - for (let i = 1; i <= bufferSize; i++) { - gb.isOn(`feat${i}`); - } - - gb.__setOn(`feat${bufferSize + 1}`, true); - gb.isOn(`feat${bufferSize + 1}`); - gb.isOn('feat3'); }, FLAG_BUFFER_SIZE); const reqPromise = waitForErrorRequest(page); @@ -45,13 +50,12 @@ sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ( const event = envelopeRequestParser(req); const values = event.contexts?.flags?.values || []; + // Only assert presence when buffer wasn't fully overwritten by filler flags + // just check capture of some typed values. expect(values).toEqual( expect.arrayContaining([ { flag: 'onTrue', result: true }, { flag: 'onFalse', result: false }, - { flag: 'strVal', result: 'hello' }, - { flag: 'numVal', result: 42 }, - { flag: 'objVal', result: { a: 1, b: 'c' } }, ]), ); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts index a5a48b48690e..48fa4718b856 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/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('GrowthBook onError: forked scopes are isolated', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { From f245d4e7717a5bd941865e431d12fe1147e6c4d7 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 15:35:42 -0700 Subject: [PATCH 30/48] use withNestedSpans --- .../featureFlags/growthbook/onSpan/subject.js | 19 ++++++++++++++++--- .../growthbook/onSpan/template.html | 5 ++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js index a036abf76c2e..ad874b2bd697 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js @@ -1,3 +1,16 @@ -document.getElementById('error').addEventListener('click', () => { - // no-op -}); +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/growthbook/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html index da7d69a24c97..4efb91e75451 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html @@ -4,7 +4,10 @@ - + + + + From 909194bbf9818e64cd517806cabcb2b34bbab5f3 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 21 Aug 2025 16:09:53 -0700 Subject: [PATCH 31/48] fill buffer and assert ordered list --- .../growthbook/onError/basic/test.ts | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts index 6cb35265b982..77bbee30ea32 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -7,7 +7,7 @@ import { waitForErrorRequest, } from '../../../../../../utils/helpers'; -sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ({ getLocalTestUrl, page }) => { +sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async ({ getLocalTestUrl, page }) => { if (shouldSkipFeatureFlagsTest()) { sentryTest.skip(); } @@ -27,21 +27,10 @@ sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ( } gb.__setOn(`feat${bufferSize + 1}`, true); - gb.isOn(`feat${bufferSize + 1}`); - gb.isOn('feat3'); - - // Add typed flags at the end so they are not evicted - gb.__setOn('onTrue', true); - gb.__setOn('onFalse', false); - gb.__setFeatureValue('strVal', 'hello'); - gb.__setFeatureValue('numVal', 42); - gb.__setFeatureValue('objVal', { a: 1, b: 'c' }); - - gb.isOn('onTrue'); - gb.isOn('onFalse'); - gb.getFeatureValue('strVal', ''); - gb.getFeatureValue('numVal', 0); - gb.getFeatureValue('objVal', {}); + gb.isOn(`feat${bufferSize + 1}`); // eviction + + gb.__setOn('feat3', true); + gb.isOn('feat3'); // update }, FLAG_BUFFER_SIZE); const reqPromise = waitForErrorRequest(page); @@ -50,12 +39,12 @@ sentryTest('GrowthBook onError: basic eviction/update and mixed values', async ( const event = envelopeRequestParser(req); const values = event.contexts?.flags?.values || []; - // Only assert presence when buffer wasn't fully overwritten by filler flags - // just check capture of some typed values. - expect(values).toEqual( - expect.arrayContaining([ - { flag: 'onTrue', result: true }, - { flag: 'onFalse', result: false }, - ]), - ); + const expectedFlags = [{ flag: 'feat2', result: false }]; + for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) { + expectedFlags.push({ flag: `feat${i}`, result: false }); + } + expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true }); + expectedFlags.push({ flag: 'feat3', result: true }); + + expect(values).toEqual(expectedFlags); }); From 2a2ab110d0ccf4229d8f7309ef9f0d7f06b4086b Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 23 Sep 2025 14:39:40 -0700 Subject: [PATCH 32/48] move gb integration to core --- .../featureFlags/growthbook/integration.ts | 54 ++------------ .../featureFlags/growthbook/types.ts | 4 +- packages/core/src/index.ts | 1 + .../integrations/featureFlags/growthbook.ts | 70 +++++++++++++++++++ .../src/integrations/featureFlags/index.ts | 1 + .../featureFlagShims/growthbook.ts | 27 +++---- 6 files changed, 95 insertions(+), 62 deletions(-) create mode 100644 packages/core/src/integrations/featureFlags/growthbook.ts diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts index 8a71267a1c0c..b05aebf97fab 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -1,12 +1,6 @@ -import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core'; -import { - _INTERNAL_addFeatureFlagToActiveSpan, - _INTERNAL_copyFlagsFromScopeToEvent, - _INTERNAL_insertFlagToScope, - defineIntegration, - fill, -} from '@sentry/core'; -import type { GrowthBook, GrowthBookClass } from './types'; +import type { IntegrationFn } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import type { GrowthBookClass } from './types'; /** * Sentry integration for capturing feature flag evaluations from GrowthBook. @@ -28,43 +22,7 @@ import type { GrowthBook, GrowthBookClass } from './types'; * Sentry.captureException(new Error('something went wrong')); * ``` */ -export const growthbookIntegration = defineIntegration(({ growthbookClass }: { growthbookClass: GrowthBookClass }) => { - return { - name: 'GrowthBook', +const _coreAny = SentryCore as unknown as Record; - setupOnce() { - const proto = growthbookClass.prototype as GrowthBook; - fill(proto, 'isOn', _wrapBooleanReturningMethod); - fill(proto, 'getFeatureValue', _wrapBooleanReturningMethod); - // Also capture evalFeature when present. Not all versions have it, so guard. - if (typeof (proto as unknown as Record).evalFeature === 'function') { - fill(proto as any, 'evalFeature', _wrapBooleanReturningMethod as any); - } - }, - - processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return _INTERNAL_copyFlagsFromScopeToEvent(event); - }, - }; -}) satisfies IntegrationFn; - -function _wrapBooleanReturningMethod( - original: (this: GrowthBook, ...args: unknown[]) => unknown, -): (this: GrowthBook, ...args: unknown[]) => unknown { - return function (this: GrowthBook, ...args: unknown[]): unknown { - const flagName = args[0]; - const result = original.apply(this, args); - // Capture any JSON-serializable result (booleans, strings, numbers, null, plain objects/arrays). - // Skip functions/symbols/undefined. - if ( - typeof flagName === 'string' && - typeof result !== 'undefined' && - typeof result !== 'function' && - typeof result !== 'symbol' - ) { - _INTERNAL_insertFlagToScope(flagName, result); - _INTERNAL_addFeatureFlagToActiveSpan(flagName, result); - } - return result; - }; -} +export const growthbookIntegration = (({ growthbookClass }: { growthbookClass: GrowthBookClass }) => + _coreAny.growthbookIntegration({ growthbookClass })) satisfies IntegrationFn; diff --git a/packages/browser/src/integrations/featureFlags/growthbook/types.ts b/packages/browser/src/integrations/featureFlags/growthbook/types.ts index df0971235078..5a852d633da9 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/types.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/types.ts @@ -1,6 +1,6 @@ export interface GrowthBook { - isOn(this: GrowthBook, featureKey: string): boolean; - getFeatureValue(this: GrowthBook, featureKey: string, defaultValue: unknown): unknown; + isOn(this: GrowthBook, featureKey: string, ...rest: unknown[]): boolean; + getFeatureValue(this: GrowthBook, featureKey: string, defaultValue: unknown, ...rest: unknown[]): unknown; } // We only depend on the surface we wrap; constructor args are irrelevant here. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e0daefd54d76..d212fbc9127a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,6 +113,7 @@ 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 { growthbookIntegration } from './integrations/featureFlags'; export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) diff --git a/packages/core/src/integrations/featureFlags/growthbook.ts b/packages/core/src/integrations/featureFlags/growthbook.ts new file mode 100644 index 000000000000..24162d279c52 --- /dev/null +++ b/packages/core/src/integrations/featureFlags/growthbook.ts @@ -0,0 +1,70 @@ +import type { Client } from '../../client'; +import { defineIntegration } from '../../integration'; +import type { Event, EventHint } from '../../types-hoist/event'; +import type { IntegrationFn } from '../../types-hoist/integration'; +import { + _INTERNAL_addFeatureFlagToActiveSpan, + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_insertFlagToScope, +} from '../../utils/featureFlags'; +import { fill } from '../../utils/object'; + +interface GrowthBookLike { + isOn(this: GrowthBookLike, featureKey: string, ...rest: unknown[]): boolean; + getFeatureValue(this: GrowthBookLike, featureKey: string, defaultValue: unknown, ...rest: unknown[]): unknown; +} + +export type GrowthBookClassLike = new (...args: unknown[]) => GrowthBookLike; + +/** + * Sentry integration for capturing feature flag evaluations from GrowthBook. + * + * Only boolean results are captured at this time. + */ +export const growthbookIntegration = defineIntegration( + ({ growthbookClass }: { growthbookClass: GrowthBookClassLike }) => { + return { + name: 'GrowthBook', + + setupOnce() { + const proto = growthbookClass.prototype as GrowthBookLike; + + // Type guard and wrap isOn + if (typeof proto.isOn === 'function') { + fill(proto, 'isOn', _wrapAndCaptureBooleanResult); + } + + // Type guard and wrap getFeatureValue + if (typeof proto.getFeatureValue === 'function') { + fill(proto, 'getFeatureValue', _wrapAndCaptureBooleanResult); + } + + // Type guard and wrap evalFeature if present + const maybeEval = (proto as unknown as Record).evalFeature; + if (typeof maybeEval === 'function') { + fill(proto as unknown as Record, 'evalFeature', _wrapAndCaptureBooleanResult as any); + } + }, + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return _INTERNAL_copyFlagsFromScopeToEvent(event); + }, + }; + }, +) satisfies IntegrationFn; + +function _wrapAndCaptureBooleanResult( + original: (this: GrowthBookLike, ...args: unknown[]) => unknown, +): (this: GrowthBookLike, ...args: unknown[]) => unknown { + return function (this: GrowthBookLike, ...args: unknown[]): unknown { + const flagName = args[0]; + const result = original.apply(this, args); + + if (typeof flagName === 'string' && typeof result === 'boolean') { + _INTERNAL_insertFlagToScope(flagName, result); + _INTERNAL_addFeatureFlagToActiveSpan(flagName, result); + } + + return result; + }; +} diff --git a/packages/core/src/integrations/featureFlags/index.ts b/packages/core/src/integrations/featureFlags/index.ts index 2106ee7accf0..f0ee5ece65b2 100644 --- a/packages/core/src/integrations/featureFlags/index.ts +++ b/packages/core/src/integrations/featureFlags/index.ts @@ -1 +1,2 @@ export { featureFlagsIntegration, type FeatureFlagsIntegration } from './featureFlagsIntegration'; +export { growthbookIntegration } from './growthbook'; diff --git a/packages/node/src/integrations/featureFlagShims/growthbook.ts b/packages/node/src/integrations/featureFlagShims/growthbook.ts index a36bf6ba18c8..bc7aa49f77f2 100644 --- a/packages/node/src/integrations/featureFlagShims/growthbook.ts +++ b/packages/node/src/integrations/featureFlagShims/growthbook.ts @@ -1,17 +1,20 @@ -import { consoleSandbox, defineIntegration, isBrowser } from '@sentry/core'; +import { + defineIntegration, + growthbookIntegration as coreGrowthbookIntegration, + isBrowser, +} from '@sentry/core'; /** * Shim for the GrowthBook integration to avoid runtime errors when imported on the server. */ -export const growthbookIntegrationShim = defineIntegration((_options?: unknown) => { - if (!isBrowser()) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn('The growthbookIntegration() can only be used in the browser.'); - }); - } +export const growthbookIntegrationShim = defineIntegration( + (options: Parameters[0]) => { + if (!isBrowser()) { + // On Node, just return the core integration so Node SDKs can also use it. + return coreGrowthbookIntegration(options); + } - return { - name: 'GrowthBook', - }; -}); + // In browser, still return the integration to preserve behavior. + return coreGrowthbookIntegration(options); + }, +); From f96d2046418724c7b7a315000b355c24fc22d31c Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 23 Sep 2025 14:39:51 -0700 Subject: [PATCH 33/48] update tests --- .../featureFlags/growthbook/onError/basic/test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts index 77bbee30ea32..5b3cd7ac35b7 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -31,6 +31,16 @@ sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async gb.__setOn('feat3', true); gb.isOn('feat3'); // update + + // Test getFeatureValue with boolean values (should be captured) + gb.__setFeatureValue('bool-feat', true); + gb.getFeatureValue('bool-feat', false); + + // Test getFeatureValue with non-boolean values (should be ignored) + gb.__setFeatureValue('string-feat', 'hello'); + gb.getFeatureValue('string-feat', 'default'); + gb.__setFeatureValue('number-feat', 42); + gb.getFeatureValue('number-feat', 0); }, FLAG_BUFFER_SIZE); const reqPromise = waitForErrorRequest(page); @@ -45,6 +55,7 @@ sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async } expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true }); expectedFlags.push({ flag: 'feat3', result: true }); + expectedFlags.push({ flag: 'bool-feat', result: true }); // Only boolean getFeatureValue should be captured expect(values).toEqual(expectedFlags); }); From d9c81686b5a1531bd1def59803b5215e62d0c8e2 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 23 Sep 2025 18:49:15 -0700 Subject: [PATCH 34/48] remove evalFunction --- .../integrations/featureFlags/growthbook.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/src/integrations/featureFlags/growthbook.ts b/packages/core/src/integrations/featureFlags/growthbook.ts index 24162d279c52..8fb5cc49cc0a 100644 --- a/packages/core/src/integrations/featureFlags/growthbook.ts +++ b/packages/core/src/integrations/featureFlags/growthbook.ts @@ -20,6 +20,19 @@ export type GrowthBookClassLike = new (...args: unknown[]) => GrowthBookLike; * Sentry integration for capturing feature flag evaluations from GrowthBook. * * Only boolean results are captured at this time. + * + * @example + * ```typescript + * import { GrowthBook } from '@growthbook/growthbook'; + * import * as Sentry from '@sentry/browser'; // or '@sentry/node' + * + * Sentry.init({ + * dsn: 'your-dsn', + * integrations: [ + * Sentry.growthbookIntegration({ growthbookClass: GrowthBook }) + * ] + * }); + * ``` */ export const growthbookIntegration = defineIntegration( ({ growthbookClass }: { growthbookClass: GrowthBookClassLike }) => { @@ -39,11 +52,6 @@ export const growthbookIntegration = defineIntegration( fill(proto, 'getFeatureValue', _wrapAndCaptureBooleanResult); } - // Type guard and wrap evalFeature if present - const maybeEval = (proto as unknown as Record).evalFeature; - if (typeof maybeEval === 'function') { - fill(proto as unknown as Record, 'evalFeature', _wrapAndCaptureBooleanResult as any); - } }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { From 51e4716a0b8bab3d560a0993057b7a4d9fe8e7bd Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 23 Sep 2025 19:00:32 -0700 Subject: [PATCH 35/48] fix shim --- .../integrations/featureFlags/growthbook.ts | 1 - .../featureFlagShims/growthbook.ts | 21 ++++--------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/core/src/integrations/featureFlags/growthbook.ts b/packages/core/src/integrations/featureFlags/growthbook.ts index 8fb5cc49cc0a..a72e48e0f596 100644 --- a/packages/core/src/integrations/featureFlags/growthbook.ts +++ b/packages/core/src/integrations/featureFlags/growthbook.ts @@ -51,7 +51,6 @@ export const growthbookIntegration = defineIntegration( if (typeof proto.getFeatureValue === 'function') { fill(proto, 'getFeatureValue', _wrapAndCaptureBooleanResult); } - }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { diff --git a/packages/node/src/integrations/featureFlagShims/growthbook.ts b/packages/node/src/integrations/featureFlagShims/growthbook.ts index bc7aa49f77f2..d86f3a0349bc 100644 --- a/packages/node/src/integrations/featureFlagShims/growthbook.ts +++ b/packages/node/src/integrations/featureFlagShims/growthbook.ts @@ -1,20 +1,7 @@ -import { - defineIntegration, - growthbookIntegration as coreGrowthbookIntegration, - isBrowser, -} from '@sentry/core'; +import { growthbookIntegration as coreGrowthbookIntegration } from '@sentry/core'; /** - * Shim for the GrowthBook integration to avoid runtime errors when imported on the server. + * Re-export the core GrowthBook integration for Node.js usage. + * The core integration is runtime-agnostic and works in both browser and Node environments. */ -export const growthbookIntegrationShim = defineIntegration( - (options: Parameters[0]) => { - if (!isBrowser()) { - // On Node, just return the core integration so Node SDKs can also use it. - return coreGrowthbookIntegration(options); - } - - // In browser, still return the integration to preserve behavior. - return coreGrowthbookIntegration(options); - }, -); +export const growthbookIntegrationShim = coreGrowthbookIntegration; From 6e732506a324c5d8845aad3b2a31b6a3dcdd8a99 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 16:08:45 -0700 Subject: [PATCH 36/48] add node integration tests --- .../growthbook/onError/basic/scenario.ts | 59 +++++++++++++++++++ .../growthbook/onError/basic/test.ts | 32 ++++++++++ .../growthbook/onSpan/scenario.ts | 50 ++++++++++++++++ .../featureFlags/growthbook/onSpan/test.ts | 34 +++++++++++ packages/node/src/index.ts | 1 + 5 files changed, 176 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts new file mode 100644 index 000000000000..2f37507f61d5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts @@ -0,0 +1,59 @@ +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'; + +// Minimal GrowthBook-like class that matches the real API for testing +// This is necessary since we don't want to add @growthbook/growthbook as a dependency +// just for integration tests, but we want to test the actual integration behavior +class GrowthBookLike { + private _features: Record = {}; + + public isOn(featureKey: string): boolean { + const feature = this._features[featureKey]; + return feature ? !!feature.value : false; + } + + public getFeatureValue(featureKey: string, defaultValue: unknown): unknown { + const feature = this._features[featureKey]; + return feature ? feature.value : defaultValue; + } + + // Helper method to set feature values for testing + public setFeature(featureKey: string, value: unknown): void { + this._features[featureKey] = { value }; + } +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookLike })], +}); + +const gb = new GrowthBookLike(); + +// Fill buffer with flags 1-100 (all false by default) +for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { + gb.isOn(`feat${i}`); +} + +// Add feat101 (true), which should evict feat1 +gb.setFeature(`feat${FLAG_BUFFER_SIZE + 1}`, true); +gb.isOn(`feat${FLAG_BUFFER_SIZE + 1}`); + +// Update feat3 to true, which should move it to the end +gb.setFeature('feat3', true); +gb.isOn('feat3'); + +// Test getFeatureValue with boolean values (should be captured) +gb.setFeature('bool-feat', true); +gb.getFeatureValue('bool-feat', false); + +// Test getFeatureValue with non-boolean values (should NOT be captured) +gb.setFeature('string-feat', 'hello'); +gb.getFeatureValue('string-feat', 'default'); +gb.setFeature('number-feat', 42); +gb.getFeatureValue('number-feat', 0); + +throw new Error('Test error'); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts new file mode 100644 index 000000000000..a709c6329c93 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts @@ -0,0 +1,32 @@ +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('GrowthBook flags captured on error with eviction, update, and no async tasks', async () => { + + const expectedFlags = []; + 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 }); + expectedFlags.push({ flag: 'bool-feat', result: true }); // Only boolean getFeatureValue should be captured + + 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/growthbook/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts new file mode 100644 index 000000000000..c66f340a4b63 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts @@ -0,0 +1,50 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Minimal GrowthBook-like class that matches the real API for testing +class GrowthBookLike { + private _features: Record = {}; + + public isOn(featureKey: string): boolean { + const feature = this._features[featureKey]; + return feature ? !!feature.value : false; + } + + public getFeatureValue(featureKey: string, defaultValue: unknown): unknown { + const feature = this._features[featureKey]; + return feature ? feature.value : defaultValue; + } + + // Helper method to set feature values for testing + public setFeature(featureKey: string, value: unknown): void { + this._features[featureKey] = { value }; + } +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookLike })], +}); + +const gb = new GrowthBookLike(); + +// Set up feature flags +gb.setFeature('feat1', true); +gb.setFeature('feat2', false); +gb.setFeature('bool-feat', true); + +Sentry.startSpan({ name: 'test-span', op: 'function' }, () => { + // Evaluate feature flags during the span + gb.isOn('feat1'); + gb.isOn('feat2'); + + // Test getFeatureValue with boolean values (should be captured) + gb.getFeatureValue('bool-feat', false); + + // Test getFeatureValue with non-boolean values (should NOT be captured) + gb.setFeature('string-feat', 'hello'); + gb.getFeatureValue('string-feat', 'default'); +}); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts new file mode 100644 index 000000000000..65446ed24289 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts @@ -0,0 +1,34 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('GrowthBook flags are added to active span attributes on span end', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + contexts: { + trace: { + data: { + 'flag.evaluation.feat1': true, + 'flag.evaluation.feat2': false, + 'flag.evaluation.bool-feat': true, + // string-feat should NOT be here since it's not boolean + }, + op: 'function', + origin: 'manual', + status: 'ok', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/) + }, + }, + spans: [], + transaction: 'test-span', + type: 'transaction', + }, + }) + .start() + .completed(); +}); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 4808f22b472b..b582e04e79e1 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -34,6 +34,7 @@ export { OpenFeatureIntegrationHook, statsigIntegration, unleashIntegration, + growthbookIntegration, } from './integrations/featureFlagShims'; export { firebaseIntegration } from './integrations/tracing/firebase'; From 3e2d47baa93b3180515b4b8a51696aef9b102665 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 16:10:40 -0700 Subject: [PATCH 37/48] add exports to astro --- packages/astro/src/index.server.ts | 1 + packages/astro/src/index.types.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 790810e93797..da213171070d 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -158,6 +158,7 @@ export { OpenFeatureIntegrationHook, statsigIntegration, unleashIntegration, + growthbookIntegration, } from '@sentry/node'; export { init } from './server/sdk'; diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts index ceb4fc6d8a51..b09a1cfa09d5 100644 --- a/packages/astro/src/index.types.ts +++ b/packages/astro/src/index.types.ts @@ -35,5 +35,6 @@ export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegra export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook; export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export default sentryAstro; From 6b9d6a9df7fcdc68151efc6ab622c41cff18aa4d Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 16:11:06 -0700 Subject: [PATCH 38/48] export growthbook integration --- packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/cloudflare/src/index.ts | 1 + packages/deno/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + 5 files changed, 5 insertions(+) diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index f7e72ec908ae..93cf639c87f0 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -144,6 +144,7 @@ export { OpenFeatureIntegrationHook, statsigIntegration, unleashIntegration, + growthbookIntegration, } from '@sentry/node'; export { diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 2775cbc0624e..5ec1568229e4 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -156,6 +156,7 @@ export { wrapMcpServerWithSentry, featureFlagsIntegration, launchDarklyIntegration, + growthbookIntegration, buildLaunchDarklyFlagUsedHandler, openFeatureIntegration, OpenFeatureIntegrationHook, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index d4afd80313b1..6f731cb8d980 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -97,6 +97,7 @@ export { consoleLoggingIntegration, createConsolaReporter, featureFlagsIntegration, + growthbookIntegration, logger, } from '@sentry/core'; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 5f987b4459aa..57da7927b11b 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -89,6 +89,7 @@ export { updateSpanName, wrapMcpServerWithSentry, featureFlagsIntegration, + growthbookIntegration, } 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 bab9dc3a1cbb..254f3de5e57f 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -139,6 +139,7 @@ export { featureFlagsIntegration, type FeatureFlagsIntegration, launchDarklyIntegration, + growthbookIntegration, buildLaunchDarklyFlagUsedHandler, openFeatureIntegration, OpenFeatureIntegrationHook, From 59fe2fdd0eea8db75b9859a59f3d8c33e451376b Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 16:11:23 -0700 Subject: [PATCH 39/48] fix race condition --- .../featureFlags/growthbook/onError/basic/test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts index 5b3cd7ac35b7..fc23f80927ff 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -49,7 +49,14 @@ sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async const event = envelopeRequestParser(req); const values = event.contexts?.flags?.values || []; - const expectedFlags = [{ flag: 'feat2', result: false }]; + + // After the sequence of operations: + // 1. feat1-feat100 are added (100 items) + // 2. feat101 is added, evicts feat1 (100 items: feat2-feat100, feat101) + // 3. feat3 is updated to true, moves to end (100 items: feat2, feat4-feat100, feat101, feat3) + // 4. bool-feat is added, evicts feat2 (100 items: feat4-feat100, feat101, feat3, bool-feat) + + const expectedFlags = []; for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) { expectedFlags.push({ flag: `feat${i}`, result: false }); } From 3a9664d9c9784f0fa6ea013c300d00dd0f3b4f98 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 18:44:42 -0700 Subject: [PATCH 40/48] fix lint issues --- .../suites/featureFlags/growthbook/onError/basic/test.ts | 1 - .../suites/featureFlags/growthbook/onSpan/test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts index a709c6329c93..82e39eb62364 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts @@ -7,7 +7,6 @@ afterAll(() => { }); test('GrowthBook flags captured on error with eviction, update, and no async tasks', async () => { - const expectedFlags = []; for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) { expectedFlags.push({ flag: `feat${i}`, result: false }); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts index 65446ed24289..fbb084b98928 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts @@ -21,7 +21,7 @@ test('GrowthBook flags are added to active span attributes on span end', async ( origin: 'manual', status: 'ok', span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/) + trace_id: expect.stringMatching(/[a-f0-9]{32}/), }, }, spans: [], From 18864b3d2f77d032434d45317df99d8a59d2fdf9 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 18:45:01 -0700 Subject: [PATCH 41/48] fix nuxt build issue --- packages/nuxt/src/index.types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index 4f006e0b5b07..7abb16d197e3 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -19,6 +19,7 @@ export declare const defaultStackParser: StackParser; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; From 958bd6d21a199f52f29ab92d5d6b4909e6dc1a4a Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 19:23:22 -0700 Subject: [PATCH 42/48] resolve import conflict --- packages/nextjs/src/index.types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index fe5a75bd5c8b..d982ebbc7559 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -142,6 +142,7 @@ export declare function wrapPageComponentWithSentry(WrappingTarget: C): C; export { captureRequestError } from './common/captureRequestError'; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; From 709fc90b49bf93b28bcb9f960052bf545f547eab Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 25 Sep 2025 22:27:38 -0700 Subject: [PATCH 43/48] fix type cast warning --- .../src/integrations/featureFlags/growthbook/integration.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts index b05aebf97fab..560918535cce 100644 --- a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -1,5 +1,5 @@ import type { IntegrationFn } from '@sentry/core'; -import * as SentryCore from '@sentry/core'; +import { growthbookIntegration as coreGrowthbookIntegration } from '@sentry/core'; import type { GrowthBookClass } from './types'; /** @@ -22,7 +22,5 @@ import type { GrowthBookClass } from './types'; * Sentry.captureException(new Error('something went wrong')); * ``` */ -const _coreAny = SentryCore as unknown as Record; - export const growthbookIntegration = (({ growthbookClass }: { growthbookClass: GrowthBookClass }) => - _coreAny.growthbookIntegration({ growthbookClass })) satisfies IntegrationFn; + coreGrowthbookIntegration({ growthbookClass })) satisfies IntegrationFn; From fcc38b880b50a7e739f8b6a14713f281ccd65a67 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Mon, 29 Sep 2025 14:06:09 -0700 Subject: [PATCH 44/48] export growthbook integration --- packages/deno/src/index.ts | 1 - packages/react-router/src/index.types.ts | 1 + packages/remix/src/index.types.ts | 1 + packages/solidstart/src/index.types.ts | 1 + packages/sveltekit/src/index.types.ts | 1 + packages/tanstackstart-react/src/index.types.ts | 1 + 6 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 57da7927b11b..5f987b4459aa 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -89,7 +89,6 @@ export { updateSpanName, wrapMcpServerWithSentry, featureFlagsIntegration, - growthbookIntegration, } from '@sentry/core'; export { DenoClient } from './client'; diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index 150fc45a1e63..58566ba214fe 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -19,6 +19,7 @@ export declare const getDefaultIntegrations: (options: Options) => Integration[] export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts index d0df7397f612..cacbac00e591 100644 --- a/packages/remix/src/index.types.ts +++ b/packages/remix/src/index.types.ts @@ -33,6 +33,7 @@ export const close = runtime === 'client' ? clientSdk.close : serverSdk.close; export const flush = runtime === 'client' ? clientSdk.flush : serverSdk.flush; export const lastEventId = runtime === 'client' ? clientSdk.lastEventId : serverSdk.lastEventId; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts index 7725d1ad3d3c..7f7528a0dddb 100644 --- a/packages/solidstart/src/index.types.ts +++ b/packages/solidstart/src/index.types.ts @@ -26,6 +26,7 @@ export declare function lastEventId(): string | undefined; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index 108e262f9992..40c2f5ff848e 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -60,6 +60,7 @@ export declare function trackComponent(options: clientSdk.TrackingOptions): Retu export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 448ea35f637b..5a44af1b59d4 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -29,6 +29,7 @@ export declare const withErrorBoundary: typeof clientSdk.withErrorBoundary; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; From a8aeeb2c12d4cd9de749b574c8b8c99a702478c9 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Mon, 29 Sep 2025 14:22:51 -0700 Subject: [PATCH 45/48] Use actual growthbook instance for testing --- .../node-integration-tests/package.json | 1 + .../growthbook/onError/basic/scenario.ts | 72 +++++++++++++------ .../growthbook/onSpan/scenario.ts | 56 +++++++++------ 3 files changed, 87 insertions(+), 42 deletions(-) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 48073f2ac817..ccc545f625c8 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -26,6 +26,7 @@ "@anthropic-ai/sdk": "0.63.0", "@aws-sdk/client-s3": "^3.552.0", "@google/genai": "^1.20.0", + "@growthbook/growthbook": "^1.6.1", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", "@nestjs/common": "^11", diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts index 2f37507f61d5..3dadfade8713 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts @@ -1,26 +1,60 @@ +import type { ClientOptions, UserContext } from '@growthbook/growthbook'; +import { GrowthBookClient } from '@growthbook/growthbook'; 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'; -// Minimal GrowthBook-like class that matches the real API for testing -// This is necessary since we don't want to add @growthbook/growthbook as a dependency -// just for integration tests, but we want to test the actual integration behavior -class GrowthBookLike { - private _features: Record = {}; +// Wrapper class to instantiate GrowthBookClient +class GrowthBookWrapper { + private _gbClient: GrowthBookClient; + private _userContext: UserContext = { attributes: { id: 'test-user-123' } }; - public isOn(featureKey: string): boolean { - const feature = this._features[featureKey]; - return feature ? !!feature.value : false; + public constructor(..._args: unknown[]) { + // Create GrowthBookClient with proper configuration + const clientOptions: ClientOptions = { + apiHost: 'https://cdn.growthbook.io', + clientKey: 'sdk-abc123' + }; + this._gbClient = new GrowthBookClient(clientOptions); + + // Create features for testing + const features = this._createTestFeatures(); + + this._gbClient.initSync({ + payload: { features } + }); } - public getFeatureValue(featureKey: string, defaultValue: unknown): unknown { - const feature = this._features[featureKey]; - return feature ? feature.value : defaultValue; + public isOn(featureKey: string, ..._rest: unknown[]): boolean { + return this._gbClient.isOn(featureKey, this._userContext); } - // Helper method to set feature values for testing - public setFeature(featureKey: string, value: unknown): void { - this._features[featureKey] = { value }; + public getFeatureValue(featureKey: string, defaultValue: unknown, ..._rest: unknown[]): unknown { + return this._gbClient.getFeatureValue(featureKey, defaultValue as boolean | string | number, this._userContext); + } + + private _createTestFeatures(): Record { + const features: Record = {}; + + // Fill buffer with flags 1-100 (all false by default) + for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { + features[`feat${i}`] = { defaultValue: false }; + } + + // Add feat101 (true), which should evict feat1 + features[`feat${FLAG_BUFFER_SIZE + 1}`] = { defaultValue: true }; + + // Update feat3 to true, which should move it to the end + features['feat3'] = { defaultValue: true }; + + // Test features with boolean values (should be captured) + features['bool-feat'] = { defaultValue: true }; + + // Test features with non-boolean values (should NOT be captured) + features['string-feat'] = { defaultValue: 'hello' }; + features['number-feat'] = { defaultValue: 42 }; + + return features; } } @@ -28,10 +62,11 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', sampleRate: 1.0, transport: loggingTransport, - integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookLike })], + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookWrapper })], }); -const gb = new GrowthBookLike(); +// Create GrowthBookWrapper instance +const gb = new GrowthBookWrapper(); // Fill buffer with flags 1-100 (all false by default) for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { @@ -39,21 +74,16 @@ for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { } // Add feat101 (true), which should evict feat1 -gb.setFeature(`feat${FLAG_BUFFER_SIZE + 1}`, true); gb.isOn(`feat${FLAG_BUFFER_SIZE + 1}`); // Update feat3 to true, which should move it to the end -gb.setFeature('feat3', true); gb.isOn('feat3'); // Test getFeatureValue with boolean values (should be captured) -gb.setFeature('bool-feat', true); gb.getFeatureValue('bool-feat', false); // Test getFeatureValue with non-boolean values (should NOT be captured) -gb.setFeature('string-feat', 'hello'); gb.getFeatureValue('string-feat', 'default'); -gb.setFeature('number-feat', 42); gb.getFeatureValue('number-feat', 0); throw new Error('Test error'); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts index c66f340a4b63..8800711b9901 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts @@ -1,41 +1,56 @@ +import type { ClientOptions, InitSyncOptions, UserContext } from '@growthbook/growthbook'; +import { GrowthBookClient } from '@growthbook/growthbook'; import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; -// Minimal GrowthBook-like class that matches the real API for testing -class GrowthBookLike { - private _features: Record = {}; +// Wrapper class to instantiate GrowthBookClient +class GrowthBookWrapper { + private _gbClient: GrowthBookClient; + private _userContext: UserContext = { attributes: { id: 'test-user-123' } }; - public isOn(featureKey: string): boolean { - const feature = this._features[featureKey]; - return feature ? !!feature.value : false; + public constructor(..._args: unknown[]) { + // Create GrowthBookClient and initialize it synchronously with payload + const clientOptions: ClientOptions = { + apiHost: 'https://cdn.growthbook.io', + clientKey: 'sdk-abc123' + }; + this._gbClient = new GrowthBookClient(clientOptions); + + // Create test features + const features = { + 'feat1': { defaultValue: true }, + 'feat2': { defaultValue: false }, + 'bool-feat': { defaultValue: true }, + 'string-feat': { defaultValue: 'hello' } + }; + + // Initialize synchronously with payload + const initOptions: InitSyncOptions = { + payload: { features } + }; + + this._gbClient.initSync(initOptions); } - public getFeatureValue(featureKey: string, defaultValue: unknown): unknown { - const feature = this._features[featureKey]; - return feature ? feature.value : defaultValue; + public isOn(featureKey: string, ..._rest: unknown[]): boolean { + return this._gbClient.isOn(featureKey, this._userContext); } - // Helper method to set feature values for testing - public setFeature(featureKey: string, value: unknown): void { - this._features[featureKey] = { value }; + public getFeatureValue(featureKey: string, defaultValue: unknown, ..._rest: unknown[]): unknown { + return this._gbClient.getFeatureValue(featureKey, defaultValue as boolean | string | number, this._userContext); } } +const gb = new GrowthBookWrapper(); + Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', sampleRate: 1.0, tracesSampleRate: 1.0, transport: loggingTransport, - integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookLike })], + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookWrapper })], }); -const gb = new GrowthBookLike(); - -// Set up feature flags -gb.setFeature('feat1', true); -gb.setFeature('feat2', false); -gb.setFeature('bool-feat', true); - Sentry.startSpan({ name: 'test-span', op: 'function' }, () => { // Evaluate feature flags during the span gb.isOn('feat1'); @@ -45,6 +60,5 @@ Sentry.startSpan({ name: 'test-span', op: 'function' }, () => { gb.getFeatureValue('bool-feat', false); // Test getFeatureValue with non-boolean values (should NOT be captured) - gb.setFeature('string-feat', 'hello'); gb.getFeatureValue('string-feat', 'default'); }); From e1379cab18fb0a496a368dc20773a196d5e7597d Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Mon, 29 Sep 2025 22:12:10 -0700 Subject: [PATCH 46/48] fix linting issues --- .../featureFlags/growthbook/onError/basic/scenario.ts | 4 ++-- .../suites/featureFlags/growthbook/onSpan/scenario.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts index 3dadfade8713..f907e320696d 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts @@ -13,7 +13,7 @@ class GrowthBookWrapper { // Create GrowthBookClient with proper configuration const clientOptions: ClientOptions = { apiHost: 'https://cdn.growthbook.io', - clientKey: 'sdk-abc123' + clientKey: 'sdk-abc123', }; this._gbClient = new GrowthBookClient(clientOptions); @@ -21,7 +21,7 @@ class GrowthBookWrapper { const features = this._createTestFeatures(); this._gbClient.initSync({ - payload: { features } + payload: { features }, }); } diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts index 8800711b9901..b25f36f00951 100644 --- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts @@ -12,21 +12,21 @@ class GrowthBookWrapper { // Create GrowthBookClient and initialize it synchronously with payload const clientOptions: ClientOptions = { apiHost: 'https://cdn.growthbook.io', - clientKey: 'sdk-abc123' + clientKey: 'sdk-abc123', }; this._gbClient = new GrowthBookClient(clientOptions); // Create test features const features = { - 'feat1': { defaultValue: true }, - 'feat2': { defaultValue: false }, + feat1: { defaultValue: true }, + feat2: { defaultValue: false }, 'bool-feat': { defaultValue: true }, - 'string-feat': { defaultValue: 'hello' } + 'string-feat': { defaultValue: 'hello' }, }; // Initialize synchronously with payload const initOptions: InitSyncOptions = { - payload: { features } + payload: { features }, }; this._gbClient.initSync(initOptions); From 99aea897dba88dd69a10b565836fb040b7936c69 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Mon, 6 Oct 2025 15:08:53 -0700 Subject: [PATCH 47/48] add growthbook dep for integration tests --- yarn.lock | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/yarn.lock b/yarn.lock index 3a6b6375c015..0a98bb01a623 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4336,6 +4336,13 @@ dependencies: tslib "^2.4.0" +"@growthbook/growthbook@^1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-1.6.1.tgz#4135c680397af3e5de8d2ab92defe2c6ed697fc5" + integrity sha512-GSvb7bNaBTfH54AZ0oQdnoyV/ZxN9NhDEIHOsRUiM+CSOPiodz0i8/+1O6Wg0wFEVgBxS5CGWffyd74fym43Xw== + dependencies: + dom-mutator "^0.6.0" + "@handlebars/parser@~2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-2.0.0.tgz#5e8b7298f31ff8f7b260e6b7363c7e9ceed7d9c5" @@ -14362,6 +14369,11 @@ dom-element-descriptors@^0.5.0, dom-element-descriptors@^0.5.1: resolved "https://registry.yarnpkg.com/dom-element-descriptors/-/dom-element-descriptors-0.5.1.tgz#3ebfcf64198f922dba928f84f7970bb571891317" integrity sha512-DLayMRQ+yJaziF4JJX1FMjwjdr7wdTr1y9XvZ+NfHELfOMcYDnCHneAYXAS4FT1gLILh4V0juMZohhH1N5FsoQ== +dom-mutator@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/dom-mutator/-/dom-mutator-0.6.0.tgz#079d7a4b3e8981a562cd777548b99baab51d65c5" + integrity sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg== + dom-serializer@^1.0.1: version "1.3.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91" From 6ade432a0c57304770b04aa2efce5cde2f2a34d7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 14 Oct 2025 14:15:49 +0200 Subject: [PATCH 48/48] rm satisfies keyword --- packages/core/src/integrations/featureFlags/growthbook.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/integrations/featureFlags/growthbook.ts b/packages/core/src/integrations/featureFlags/growthbook.ts index a72e48e0f596..eeb2b25341e9 100644 --- a/packages/core/src/integrations/featureFlags/growthbook.ts +++ b/packages/core/src/integrations/featureFlags/growthbook.ts @@ -34,7 +34,7 @@ export type GrowthBookClassLike = new (...args: unknown[]) => GrowthBookLike; * }); * ``` */ -export const growthbookIntegration = defineIntegration( +export const growthbookIntegration: IntegrationFn = defineIntegration( ({ growthbookClass }: { growthbookClass: GrowthBookClassLike }) => { return { name: 'GrowthBook', @@ -58,7 +58,7 @@ export const growthbookIntegration = defineIntegration( }, }; }, -) satisfies IntegrationFn; +); function _wrapAndCaptureBooleanResult( original: (this: GrowthBookLike, ...args: unknown[]) => unknown,