Skip to content

Commit 245e91b

Browse files
feat(flags): Add Growthbook integration (#17440)
Co-authored-by: Charly Gomez <charly.gomez@sentry.io>
1 parent 6229224 commit 245e91b

File tree

38 files changed

+688
-0
lines changed

38 files changed

+688
-0
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { expect } from '@playwright/test';
2+
import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core';
3+
import { sentryTest } from '../../../../../../utils/fixtures';
4+
import {
5+
envelopeRequestParser,
6+
shouldSkipFeatureFlagsTest,
7+
waitForErrorRequest,
8+
} from '../../../../../../utils/helpers';
9+
10+
sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async ({ getLocalTestUrl, page }) => {
11+
if (shouldSkipFeatureFlagsTest()) {
12+
sentryTest.skip();
13+
}
14+
15+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
16+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) });
17+
});
18+
19+
const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
20+
await page.goto(url);
21+
22+
await page.evaluate(bufferSize => {
23+
const gb = new (window as any).GrowthBook();
24+
25+
for (let i = 1; i <= bufferSize; i++) {
26+
gb.isOn(`feat${i}`);
27+
}
28+
29+
gb.__setOn(`feat${bufferSize + 1}`, true);
30+
gb.isOn(`feat${bufferSize + 1}`); // eviction
31+
32+
gb.__setOn('feat3', true);
33+
gb.isOn('feat3'); // update
34+
35+
// Test getFeatureValue with boolean values (should be captured)
36+
gb.__setFeatureValue('bool-feat', true);
37+
gb.getFeatureValue('bool-feat', false);
38+
39+
// Test getFeatureValue with non-boolean values (should be ignored)
40+
gb.__setFeatureValue('string-feat', 'hello');
41+
gb.getFeatureValue('string-feat', 'default');
42+
gb.__setFeatureValue('number-feat', 42);
43+
gb.getFeatureValue('number-feat', 0);
44+
}, FLAG_BUFFER_SIZE);
45+
46+
const reqPromise = waitForErrorRequest(page);
47+
await page.locator('#error').click();
48+
const req = await reqPromise;
49+
const event = envelopeRequestParser(req);
50+
51+
const values = event.contexts?.flags?.values || [];
52+
53+
// After the sequence of operations:
54+
// 1. feat1-feat100 are added (100 items)
55+
// 2. feat101 is added, evicts feat1 (100 items: feat2-feat100, feat101)
56+
// 3. feat3 is updated to true, moves to end (100 items: feat2, feat4-feat100, feat101, feat3)
57+
// 4. bool-feat is added, evicts feat2 (100 items: feat4-feat100, feat101, feat3, bool-feat)
58+
59+
const expectedFlags = [];
60+
for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) {
61+
expectedFlags.push({ flag: `feat${i}`, result: false });
62+
}
63+
expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true });
64+
expectedFlags.push({ flag: 'feat3', result: true });
65+
expectedFlags.push({ flag: 'bool-feat', result: true }); // Only boolean getFeatureValue should be captured
66+
67+
expect(values).toEqual(expectedFlags);
68+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
// Minimal mock GrowthBook class for tests
4+
window.GrowthBook = class {
5+
constructor() {
6+
this._onFlags = Object.create(null);
7+
this._featureValues = Object.create(null);
8+
}
9+
10+
isOn(featureKey) {
11+
return !!this._onFlags[featureKey];
12+
}
13+
14+
getFeatureValue(featureKey, defaultValue) {
15+
return Object.prototype.hasOwnProperty.call(this._featureValues, featureKey)
16+
? this._featureValues[featureKey]
17+
: defaultValue;
18+
}
19+
20+
// Helpers for tests
21+
__setOn(featureKey, value) {
22+
this._onFlags[featureKey] = !!value;
23+
}
24+
25+
__setFeatureValue(featureKey, value) {
26+
this._featureValues[featureKey] = value;
27+
}
28+
};
29+
30+
window.Sentry = Sentry;
31+
window.sentryGrowthBookIntegration = Sentry.growthbookIntegration({ growthbookClass: window.GrowthBook });
32+
33+
Sentry.init({
34+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
35+
sampleRate: 1.0,
36+
integrations: [window.sentryGrowthBookIntegration],
37+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
document.getElementById('error').addEventListener('click', () => {
2+
throw new Error('Button triggered error');
3+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="error">Throw Error</button>
8+
</body>
9+
<script src="./subject.js"></script>
10+
</html>
11+
12+
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { expect } from '@playwright/test';
2+
import type { Scope } from '@sentry/browser';
3+
import { sentryTest } from '../../../../../../utils/fixtures';
4+
import {
5+
envelopeRequestParser,
6+
shouldSkipFeatureFlagsTest,
7+
waitForErrorRequest,
8+
} from '../../../../../../utils/helpers';
9+
10+
sentryTest('GrowthBook onError: forked scopes are isolated', async ({ getLocalTestUrl, page }) => {
11+
if (shouldSkipFeatureFlagsTest()) {
12+
sentryTest.skip();
13+
}
14+
15+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
16+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) });
17+
});
18+
19+
const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
20+
await page.goto(url);
21+
22+
const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === true);
23+
const mainReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === false);
24+
25+
await page.evaluate(() => {
26+
const Sentry = (window as any).Sentry;
27+
const errorButton = document.querySelector('#error') as HTMLButtonElement;
28+
const gb = new (window as any).GrowthBook();
29+
30+
gb.__setOn('shared', true);
31+
gb.__setOn('main', true);
32+
33+
gb.isOn('shared');
34+
35+
Sentry.withScope((scope: Scope) => {
36+
gb.__setOn('forked', true);
37+
gb.__setOn('shared', false);
38+
gb.isOn('forked');
39+
gb.isOn('shared');
40+
scope.setTag('isForked', true);
41+
errorButton.click();
42+
});
43+
44+
gb.isOn('main');
45+
Sentry.getCurrentScope().setTag('isForked', false);
46+
errorButton.click();
47+
return true;
48+
});
49+
50+
const forkedReq = await forkedReqPromise;
51+
const forkedEvent = envelopeRequestParser(forkedReq);
52+
53+
const mainReq = await mainReqPromise;
54+
const mainEvent = envelopeRequestParser(mainReq);
55+
56+
expect(forkedEvent.contexts?.flags?.values).toEqual([
57+
{ flag: 'forked', result: true },
58+
{ flag: 'shared', result: false },
59+
]);
60+
61+
expect(mainEvent.contexts?.flags?.values).toEqual([
62+
{ flag: 'shared', result: true },
63+
{ flag: 'main', result: true },
64+
]);
65+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.GrowthBook = class {
4+
constructor() {
5+
this._onFlags = Object.create(null);
6+
this._featureValues = Object.create(null);
7+
}
8+
9+
isOn(featureKey) {
10+
return !!this._onFlags[featureKey];
11+
}
12+
13+
getFeatureValue(featureKey, defaultValue) {
14+
return Object.prototype.hasOwnProperty.call(this._featureValues, featureKey)
15+
? this._featureValues[featureKey]
16+
: defaultValue;
17+
}
18+
19+
__setOn(featureKey, value) {
20+
this._onFlags[featureKey] = !!value;
21+
}
22+
23+
__setFeatureValue(featureKey, value) {
24+
this._featureValues[featureKey] = value;
25+
}
26+
};
27+
28+
window.Sentry = Sentry;
29+
window.sentryGrowthBookIntegration = Sentry.growthbookIntegration({ growthbookClass: window.GrowthBook });
30+
31+
Sentry.init({
32+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
33+
sampleRate: 1.0,
34+
tracesSampleRate: 1.0,
35+
integrations: [
36+
window.sentryGrowthBookIntegration,
37+
Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }),
38+
],
39+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const btnStartSpan = document.getElementById('btnStartSpan');
2+
const btnEndSpan = document.getElementById('btnEndSpan');
3+
const btnStartNestedSpan = document.getElementById('btnStartNestedSpan');
4+
const btnEndNestedSpan = document.getElementById('btnEndNestedSpan');
5+
6+
window.withNestedSpans = callback => {
7+
window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => {
8+
window.traceId = rootSpan.spanContext().traceId;
9+
10+
window.Sentry.startSpan({ name: 'test-span' }, _span => {
11+
window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => {
12+
callback();
13+
});
14+
});
15+
});
16+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="btnStartSpan">Start Span</button>
8+
<button id="btnEndSpan">End Span</button>
9+
<button id="btnStartNestedSpan">Start Nested Span</button>
10+
<button id="btnEndNestedSpan">End Nested Span</button>
11+
</body>
12+
<script src="./subject.js"></script>
13+
</html>
14+
15+
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { expect } from '@playwright/test';
2+
import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core';
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
import {
5+
type EventAndTraceHeader,
6+
eventAndTraceHeaderRequestParser,
7+
getMultipleSentryEnvelopeRequests,
8+
shouldSkipFeatureFlagsTest,
9+
shouldSkipTracingTest,
10+
} from '../../../../../utils/helpers';
11+
12+
sentryTest(
13+
"GrowthBook onSpan: flags are added to active span's attributes on span end",
14+
async ({ getLocalTestUrl, page }) => {
15+
if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) {
16+
sentryTest.skip();
17+
}
18+
19+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
20+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
21+
});
22+
23+
const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
24+
await page.goto(url);
25+
26+
const envelopeRequestPromise = getMultipleSentryEnvelopeRequests<EventAndTraceHeader>(
27+
page,
28+
1,
29+
{},
30+
eventAndTraceHeaderRequestParser,
31+
);
32+
33+
await page.evaluate(maxFlags => {
34+
(window as any).withNestedSpans(() => {
35+
const gb = new (window as any).GrowthBook();
36+
for (let i = 1; i <= maxFlags; i++) {
37+
gb.isOn(`feat${i}`);
38+
}
39+
gb.__setOn(`feat${maxFlags + 1}`, true);
40+
gb.isOn(`feat${maxFlags + 1}`); // dropped
41+
gb.__setOn('feat3', true);
42+
gb.isOn('feat3'); // update
43+
});
44+
return true;
45+
}, MAX_FLAGS_PER_SPAN);
46+
47+
const event = (await envelopeRequestPromise)[0][0];
48+
const innerSpan = event.spans?.[0];
49+
const outerSpan = event.spans?.[1];
50+
const outerSpanFlags = Object.entries(outerSpan?.data ?? {}).filter(([key, _val]) =>
51+
key.startsWith('flag.evaluation'),
52+
);
53+
const innerSpanFlags = Object.entries(innerSpan?.data ?? {}).filter(([key, _val]) =>
54+
key.startsWith('flag.evaluation'),
55+
);
56+
57+
expect(innerSpanFlags).toEqual([]);
58+
59+
const expectedOuterSpanFlags = [] as Array<[string, unknown]>;
60+
for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) {
61+
expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]);
62+
}
63+
expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort());
64+
},
65+
);

dev-packages/node-integration-tests/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@anthropic-ai/sdk": "0.63.0",
2727
"@aws-sdk/client-s3": "^3.552.0",
2828
"@google/genai": "^1.20.0",
29+
"@growthbook/growthbook": "^1.6.1",
2930
"@hapi/hapi": "^21.3.10",
3031
"@hono/node-server": "^1.19.4",
3132
"@nestjs/common": "^11",

0 commit comments

Comments
 (0)