Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/aliu/launch-darkly-integration' …
Browse files Browse the repository at this point in the history
…into beta
  • Loading branch information
billyvg committed Nov 14, 2024
2 parents d20f878 + 611df2c commit 2b6025f
Show file tree
Hide file tree
Showing 18 changed files with 464 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'feedbackAsyncIntegration'),
gzip: true,
limit: '33 KB',
limit: '33.1 KB',
},
// React SDK (ESM)
{
Expand Down
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, shouldSkipLaunchDarklyTest, 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 ({ getLocalTestPath, page }) => {
if (shouldSkipLaunchDarklyTest()) {
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 getLocalTestPath({ testDir: __dirname, skipDsnRouteHandler: true });
await page.goto(url);

await page.waitForFunction(bufferSize => {
const ldClient = (window as any).initializeLD();
for (let i = 1; i <= bufferSize; i++) {
ldClient.variation(`feat${i}`, false);
}
ldClient.variation(`feat${bufferSize + 1}`, true); // eviction
ldClient.variation('feat3', true); // update
return true;
}, FLAG_BUFFER_SIZE);

const reqPromise = waitForErrorRequest(page);
await page.locator('#error').click();
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,35 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.sentryLDIntegration = Sentry.launchDarklyIntegration();

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 1.0,
integrations: [window.sentryLDIntegration],
});

// Manually mocking this because LD only has mock test utils for the React SDK.
// Also, no SDK has mock utils for FlagUsedHandler's.
const MockLaunchDarkly = {
initialize(_clientId, context, options) {
const flagUsedHandler = options && options.inspectors ? options.inspectors[0].method : undefined;

return {
variation(key, defaultValue) {
if (flagUsedHandler) {
flagUsedHandler(key, { value: defaultValue }, context);
}
return defaultValue;
},
};
},
};

window.initializeLD = () => {
return MockLaunchDarkly.initialize(
'example-client-id',
{ kind: 'user', key: 'example-context-key' },
{ inspectors: [Sentry.buildLaunchDarklyFlagUsedHandler()] },
);
};
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, shouldSkipLaunchDarklyTest, waitForErrorRequest } from '../../../../../utils/helpers';

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

sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestPath, page }) => {
if (shouldSkipLaunchDarklyTest()) {
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 getLocalTestPath({ 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.waitForFunction(() => {
const Sentry = (window as any).Sentry;
const errorButton = document.querySelector('#error') as HTMLButtonElement;
const ldClient = (window as any).initializeLD();

ldClient.variation('shared', true);

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

ldClient.variation('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 },
]);
});
12 changes: 12 additions & 0 deletions dev-packages/browser-integration-tests/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,18 @@ export function shouldSkipMetricsTest(): boolean {
return bundle != null && !bundle.includes('tracing') && !bundle.includes('esm') && !bundle.includes('cjs');
}

/**
* We can only test the launchdarkly browser integration in certain bundles/packages:
* - NPM (ESM, CJS)
* - Not CDNs.
*
* @returns `true` if we should skip the launchdarkly test
*/
export function shouldSkipLaunchDarklyTest(): boolean {
const bundle = process.env.PW_BUNDLE as string | undefined;
return bundle != null && !bundle.includes('esm') && !bundle.includes('cjs');
}

/**
* Waits until a number of requests matching urlRgx at the given URL arrive.
* If the timeout option is configured, this function will abort waiting, even if it hasn't received the configured
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,4 @@ export type { Span } from '@sentry/types';
export { makeBrowserOfflineTransport } from './transports/offline';
export { browserProfilingIntegration } from './profiling/integration';
export { spotlightBrowserIntegration } from './integrations/spotlight';
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integration';
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types';
import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types';

import { defineIntegration, getCurrentScope } from '@sentry/core';
import { insertToFlagBuffer } from '../../../utils/featureFlags';

/**
* Sentry integration for capturing feature flags from LaunchDarkly.
*
* 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 {launchDarklyIntegration, buildLaunchDarklyFlagUsedInspector} from '@sentry/browser';
* import * as LaunchDarkly from 'launchdarkly-js-client-sdk';
*
* Sentry.init(..., integrations: [launchDarklyIntegration()])
* const ldClient = LaunchDarkly.initialize(..., {inspectors: [buildLaunchDarklyFlagUsedHandler()]});
* ```
*/
export const launchDarklyIntegration = defineIntegration(() => {
return {
name: 'LaunchDarkly',

processEvent(event: Event, _hint: EventHint, _client: Client): Event {
const scope = getCurrentScope();
const flagContext = scope.getScopeData().contexts.flags;
const flagBuffer = flagContext ? flagContext.values : [];

if (event.contexts === undefined) {
event.contexts = {};
}
event.contexts.flags = { values: [...flagBuffer] };
return event;
},
};
}) satisfies IntegrationFn;

/**
* LaunchDarkly hook that listens for flag evaluations and updates the `flags`
* context in our Sentry scope. This needs to be registered as an
* 'inspector' in LaunchDarkly initialize() options, separately from
* `launchDarklyIntegration`. Both are needed to collect feature flags on error.
*/
export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler {
return {
name: 'sentry-flag-auditor',
type: 'flag-used',

synchronous: true,

/**
* Handle a flag evaluation by storing its name and value on the current scope.
*/
method: (flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext) => {
if (typeof flagDetail.value === 'boolean') {
const scopeContexts = getCurrentScope().getScopeData().contexts;
if (!scopeContexts.flags) {
scopeContexts.flags = { values: [] };
}
const flagBuffer = scopeContexts.flags.values;
insertToFlagBuffer(flagBuffer, flagKey, flagDetail.value);
}
return;
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Inline definitions of LaunchDarkly types so we don't have to include their
* SDK in devDependencies. These are only for type-checking and can be extended
* as needed - for exact definitions, reference `launchdarkly-js-client-sdk`.
*/

/**
* Currently, the Sentry integration does not read from values of this type.
*/
export type LDContext = object;

/**
* An object that combines the result of a feature flag evaluation with information about
* how it was calculated.
*/
export interface LDEvaluationDetail {
value: unknown;
// unused optional props: variationIndex and reason
}

/**
* Callback interface for collecting information about the SDK at runtime.
*
* This interface is used to collect information about flag usage.
*
* This interface should not be used by the application to access flags for the purpose of controlling application
* flow. It is intended for monitoring, analytics, or debugging purposes.
*/
export interface LDInspectionFlagUsedHandler {
type: 'flag-used';

/**
* Name of the inspector. Will be used for logging issues with the inspector.
*/
name: string;

/**
* If `true`, then the inspector will be ran synchronously with evaluation.
* Synchronous inspectors execute inline with evaluation and care should be taken to ensure
* they have minimal performance overhead.
*/
synchronous?: boolean;

/**
* This method is called when a flag is accessed via a variation method, or it can be called based on actions in
* wrapper SDKs which have different methods of tracking when a flag was accessed. It is not called when a call is made
* to allFlags.
*/
method: (flagKey: string, flagDetail: LDEvaluationDetail, context: LDContext) => void;
}
59 changes: 59 additions & 0 deletions packages/browser/src/utils/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { FeatureFlag } from '@sentry/types';
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../debug-build';

/**
* Ordered LRU cache for storing feature flags in the scope context. The name
* of each flag in the buffer is unique, and the output of getAll() is ordered
* from oldest to newest.
*/

/**
* Max size of the LRU flag buffer stored in Sentry scope and event contexts.
*/
export const FLAG_BUFFER_SIZE = 100;

/**
* Insert into a FeatureFlag array while maintaining ordered LRU properties. Not
* thread-safe. After inserting:
* - `flags` is sorted in order of recency, with the newest flag at the end.
* - No other flags with the same name exist in `flags`.
* - The length of `flags` does not exceed `maxSize`. The oldest flag is evicted
* as needed.
*
* @param flags The array to insert into.
* @param name Name of the feature flag to insert.
* @param value Value of the feature flag.
* @param maxSize Max number of flags the buffer should store. It's recommended
* to keep this consistent across insertions. Default is DEFAULT_MAX_SIZE
*/
export function insertToFlagBuffer(
flags: FeatureFlag[],
name: string,
value: boolean,
maxSize: number = FLAG_BUFFER_SIZE,
): void {
if (flags.length > maxSize) {
DEBUG_BUILD && logger.error(`[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize=${maxSize}`);
return;
}

// Check if the flag is already in the buffer - O(n)
const index = flags.findIndex(f => f.flag === name);

if (index !== -1) {
// The flag was found, remove it from its current position - O(n)
flags.splice(index, 1);
}

if (flags.length === maxSize) {
// If at capacity, pop the earliest flag - O(n)
flags.shift();
}

// Push the flag to the end - O(1)
flags.push({
flag: name,
result: value,
});
}
Loading

0 comments on commit 2b6025f

Please sign in to comment.