Skip to content

Commit a985d64

Browse files
authored
feat(flags): Add featureFlagsIntegration for custom tracking of flag evaluations (#14582)
Follow-up to #14207. Sentry integration for buffering feature flags manually with an API and auto-capturing them on error events.
1 parent 666e668 commit a985d64

File tree

8 files changed

+189
-0
lines changed

8 files changed

+189
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
5+
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
6+
7+
const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.
8+
9+
sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
10+
if (shouldSkipFeatureFlagsTest()) {
11+
sentryTest.skip();
12+
}
13+
14+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
15+
return route.fulfill({
16+
status: 200,
17+
contentType: 'application/json',
18+
body: JSON.stringify({ id: 'test-id' }),
19+
});
20+
});
21+
22+
const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
23+
await page.goto(url);
24+
25+
await page.evaluate(bufferSize => {
26+
const flagsIntegration = (window as any).Sentry.getClient().getIntegrationByName('FeatureFlags');
27+
for (let i = 1; i <= bufferSize; i++) {
28+
flagsIntegration.addFeatureFlag(`feat${i}`, false);
29+
}
30+
flagsIntegration.addFeatureFlag(`feat${bufferSize + 1}`, true); // eviction
31+
flagsIntegration.addFeatureFlag('feat3', true); // update
32+
return true;
33+
}, FLAG_BUFFER_SIZE);
34+
35+
const reqPromise = waitForErrorRequest(page);
36+
await page.locator('#error').click(); // trigger error
37+
const req = await reqPromise;
38+
const event = envelopeRequestParser(req);
39+
40+
const expectedFlags = [{ flag: 'feat2', result: false }];
41+
for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) {
42+
expectedFlags.push({ flag: `feat${i}`, result: false });
43+
}
44+
expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true });
45+
expectedFlags.push({ flag: 'feat3', result: true });
46+
47+
expect(event.contexts?.flags?.values).toEqual(expectedFlags);
48+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
// Not using this as we want to test the getIntegrationByName() approach
6+
// window.sentryFeatureFlagsIntegration = Sentry.featureFlagsIntegration();
7+
8+
Sentry.init({
9+
dsn: 'https://[email protected]/1337',
10+
sampleRate: 1.0,
11+
integrations: [Sentry.featureFlagsIntegration()],
12+
});
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+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
5+
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
6+
7+
import type { Scope } from '@sentry/browser';
8+
9+
sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => {
10+
if (shouldSkipFeatureFlagsTest()) {
11+
sentryTest.skip();
12+
}
13+
14+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
15+
return route.fulfill({
16+
status: 200,
17+
contentType: 'application/json',
18+
body: JSON.stringify({ id: 'test-id' }),
19+
});
20+
});
21+
22+
const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
23+
await page.goto(url);
24+
25+
const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags && event.tags.isForked === true);
26+
const mainReqPromise = waitForErrorRequest(page, event => !!event.tags && event.tags.isForked === false);
27+
28+
await page.evaluate(() => {
29+
const Sentry = (window as any).Sentry;
30+
const errorButton = document.querySelector('#error') as HTMLButtonElement;
31+
const flagsIntegration = (window as any).Sentry.getClient().getIntegrationByName('FeatureFlags');
32+
33+
flagsIntegration.addFeatureFlag('shared', true);
34+
35+
Sentry.withScope((scope: Scope) => {
36+
flagsIntegration.addFeatureFlag('forked', true);
37+
flagsIntegration.addFeatureFlag('shared', false);
38+
scope.setTag('isForked', true);
39+
if (errorButton) {
40+
errorButton.click();
41+
}
42+
});
43+
44+
flagsIntegration.addFeatureFlag('main', true);
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+
});

packages/browser/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,9 @@ export { makeBrowserOfflineTransport } from './transports/offline';
6969
export { browserProfilingIntegration } from './profiling/integration';
7070
export { spotlightBrowserIntegration } from './integrations/spotlight';
7171
export { browserSessionIntegration } from './integrations/browsersession';
72+
export {
73+
featureFlagsIntegration,
74+
type FeatureFlagsIntegration,
75+
} from './integrations/featureFlags';
7276
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
7377
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { Client, Event, EventHint, Integration, IntegrationFn } from '@sentry/core';
2+
3+
import { defineIntegration } from '@sentry/core';
4+
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../utils/featureFlags';
5+
6+
export interface FeatureFlagsIntegration extends Integration {
7+
addFeatureFlag: (name: string, value: unknown) => void;
8+
}
9+
10+
/**
11+
* Sentry integration for buffering feature flags manually with an API, and
12+
* capturing them on error events. We recommend you do this on each flag
13+
* evaluation. Flags are buffered per Sentry scope and limited to 100 per event.
14+
*
15+
* See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information.
16+
*
17+
* @example
18+
* ```
19+
* import * as Sentry from '@sentry/browser';
20+
* import { type FeatureFlagsIntegration } from '@sentry/browser';
21+
*
22+
* // Setup
23+
* Sentry.init(..., integrations: [Sentry.featureFlagsIntegration()])
24+
*
25+
* // Verify
26+
* const flagsIntegration = Sentry.getClient()?.getIntegrationByName<FeatureFlagsIntegration>('FeatureFlags');
27+
* if (flagsIntegration) {
28+
* flagsIntegration.addFeatureFlag('my-flag', true);
29+
* } else {
30+
* // check your setup
31+
* }
32+
* Sentry.captureException(Exception('broke')); // 'my-flag' should be captured to this Sentry event.
33+
* ```
34+
*/
35+
export const featureFlagsIntegration = defineIntegration(() => {
36+
return {
37+
name: 'FeatureFlags',
38+
39+
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
40+
return copyFlagsFromScopeToEvent(event);
41+
},
42+
43+
addFeatureFlag(name: string, value: unknown): void {
44+
insertFlagToScope(name, value);
45+
},
46+
};
47+
}) as IntegrationFn<FeatureFlagsIntegration>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { featureFlagsIntegration, type FeatureFlagsIntegration } from './featureFlagsIntegration';

0 commit comments

Comments
 (0)