Skip to content

Commit

Permalink
feat(flags): Add featureFlagsIntegration for custom tracking of flag …
Browse files Browse the repository at this point in the history
…evaluations (#14582)

Follow-up to #14207. 

Sentry integration for buffering feature flags manually with an API and auto-capturing them on error events.
  • Loading branch information
aliu39 authored Dec 6, 2024
1 parent 666e668 commit a985d64
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../../utils/fixtures';

import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';

const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.

sentryTest('Basic test with 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 flagsIntegration = (window as any).Sentry.getClient().getIntegrationByName('FeatureFlags');
for (let i = 1; i <= bufferSize; i++) {
flagsIntegration.addFeatureFlag(`feat${i}`, false);
}
flagsIntegration.addFeatureFlag(`feat${bufferSize + 1}`, true); // eviction
flagsIntegration.addFeatureFlag('feat3', true); // update
return true;
}, FLAG_BUFFER_SIZE);

const reqPromise = waitForErrorRequest(page);
await page.locator('#error').click(); // trigger error
const req = await reqPromise;
const event = envelopeRequestParser(req);

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(event.contexts?.flags?.values).toEqual(expectedFlags);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

// Not using this as we want to test the getIntegrationByName() approach
// window.sentryFeatureFlagsIntegration = Sentry.featureFlagsIntegration();

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 1.0,
integrations: [Sentry.featureFlagsIntegration()],
});
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,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="error">Throw Error</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../../utils/fixtures';

import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';

import type { Scope } from '@sentry/browser';

sentryTest('Flag evaluations in forked scopes are stored separately.', 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 && event.tags.isForked === true);
const mainReqPromise = waitForErrorRequest(page, event => !!event.tags && event.tags.isForked === false);

await page.evaluate(() => {
const Sentry = (window as any).Sentry;
const errorButton = document.querySelector('#error') as HTMLButtonElement;
const flagsIntegration = (window as any).Sentry.getClient().getIntegrationByName('FeatureFlags');

flagsIntegration.addFeatureFlag('shared', true);

Sentry.withScope((scope: Scope) => {
flagsIntegration.addFeatureFlag('forked', true);
flagsIntegration.addFeatureFlag('shared', false);
scope.setTag('isForked', true);
if (errorButton) {
errorButton.click();
}
});

flagsIntegration.addFeatureFlag('main', true);
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 },
]);
});
4 changes: 4 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,9 @@ export { makeBrowserOfflineTransport } from './transports/offline';
export { browserProfilingIntegration } from './profiling/integration';
export { spotlightBrowserIntegration } from './integrations/spotlight';
export { browserSessionIntegration } from './integrations/browsersession';
export {
featureFlagsIntegration,
type FeatureFlagsIntegration,
} from './integrations/featureFlags';
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature';
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Client, Event, EventHint, Integration, IntegrationFn } from '@sentry/core';

import { defineIntegration } from '@sentry/core';
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../utils/featureFlags';

export interface FeatureFlagsIntegration extends Integration {
addFeatureFlag: (name: string, value: unknown) => void;
}

/**
* Sentry integration for buffering feature flags manually with an API, and
* capturing them on error events. We recommend you do this on each flag
* evaluation. Flags are buffered per Sentry scope and limited to 100 per event.
*
* See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information.
*
* @example
* ```
* import * as Sentry from '@sentry/browser';
* import { type FeatureFlagsIntegration } from '@sentry/browser';
*
* // Setup
* Sentry.init(..., integrations: [Sentry.featureFlagsIntegration()])
*
* // Verify
* const flagsIntegration = Sentry.getClient()?.getIntegrationByName<FeatureFlagsIntegration>('FeatureFlags');
* if (flagsIntegration) {
* flagsIntegration.addFeatureFlag('my-flag', true);
* } else {
* // check your setup
* }
* Sentry.captureException(Exception('broke')); // 'my-flag' should be captured to this Sentry event.
* ```
*/
export const featureFlagsIntegration = defineIntegration(() => {
return {
name: 'FeatureFlags',

processEvent(event: Event, _hint: EventHint, _client: Client): Event {
return copyFlagsFromScopeToEvent(event);
},

addFeatureFlag(name: string, value: unknown): void {
insertFlagToScope(name, value);
},
};
}) as IntegrationFn<FeatureFlagsIntegration>;
1 change: 1 addition & 0 deletions packages/browser/src/integrations/featureFlags/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { featureFlagsIntegration, type FeatureFlagsIntegration } from './featureFlagsIntegration';

0 comments on commit a985d64

Please sign in to comment.