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

Merged
merged 17 commits into from
Jun 17, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add of, stat, unleash tests
  • Loading branch information
aliu39 committed Jun 6, 2025
commit 2459a11182f12f7076db3b96056b6243f92388ad
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as Sentry from '@sentry/browser';

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

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/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;
},
};
};

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 client = (window as any).initialize();
for (let i = 1; i <= maxFlags; i++) {
client.getBooleanValue(`feat${i}`, false);
}
client.getBooleanValue(`feat${maxFlags + 1}`, true); // drop
client.getBooleanValue('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
@@ -0,0 +1,39 @@
import * as Sentry from '@sentry/browser';

class MockStatsigClient {
constructor() {
this._gateEvaluationListeners = [];
this._mockGateValues = {};
}

on(event, listener) {
this._gateEvaluationListeners.push(listener);
}

checkGate(name) {
const value = this._mockGateValues[name] || false; // unknown features default to false.
this._gateEvaluationListeners.forEach(listener => {
listener({ gate: { name, value } });
});
return value;
}

setMockGateValue(name, value) {
this._mockGateValues[name] = value;
}
}

window.statsigClient = new MockStatsigClient();

window.Sentry = Sentry;
window.sentryStatsigIntegration = Sentry.statsigIntegration({ featureFlagClient: window.statsigClient });

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
sampleRate: 1.0,
tracesSampleRate: 1.0,
integrations: [
window.sentryStatsigIntegration,
Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }),
],
});
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,73 @@
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 client = (window as any).statsigClient;
for (let i = 1; i <= maxFlags; i++) {
client.checkGate(`feat${i}`); // values default to false
}

client.setMockGateValue(`feat${maxFlags + 1}`, true);
client.checkGate(`feat${maxFlags + 1}`); // dropped

client.setMockGateValue('feat3', true);
client.checkGate('feat3'); // 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
@@ -0,0 +1,60 @@
import * as Sentry from '@sentry/browser';

window.UnleashClient = class {
constructor() {
this._featureToVariant = {
strFeat: { name: 'variant1', enabled: true, feature_enabled: true, payload: { type: 'string', value: 'test' } },
noPayloadFeat: { name: 'eu-west', enabled: true, feature_enabled: true },
jsonFeat: {
name: 'paid-orgs',
enabled: true,
feature_enabled: true,
payload: {
type: 'json',
value: '{"foo": {"bar": "baz"}, "hello": [1, 2, 3]}',
},
},

// Enabled feature with no configured variants.
noVariantFeat: { name: 'disabled', enabled: false, feature_enabled: true },

// Disabled feature.
disabledFeat: { name: 'disabled', enabled: false, feature_enabled: false },
};

// Variant returned for features that don't exist.
// `feature_enabled` may be defined in prod, but we want to test the undefined case.
this._fallbackVariant = {
name: 'disabled',
enabled: false,
};
}

isEnabled(toggleName) {
const variant = this._featureToVariant[toggleName] || this._fallbackVariant;
return variant.feature_enabled || false;
}

getVariant(toggleName) {
return this._featureToVariant[toggleName] || this._fallbackVariant;
}
};

// Not a mock UnleashClient class method since it needs to match the signature of the actual UnleashClient.
window.setVariant = (client, featureName, variantName, isEnabled) => {
client._featureToVariant[featureName] = { name: variantName, enabled: isEnabled, feature_enabled: isEnabled };
}

window.Sentry = Sentry;
window.sentryUnleashIntegration = Sentry.unleashIntegration({ featureFlagClientClass: window.UnleashClient });

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
sampleRate: 1.0,
tracesSampleRate: 1.0,
integrations: [
window.sentryUnleashIntegration,
Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }),
],
});

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>
Loading