Skip to content

feat(flags): capture feature flag evaluations on spans #16485

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.
// Corresponds to constants in featureFlags.ts, in browser utils.
export const FLAG_BUFFER_SIZE = 100;
export const MAX_FLAGS_PER_SPAN = 10;
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
import { FLAG_BUFFER_SIZE } from '../../constants';
import { sentryTest } from '../../../../../../utils/fixtures';
import {
envelopeRequestParser,
shouldSkipFeatureFlagsTest,
waitForErrorRequest,
} from '../../../../../../utils/helpers';
import { FLAG_BUFFER_SIZE } from '../../../constants';

sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect } from '@playwright/test';
import type { Scope } from '@sentry/browser';
import { sentryTest } from '../../../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
import { sentryTest } from '../../../../../../utils/fixtures';
import {
envelopeRequestParser,
shouldSkipFeatureFlagsTest,
waitForErrorRequest,
} from '../../../../../../utils/helpers';

sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 1.0,
tracesSampleRate: 1.0,
integrations: [
Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }),
Sentry.featureFlagsIntegration(),
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const btnStartSpan = document.getElementById('btnStartSpan');
const btnEndSpan = document.getElementById('btnEndSpan');
const btnStartNestedSpan = document.getElementById('btnStartNestedSpan');
const btnEndNestedSpan = document.getElementById('btnEndNestedSpan');

window.withNestedSpans = callback => {
window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => {
window.traceId = rootSpan.spanContext().traceId;

window.Sentry.startSpan({ name: 'test-span' }, _span => {
window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => {
callback();
});
});
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="btnStartSpan">Start Span</button>
<button id="btnEndSpan">End Span</button>
<button id="btnStartNestedSpan">Start Nested Span</button>
<button id="btnEndNestedSpan">End Nested Span</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../../utils/fixtures';
import {
type EventAndTraceHeader,
eventAndTraceHeaderRequestParser,
getMultipleSentryEnvelopeRequests,
shouldSkipFeatureFlagsTest,
shouldSkipTracingTest,
} from '../../../../../utils/helpers';
import { MAX_FLAGS_PER_SPAN } from '../../constants';

sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) {
sentryTest.skip();
}

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});

const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
await page.goto(url);

const envelopeRequestPromise = getMultipleSentryEnvelopeRequests<EventAndTraceHeader>(
page,
1,
{},
eventAndTraceHeaderRequestParser,
);

// withNestedSpans is a util used to start 3 nested spans: root-span (not recorded in transaction_event.spans), span, and nested-span.
await page.evaluate(maxFlags => {
(window as any).withNestedSpans(() => {
const flagsIntegration = (window as any).Sentry.getClient().getIntegrationByName('FeatureFlags');
for (let i = 1; i <= maxFlags; i++) {
flagsIntegration.addFeatureFlag(`feat${i}`, false);
}
flagsIntegration.addFeatureFlag(`feat${maxFlags + 1}`, true); // dropped flag
flagsIntegration.addFeatureFlag('feat3', true); // update
});
return true;
}, MAX_FLAGS_PER_SPAN);

const event = (await envelopeRequestPromise)[0][0];
const innerSpan = event.spans?.[0];
const outerSpan = event.spans?.[1];
const outerSpanFlags = Object.entries(outerSpan?.data ?? {}).filter(([key, _val]) =>
key.startsWith('flag.evaluation'),
);
const innerSpanFlags = Object.entries(innerSpan?.data ?? {}).filter(([key, _val]) =>
key.startsWith('flag.evaluation'),
);

expect(innerSpanFlags).toEqual([]);

const expectedOuterSpanFlags = [];
for (let i = 1; i <= 2; i++) {
expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]);
}
for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) {
expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]);
}
expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]);
expect(outerSpanFlags).toEqual(expectedOuterSpanFlags);
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
import { FLAG_BUFFER_SIZE } from '../../constants';
import { sentryTest } from '../../../../../../utils/fixtures';
import {
envelopeRequestParser,
shouldSkipFeatureFlagsTest,
waitForErrorRequest,
} from '../../../../../../utils/helpers';
import { FLAG_BUFFER_SIZE } from '../../../constants';

sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect } from '@playwright/test';
import type { Scope } from '@sentry/browser';
import { sentryTest } from '../../../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
import { sentryTest } from '../../../../../../utils/fixtures';
import {
envelopeRequestParser,
shouldSkipFeatureFlagsTest,
waitForErrorRequest,
} from '../../../../../../utils/helpers';

sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as Sentry from '@sentry/browser';

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

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 1.0,
tracesSampleRate: 1.0,
integrations: [
Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }),
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.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,16 @@
const btnStartSpan = document.getElementById('btnStartSpan');
const btnEndSpan = document.getElementById('btnEndSpan');
const btnStartNestedSpan = document.getElementById('btnStartNestedSpan');
const btnEndNestedSpan = document.getElementById('btnEndNestedSpan');

window.withNestedSpans = callback => {
window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => {
window.traceId = rootSpan.spanContext().traceId;

window.Sentry.startSpan({ name: 'test-span' }, _span => {
window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => {
callback();
});
});
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="btnStartSpan">Start Span</button>
<button id="btnEndSpan">End Span</button>
<button id="btnStartNestedSpan">Start Nested Span</button>
<button id="btnEndNestedSpan">End Nested Span</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../../utils/fixtures';
import {
type EventAndTraceHeader,
eventAndTraceHeaderRequestParser,
getMultipleSentryEnvelopeRequests,
shouldSkipFeatureFlagsTest,
shouldSkipTracingTest,
} from '../../../../../utils/helpers';
import { MAX_FLAGS_PER_SPAN } from '../../constants';

sentryTest("Feature flags are added to active span's attributes on span end.", async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) {
sentryTest.skip();
}

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});

const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
await page.goto(url);

const envelopeRequestPromise = getMultipleSentryEnvelopeRequests<EventAndTraceHeader>(
page,
1,
{},
eventAndTraceHeaderRequestParser,
);

// withNestedSpans is a util used to start 3 nested spans: root-span (not recorded in transaction_event.spans), span, and nested-span.
await page.evaluate(maxFlags => {
(window as any).withNestedSpans(() => {
const ldClient = (window as any).initializeLD();
for (let i = 1; i <= maxFlags; i++) {
ldClient.variation(`feat${i}`, false);
}
ldClient.variation(`feat${maxFlags + 1}`, true); // dropped
ldClient.variation('feat3', true); // update
});
return true;
}, MAX_FLAGS_PER_SPAN);

const event = (await envelopeRequestPromise)[0][0];
const innerSpan = event.spans?.[0];
const outerSpan = event.spans?.[1];
const outerSpanFlags = Object.entries(outerSpan?.data ?? {}).filter(([key, _val]) =>
key.startsWith('flag.evaluation'),
);
const innerSpanFlags = Object.entries(innerSpan?.data ?? {}).filter(([key, _val]) =>
key.startsWith('flag.evaluation'),
);

expect(innerSpanFlags).toEqual([]);

const expectedOuterSpanFlags = [];
for (let i = 1; i <= 2; i++) {
expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]);
}
for (let i = 4; i <= MAX_FLAGS_PER_SPAN; i++) {
expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, false]);
}
expectedOuterSpanFlags.push(['flag.evaluation.feat3', true]);
expect(outerSpanFlags).toEqual(expectedOuterSpanFlags);
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
import { FLAG_BUFFER_SIZE } from '../../constants';
import { sentryTest } from '../../../../../../utils/fixtures';
import {
envelopeRequestParser,
shouldSkipFeatureFlagsTest,
waitForErrorRequest,
} from '../../../../../../utils/helpers';
import { FLAG_BUFFER_SIZE } from '../../../constants';

sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
import { FLAG_BUFFER_SIZE } from '../../constants';
import { sentryTest } from '../../../../../../utils/fixtures';
import {
envelopeRequestParser,
shouldSkipFeatureFlagsTest,
waitForErrorRequest,
} from '../../../../../../utils/helpers';
import { FLAG_BUFFER_SIZE } from '../../../constants';

sentryTest('Flag evaluation error hook', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect } from '@playwright/test';
import type { Scope } from '@sentry/browser';
import { sentryTest } from '../../../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
import { sentryTest } from '../../../../../../utils/fixtures';
import {
envelopeRequestParser,
shouldSkipFeatureFlagsTest,
waitForErrorRequest,
} from '../../../../../../utils/helpers';

sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.sentryOpenFeatureIntegration = Sentry.openFeatureIntegration();

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 1.0,
tracesSampleRate: 1.0,
integrations: [
window.sentryOpenFeatureIntegration,
Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }),
],
});

window.initialize = () => {
return {
getBooleanValue(flag, value) {
let hook = new Sentry.OpenFeatureIntegrationHook();
hook.after(null, { flagKey: flag, value: value });
return value;
},
};
};
Loading
Loading