Skip to content

Commit 2b6025f

Browse files
committed
Merge remote-tracking branch 'origin/aliu/launch-darkly-integration' into beta
2 parents d20f878 + 611df2c commit 2b6025f

File tree

18 files changed

+464
-1
lines changed

18 files changed

+464
-1
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ module.exports = [
107107
path: 'packages/browser/build/npm/esm/index.js',
108108
import: createImport('init', 'feedbackAsyncIntegration'),
109109
gzip: true,
110-
limit: '33 KB',
110+
limit: '33.1 KB',
111111
},
112112
// React SDK (ESM)
113113
{
Lines changed: 48 additions & 0 deletions
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, shouldSkipLaunchDarklyTest, 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 ({ getLocalTestPath, page }) => {
10+
if (shouldSkipLaunchDarklyTest()) {
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 getLocalTestPath({ testDir: __dirname, skipDsnRouteHandler: true });
23+
await page.goto(url);
24+
25+
await page.waitForFunction(bufferSize => {
26+
const ldClient = (window as any).initializeLD();
27+
for (let i = 1; i <= bufferSize; i++) {
28+
ldClient.variation(`feat${i}`, false);
29+
}
30+
ldClient.variation(`feat${bufferSize + 1}`, true); // eviction
31+
ldClient.variation('feat3', true); // update
32+
return true;
33+
}, FLAG_BUFFER_SIZE);
34+
35+
const reqPromise = waitForErrorRequest(page);
36+
await page.locator('#error').click();
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+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.sentryLDIntegration = Sentry.launchDarklyIntegration();
5+
6+
Sentry.init({
7+
dsn: 'https://[email protected]/1337',
8+
sampleRate: 1.0,
9+
integrations: [window.sentryLDIntegration],
10+
});
11+
12+
// Manually mocking this because LD only has mock test utils for the React SDK.
13+
// Also, no SDK has mock utils for FlagUsedHandler's.
14+
const MockLaunchDarkly = {
15+
initialize(_clientId, context, options) {
16+
const flagUsedHandler = options && options.inspectors ? options.inspectors[0].method : undefined;
17+
18+
return {
19+
variation(key, defaultValue) {
20+
if (flagUsedHandler) {
21+
flagUsedHandler(key, { value: defaultValue }, context);
22+
}
23+
return defaultValue;
24+
},
25+
};
26+
},
27+
};
28+
29+
window.initializeLD = () => {
30+
return MockLaunchDarkly.initialize(
31+
'example-client-id',
32+
{ kind: 'user', key: 'example-context-key' },
33+
{ inspectors: [Sentry.buildLaunchDarklyFlagUsedHandler()] },
34+
);
35+
};
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: 9 additions & 0 deletions
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>
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+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
5+
import { envelopeRequestParser, shouldSkipLaunchDarklyTest, waitForErrorRequest } from '../../../../../utils/helpers';
6+
7+
import type { Scope } from '@sentry/browser';
8+
9+
sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestPath, page }) => {
10+
if (shouldSkipLaunchDarklyTest()) {
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 getLocalTestPath({ 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.waitForFunction(() => {
29+
const Sentry = (window as any).Sentry;
30+
const errorButton = document.querySelector('#error') as HTMLButtonElement;
31+
const ldClient = (window as any).initializeLD();
32+
33+
ldClient.variation('shared', true);
34+
35+
Sentry.withScope((scope: Scope) => {
36+
ldClient.variation('forked', true);
37+
ldClient.variation('shared', false);
38+
scope.setTag('isForked', true);
39+
if (errorButton) {
40+
errorButton.click();
41+
}
42+
});
43+
44+
ldClient.variation('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+
});

dev-packages/browser-integration-tests/utils/helpers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,18 @@ export function shouldSkipMetricsTest(): boolean {
273273
return bundle != null && !bundle.includes('tracing') && !bundle.includes('esm') && !bundle.includes('cjs');
274274
}
275275

276+
/**
277+
* We can only test the launchdarkly browser integration in certain bundles/packages:
278+
* - NPM (ESM, CJS)
279+
* - Not CDNs.
280+
*
281+
* @returns `true` if we should skip the launchdarkly test
282+
*/
283+
export function shouldSkipLaunchDarklyTest(): boolean {
284+
const bundle = process.env.PW_BUNDLE as string | undefined;
285+
return bundle != null && !bundle.includes('esm') && !bundle.includes('cjs');
286+
}
287+
276288
/**
277289
* Waits until a number of requests matching urlRgx at the given URL arrive.
278290
* If the timeout option is configured, this function will abort waiting, even if it hasn't received the configured

packages/browser/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,4 @@ export type { Span } from '@sentry/types';
7575
export { makeBrowserOfflineTransport } from './transports/offline';
7676
export { browserProfilingIntegration } from './profiling/integration';
7777
export { spotlightBrowserIntegration } from './integrations/spotlight';
78+
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integration';
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types';
2+
import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types';
3+
4+
import { defineIntegration, getCurrentScope } from '@sentry/core';
5+
import { insertToFlagBuffer } from '../../../utils/featureFlags';
6+
7+
/**
8+
* Sentry integration for capturing feature flags from LaunchDarkly.
9+
*
10+
* See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information.
11+
*
12+
* @example
13+
* ```
14+
* import * as Sentry from '@sentry/browser';
15+
* import {launchDarklyIntegration, buildLaunchDarklyFlagUsedInspector} from '@sentry/browser';
16+
* import * as LaunchDarkly from 'launchdarkly-js-client-sdk';
17+
*
18+
* Sentry.init(..., integrations: [launchDarklyIntegration()])
19+
* const ldClient = LaunchDarkly.initialize(..., {inspectors: [buildLaunchDarklyFlagUsedHandler()]});
20+
* ```
21+
*/
22+
export const launchDarklyIntegration = defineIntegration(() => {
23+
return {
24+
name: 'LaunchDarkly',
25+
26+
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
27+
const scope = getCurrentScope();
28+
const flagContext = scope.getScopeData().contexts.flags;
29+
const flagBuffer = flagContext ? flagContext.values : [];
30+
31+
if (event.contexts === undefined) {
32+
event.contexts = {};
33+
}
34+
event.contexts.flags = { values: [...flagBuffer] };
35+
return event;
36+
},
37+
};
38+
}) satisfies IntegrationFn;
39+
40+
/**
41+
* LaunchDarkly hook that listens for flag evaluations and updates the `flags`
42+
* context in our Sentry scope. This needs to be registered as an
43+
* 'inspector' in LaunchDarkly initialize() options, separately from
44+
* `launchDarklyIntegration`. Both are needed to collect feature flags on error.
45+
*/
46+
export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler {
47+
return {
48+
name: 'sentry-flag-auditor',
49+
type: 'flag-used',
50+
51+
synchronous: true,
52+
53+
/**
54+
* Handle a flag evaluation by storing its name and value on the current scope.
55+
*/
56+
method: (flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext) => {
57+
if (typeof flagDetail.value === 'boolean') {
58+
const scopeContexts = getCurrentScope().getScopeData().contexts;
59+
if (!scopeContexts.flags) {
60+
scopeContexts.flags = { values: [] };
61+
}
62+
const flagBuffer = scopeContexts.flags.values;
63+
insertToFlagBuffer(flagBuffer, flagKey, flagDetail.value);
64+
}
65+
return;
66+
},
67+
};
68+
}

0 commit comments

Comments
 (0)