Skip to content

Commit 3c7450d

Browse files
authored
feat(flags): Add Unleash browser integration (#15036)
Ref getsentry/team-replay#514. Adds an integration for tracking Unleash flag evaluations, by patching the [isEnabled](https://docs.getunleash.io/reference/sdks/javascript-browser#step-4-check-feature-toggle-states) method prototype. Ref https://develop.sentry.dev/sdk/expected-features/#feature-flags PR pointing to v8: #14948
1 parent a3cf458 commit 3c7450d

File tree

11 files changed

+359
-0
lines changed

11 files changed

+359
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.UnleashClient = class {
4+
isEnabled(x) {
5+
return x;
6+
}
7+
};
8+
9+
window.Sentry = Sentry;
10+
window.sentryUnleashIntegration = Sentry.unleashIntegration({ unleashClientClass: window.UnleashClient });
11+
12+
Sentry.init({
13+
dsn: 'https://[email protected]/1337',
14+
sampleRate: 1.0,
15+
integrations: [window.sentryUnleashIntegration],
16+
debug: true, // Required to test logging.
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
5+
import { shouldSkipFeatureFlagsTest } from '../../../../../utils/helpers';
6+
7+
sentryTest('Logs and returns if isEnabled does not match expected signature', async ({ getLocalTestUrl, page }) => {
8+
if (shouldSkipFeatureFlagsTest()) {
9+
sentryTest.skip();
10+
}
11+
const bundleKey = process.env.PW_BUNDLE || '';
12+
const hasDebug = !bundleKey.includes('_min');
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 errorLogs: string[] = [];
26+
page.on('console', msg => {
27+
if (msg.type() == 'error') {
28+
errorLogs.push(msg.text());
29+
}
30+
});
31+
32+
const results = await page.evaluate(() => {
33+
const unleash = new (window as any).UnleashClient();
34+
const res1 = unleash.isEnabled('my-feature');
35+
const res2 = unleash.isEnabled(999);
36+
const res3 = unleash.isEnabled({});
37+
return [res1, res2, res3];
38+
});
39+
40+
// Test that the expected results are still returned. Note isEnabled is identity function for this test.
41+
expect(results).toEqual(['my-feature', 999, {}]);
42+
43+
// Expected error logs.
44+
if (hasDebug) {
45+
expect(errorLogs).toEqual(
46+
expect.arrayContaining([
47+
expect.stringContaining(
48+
'[Feature Flags] UnleashClient.isEnabled does not match expected signature. arg0: my-feature (string), result: my-feature (string)',
49+
),
50+
expect.stringContaining(
51+
'[Feature Flags] UnleashClient.isEnabled does not match expected signature. arg0: 999 (number), result: 999 (number)',
52+
),
53+
expect.stringContaining(
54+
'[Feature Flags] UnleashClient.isEnabled does not match expected signature. arg0: [object Object] (object), result: [object Object] (object)',
55+
),
56+
]),
57+
);
58+
}
59+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 client = new (window as any).UnleashClient();
27+
28+
client.isEnabled('feat1');
29+
client.isEnabled('strFeat');
30+
client.isEnabled('noPayloadFeat');
31+
client.isEnabled('jsonFeat');
32+
client.isEnabled('noVariantFeat');
33+
client.isEnabled('disabledFeat');
34+
35+
for (let i = 7; i <= bufferSize; i++) {
36+
client.isEnabled(`feat${i}`);
37+
}
38+
client.isEnabled(`feat${bufferSize + 1}`); // eviction
39+
client.isEnabled('noPayloadFeat'); // update (move to tail)
40+
}, FLAG_BUFFER_SIZE);
41+
42+
const reqPromise = waitForErrorRequest(page);
43+
await page.locator('#error').click();
44+
const req = await reqPromise;
45+
const event = envelopeRequestParser(req);
46+
47+
const expectedFlags = [{ flag: 'strFeat', result: true }];
48+
expectedFlags.push({ flag: 'jsonFeat', result: true });
49+
expectedFlags.push({ flag: 'noVariantFeat', result: true });
50+
expectedFlags.push({ flag: 'disabledFeat', result: false });
51+
for (let i = 7; i <= FLAG_BUFFER_SIZE; i++) {
52+
expectedFlags.push({ flag: `feat${i}`, result: false });
53+
}
54+
expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: false });
55+
expectedFlags.push({ flag: 'noPayloadFeat', result: true });
56+
57+
expect(event.contexts?.flags?.values).toEqual(expectedFlags);
58+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.UnleashClient = class {
4+
constructor() {
5+
this._featureToVariant = {
6+
strFeat: { name: 'variant1', enabled: true, feature_enabled: true, payload: { type: 'string', value: 'test' } },
7+
noPayloadFeat: { name: 'eu-west', enabled: true, feature_enabled: true },
8+
jsonFeat: {
9+
name: 'paid-orgs',
10+
enabled: true,
11+
feature_enabled: true,
12+
payload: {
13+
type: 'json',
14+
value: '{"foo": {"bar": "baz"}, "hello": [1, 2, 3]}',
15+
},
16+
},
17+
18+
// Enabled feature with no configured variants.
19+
noVariantFeat: { name: 'disabled', enabled: false, feature_enabled: true },
20+
21+
// Disabled feature.
22+
disabledFeat: { name: 'disabled', enabled: false, feature_enabled: false },
23+
};
24+
25+
// Variant returned for features that don't exist.
26+
// `feature_enabled` may be defined in prod, but we want to test the undefined case.
27+
this._fallbackVariant = {
28+
name: 'disabled',
29+
enabled: false,
30+
};
31+
}
32+
33+
isEnabled(toggleName) {
34+
const variant = this._featureToVariant[toggleName] || this._fallbackVariant;
35+
return variant.feature_enabled || false;
36+
}
37+
38+
getVariant(toggleName) {
39+
return this._featureToVariant[toggleName] || this._fallbackVariant;
40+
}
41+
};
42+
43+
window.Sentry = Sentry;
44+
window.sentryUnleashIntegration = Sentry.unleashIntegration({ unleashClientClass: window.UnleashClient });
45+
46+
Sentry.init({
47+
dsn: 'https://[email protected]/1337',
48+
sampleRate: 1.0,
49+
integrations: [window.sentryUnleashIntegration],
50+
});
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 unleash = new (window as any).UnleashClient();
32+
33+
unleash.isEnabled('strFeat');
34+
35+
Sentry.withScope((scope: Scope) => {
36+
unleash.isEnabled('disabledFeat');
37+
unleash.isEnabled('strFeat');
38+
scope.setTag('isForked', true);
39+
if (errorButton) {
40+
errorButton.click();
41+
}
42+
});
43+
44+
unleash.isEnabled('noPayloadFeat');
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: 'disabledFeat', result: false },
58+
{ flag: 'strFeat', result: true },
59+
]);
60+
61+
expect(mainEvent.contexts?.flags?.values).toEqual([
62+
{ flag: 'strFeat', result: true },
63+
{ flag: 'noPayloadFeat', result: true },
64+
]);
65+
});

packages/browser/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,4 @@ export {
6767
} from './integrations/featureFlags';
6868
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
6969
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature';
70+
export { unleashIntegration } from './integrations/featureFlags/unleash';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { unleashIntegration } from './integration';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core';
2+
3+
import { defineIntegration, fill, logger } from '@sentry/core';
4+
import { DEBUG_BUILD } from '../../../debug-build';
5+
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
6+
import type { UnleashClient, UnleashClientClass } from './types';
7+
8+
/**
9+
* Sentry integration for capturing feature flag evaluations from the Unleash SDK.
10+
*
11+
* See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information.
12+
*
13+
* @example
14+
* ```
15+
* import { UnleashClient } from 'unleash-proxy-client';
16+
* import * as Sentry from '@sentry/browser';
17+
*
18+
* Sentry.init({
19+
* dsn: '___PUBLIC_DSN___',
20+
* integrations: [Sentry.unleashIntegration({unleashClientClass: UnleashClient})],
21+
* });
22+
*
23+
* const unleash = new UnleashClient(...);
24+
* unleash.start();
25+
*
26+
* unleash.isEnabled('my-feature');
27+
* unleash.getVariant('other-feature');
28+
* Sentry.captureException(new Error('something went wrong'));
29+
* ```
30+
*/
31+
export const unleashIntegration = defineIntegration(
32+
({ unleashClientClass }: { unleashClientClass: UnleashClientClass }) => {
33+
return {
34+
name: 'Unleash',
35+
36+
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
37+
return copyFlagsFromScopeToEvent(event);
38+
},
39+
40+
setupOnce() {
41+
const unleashClientPrototype = unleashClientClass.prototype as UnleashClient;
42+
fill(unleashClientPrototype, 'isEnabled', _wrappedIsEnabled);
43+
},
44+
};
45+
},
46+
) satisfies IntegrationFn;
47+
48+
/**
49+
* Wraps the UnleashClient.isEnabled method to capture feature flag evaluations. Its only side effect is writing to Sentry scope.
50+
*
51+
* This wrapper is safe for all isEnabled signatures. If the signature does not match (this: UnleashClient, toggleName: string, ...args: unknown[]) => boolean,
52+
* we log an error and return the original result.
53+
*
54+
* @param original - The original method.
55+
* @returns Wrapped method. Results should match the original.
56+
*/
57+
function _wrappedIsEnabled(
58+
original: (this: UnleashClient, ...args: unknown[]) => unknown,
59+
): (this: UnleashClient, ...args: unknown[]) => unknown {
60+
return function (this: UnleashClient, ...args: unknown[]): unknown {
61+
const toggleName = args[0];
62+
const result = original.apply(this, args);
63+
64+
if (typeof toggleName === 'string' && typeof result === 'boolean') {
65+
insertFlagToScope(toggleName, result);
66+
} else if (DEBUG_BUILD) {
67+
logger.error(
68+
`[Feature Flags] UnleashClient.isEnabled does not match expected signature. arg0: ${toggleName} (${typeof toggleName}), result: ${result} (${typeof result})`,
69+
);
70+
}
71+
return result;
72+
};
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export interface IVariant {
2+
name: string;
3+
enabled: boolean;
4+
feature_enabled?: boolean;
5+
payload?: {
6+
type: string;
7+
value: string;
8+
};
9+
}
10+
11+
export interface UnleashClient {
12+
isEnabled(this: UnleashClient, featureName: string): boolean;
13+
getVariant(this: UnleashClient, featureName: string): IVariant;
14+
}
15+
16+
export interface IConfig {
17+
[key: string]: unknown;
18+
appName: string;
19+
clientKey: string;
20+
url: URL | string;
21+
}
22+
23+
export type UnleashClientClass = new (config: IConfig) => UnleashClient;

0 commit comments

Comments
 (0)