Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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
}, 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 || [];
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);
});
Original file line number Diff line number Diff line change
@@ -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://[email protected]/1337',
sampleRate: 1.0,
integrations: [window.sentryGrowthBookIntegration],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
document.getElementById('error').addEventListener('click', () => {
throw new Error('Button triggered error');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="error">Throw Error</button>
</body>
<script src="./subject.js"></script>
</html>


Original file line number Diff line number Diff line change
@@ -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 },
]);
});
Original file line number Diff line number Diff line change
@@ -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://[email protected]/1337',
sampleRate: 1.0,
tracesSampleRate: 1.0,
integrations: [
window.sentryGrowthBookIntegration,
Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }),
],
});
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="btnStartSpan">Start Span</button>
<button id="btnEndSpan">End Span</button>
<button id="btnStartNestedSpan">Start Nested Span</button>
<button id="btnEndNestedSpan">End Nested Span</button>
</body>
<script src="./subject.js"></script>
</html>


Original file line number Diff line number Diff line change
@@ -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<EventAndTraceHeader>(
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());
},
);
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { growthbookIntegration } from './integration';
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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);
// Also capture evalFeature when present. Not all versions have it, so guard.
if (typeof (proto as unknown as Record<string, unknown>).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'
Comment on lines +61 to +63
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
typeof result !== 'undefined' &&
typeof result !== 'function' &&
typeof result !== 'symbol'
typeof result === 'boolean'

) {
_INTERNAL_insertFlagToScope(flagName, result);
_INTERNAL_addFeatureFlagToActiveSpan(flagName, result);
}
return result;
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
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;
Loading
Loading