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..fc23f80927ff --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -0,0 +1,68 @@ +import { expect } from '@playwright/test'; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; + +sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + await page.evaluate(bufferSize => { + const 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}`); // eviction + + 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); + await page.locator('#error').click(); + const req = await reqPromise; + const event = envelopeRequestParser(req); + + const values = event.contexts?.flags?.values || []; + + // 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 }); + } + 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); +}); 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..48fa4718b856 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts @@ -0,0 +1,65 @@ +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..ad874b2bd697 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js @@ -0,0 +1,16 @@ +const btnStartSpan = document.getElementById('btnStartSpan'); +const btnEndSpan = document.getElementById('btnEndSpan'); +const btnStartNestedSpan = document.getElementById('btnStartNestedSpan'); +const btnEndNestedSpan = document.getElementById('btnEndNestedSpan'); + +window.withNestedSpans = callback => { + window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => { + window.traceId = rootSpan.spanContext().traceId; + + window.Sentry.startSpan({ name: 'test-span' }, _span => { + window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => { + callback(); + }); + }); + }); +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html new file mode 100644 index 000000000000..4efb91e75451 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + 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/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index f8c294738065..118db71a6b98 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 new file mode 100644 index 000000000000..f907e320696d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts @@ -0,0 +1,89 @@ +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'; + +// Wrapper class to instantiate GrowthBookClient +class GrowthBookWrapper { + private _gbClient: GrowthBookClient; + private _userContext: UserContext = { attributes: { id: 'test-user-123' } }; + + 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 isOn(featureKey: string, ..._rest: unknown[]): boolean { + return this._gbClient.isOn(featureKey, this._userContext); + } + + 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; + } +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookWrapper })], +}); + +// 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++) { + gb.isOn(`feat${i}`); +} + +// Add feat101 (true), which should evict feat1 +gb.isOn(`feat${FLAG_BUFFER_SIZE + 1}`); + +// Update feat3 to true, which should move it to the end +gb.isOn('feat3'); + +// Test getFeatureValue with boolean values (should be captured) +gb.getFeatureValue('bool-feat', false); + +// Test getFeatureValue with non-boolean values (should NOT be captured) +gb.getFeatureValue('string-feat', 'default'); +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..82e39eb62364 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts @@ -0,0 +1,31 @@ +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..b25f36f00951 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts @@ -0,0 +1,64 @@ +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'; + +// Wrapper class to instantiate GrowthBookClient +class GrowthBookWrapper { + private _gbClient: GrowthBookClient; + private _userContext: UserContext = { attributes: { id: 'test-user-123' } }; + + 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 isOn(featureKey: string, ..._rest: unknown[]): boolean { + return this._gbClient.isOn(featureKey, this._userContext); + } + + 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: GrowthBookWrapper })], +}); + +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.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..fbb084b98928 --- /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/astro/src/index.server.ts b/packages/astro/src/index.server.ts index f70d6e0a3573..15158bdbb7bc 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -159,6 +159,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; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 5a608a925edb..5ff30f069486 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -145,6 +145,7 @@ export { OpenFeatureIntegrationHook, statsigIntegration, unleashIntegration, + growthbookIntegration, } from '@sentry/node'; export { 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/browser/src/integrations/featureFlags/growthbook/index.ts b/packages/browser/src/integrations/featureFlags/growthbook/index.ts new file mode 100644 index 000000000000..a931e2376ab7 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/index.ts @@ -0,0 +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 new file mode 100644 index 000000000000..560918535cce --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -0,0 +1,26 @@ +import type { IntegrationFn } from '@sentry/core'; +import { growthbookIntegration as coreGrowthbookIntegration } from '@sentry/core'; +import type { 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 = (({ growthbookClass }: { growthbookClass: GrowthBookClass }) => + coreGrowthbookIntegration({ growthbookClass })) satisfies IntegrationFn; 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..5a852d633da9 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/types.ts @@ -0,0 +1,7 @@ +export interface GrowthBook { + 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. +export type GrowthBookClass = new (...args: unknown[]) => GrowthBook; 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/core/src/index.ts b/packages/core/src/index.ts index 06be19c86774..2377e2ce86b0 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..eeb2b25341e9 --- /dev/null +++ b/packages/core/src/integrations/featureFlags/growthbook.ts @@ -0,0 +1,77 @@ +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. + * + * @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: IntegrationFn = 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); + } + }, + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return _INTERNAL_copyFlagsFromScopeToEvent(event); + }, + }; + }, +); + +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/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 8f1d236f7877..db52cf357a16 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -140,6 +140,7 @@ export { featureFlagsIntegration, type FeatureFlagsIntegration, launchDarklyIntegration, + growthbookIntegration, buildLaunchDarklyFlagUsedHandler, openFeatureIntegration, OpenFeatureIntegrationHook, 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; diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 54a90dbfcd09..b599351b5124 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'; diff --git a/packages/node/src/integrations/featureFlagShims/growthbook.ts b/packages/node/src/integrations/featureFlagShims/growthbook.ts new file mode 100644 index 000000000000..d86f3a0349bc --- /dev/null +++ b/packages/node/src/integrations/featureFlagShims/growthbook.ts @@ -0,0 +1,7 @@ +import { growthbookIntegration as coreGrowthbookIntegration } from '@sentry/core'; + +/** + * 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 = coreGrowthbookIntegration; 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'; 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; 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; diff --git a/yarn.lock b/yarn.lock index 4d084a8cf3c7..5e9edc00f1df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4350,6 +4350,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" @@ -14376,6 +14383,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"