Skip to content

ref(flags): instrument sdk featureFlagsIntegration to track FE flag evals #81954

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 6 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 6 additions & 2 deletions static/app/actionCreators/organization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import TeamStore from 'sentry/stores/teamStore';
import type {Organization, Team} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
import FeatureFlagOverrides from 'sentry/utils/featureFlagOverrides';
import FeatureObserver from 'sentry/utils/featureObserver';
import {
addOrganizationFeaturesHandler,
buildSentryFeaturesHandler,
} from 'sentry/utils/featureFlags';
import {getPreloadedDataPromise} from 'sentry/utils/getPreloadedData';
import parseLinkHeader from 'sentry/utils/parseLinkHeader';

Expand All @@ -42,8 +45,9 @@ async function fetchOrg(
}

FeatureFlagOverrides.singleton().loadOrg(org);
FeatureObserver.singleton({}).observeOrganizationFlags({
addOrganizationFeaturesHandler({
organization: org,
handler: buildSentryFeaturesHandler('feature.organizations:'),
});

OrganizationStore.onUpdate(org, {replace: true});
Expand Down
9 changes: 1 addition & 8 deletions static/app/bootstrap/initializeSdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
useNavigationType,
} from 'react-router-dom';
import {useEffect} from 'react';
import FeatureObserver from 'sentry/utils/featureObserver';

const SPA_MODE_ALLOW_URLS = [
'localhost',
Expand Down Expand Up @@ -72,6 +71,7 @@ function getSentryIntegrations() {
filterKeys: ['sentry-spa'],
behaviour: 'apply-tag-if-contains-third-party-frames',
}),
Sentry.featureFlagsIntegration(),
];

return integrations;
Expand Down Expand Up @@ -179,15 +179,8 @@ export function initializeSdk(config: Config) {

handlePossibleUndefinedResponseBodyErrors(event);
addEndpointTagToRequestError(event);

lastEventId = event.event_id || hint.event_id;

// attach feature flags to the event context
if (event.contexts) {
const flags = FeatureObserver.singleton({}).getFeatureFlags();
event.contexts.flags = flags;
}

return event;
},
});
Expand Down
69 changes: 69 additions & 0 deletions static/app/utils/featureFlags.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {OrganizationFixture} from 'sentry-fixture/organization';
import {ProjectFixture} from 'sentry-fixture/project';

import {
addOrganizationFeaturesHandler,
addProjectFeaturesHandler,
} from 'sentry/utils/featureFlags';

describe('addOrganizationFeaturesHandler', () => {
let organization;

beforeEach(() => {
organization = OrganizationFixture({
features: ['enable-issues', 'enable-replay'],
});
});

it('should pass the flag name and result to the handler on each evaluation', () => {
const mockHandler = jest.fn();
addOrganizationFeaturesHandler({organization, handler: mockHandler});

organization.features.includes('enable-replay');
organization.features.includes('replay-mobile-ui');
organization.features.includes('enable-issues');

expect(mockHandler).toHaveBeenNthCalledWith(1, 'enable-replay', true);
expect(mockHandler).toHaveBeenNthCalledWith(2, 'replay-mobile-ui', false);
expect(mockHandler).toHaveBeenNthCalledWith(3, 'enable-issues', true);
});

it('should not change the functionality of `includes`', () => {
const mockHandler = jest.fn();
addOrganizationFeaturesHandler({organization, handler: mockHandler});
expect(organization.features.includes('enable-issues')).toBe(true);
expect(organization.features.includes('enable-replay')).toBe(true);
expect(organization.features.includes('replay-mobile-ui')).toBe(false);
});
});

describe('addProjectFeaturesHandler', () => {
let project;

beforeEach(() => {
project = ProjectFixture({
features: ['enable-issues', 'enable-replay'],
});
});

it('should pass the flag name and result to the handler on each evaluation', () => {
const mockHandler = jest.fn();
addProjectFeaturesHandler({project, handler: mockHandler});

project.features.includes('enable-replay');
project.features.includes('replay-mobile-ui');
project.features.includes('enable-issues');

expect(mockHandler).toHaveBeenNthCalledWith(1, 'enable-replay', true);
expect(mockHandler).toHaveBeenNthCalledWith(2, 'replay-mobile-ui', false);
expect(mockHandler).toHaveBeenNthCalledWith(3, 'enable-issues', true);
});

it('should not change the functionality of `includes`', () => {
const mockHandler = jest.fn();
addProjectFeaturesHandler({project, handler: mockHandler});
expect(project.features.includes('enable-issues')).toBe(true);
expect(project.features.includes('enable-replay')).toBe(true);
expect(project.features.includes('replay-mobile-ui')).toBe(false);
});
});
78 changes: 78 additions & 0 deletions static/app/utils/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {logger} from '@sentry/core';
import * as Sentry from '@sentry/react';

import type {Organization} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';

/**
* Returns a callback that can be used to track sentry flag evaluations through
* the Sentry SDK, in the event context. If the FeatureFlagsIntegration is not
* installed, the callback is a no-op.
*
* @param prefix - optionally specifies a prefix for flag names, before calling
* the SDK hook
*/
export function buildSentryFeaturesHandler(
prefix?: string
): (name: string, value: unknown) => void {
const featureFlagsIntegration =
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
'FeatureFlags'
);
if (!featureFlagsIntegration || !('addFeatureFlag' in featureFlagsIntegration)) {
logger.error(
'Unable to track flag evaluations because FeatureFlagsIntegration is not installed correctly.'
);
return (_name, _value) => {};
}
return (name: string, value: unknown) => {
// Append `feature.organizations:` in front to match the Sentry options automator format
featureFlagsIntegration?.addFeatureFlag((prefix ?? '') + name, value);
};
}

/**
* Registers a handler that processes feature names and values on each call to
* organization.features.includes().
*/
export function addOrganizationFeaturesHandler({
organization,
handler,
}: {
handler: (name: string, value: unknown) => void;
organization: Organization;
}) {
const includesHandler = {
apply: (includes: any, orgFeatures: string[], flagName: string[]) => {
// Evaluate the result of .includes() and pass it to hook before returning
const flagResult = includes.apply(orgFeatures, flagName);
handler(flagName[0], flagResult);
return flagResult;
},
};
const proxy = new Proxy(organization.features.includes, includesHandler);
organization.features.includes = proxy;
}

/**
* Registers a handler that processes feature names and values on each call to
* organization.features.includes().
*/
export function addProjectFeaturesHandler({
project,
handler,
}: {
handler: (name: string, value: unknown) => void;
project: Project;
}) {
const includesHandler = {
apply: (includes: any, projFeatures: string[], flagName: string[]) => {
// Evaluate the result of .includes() and pass it to hook before returning
const flagResult = includes.apply(projFeatures, flagName);
handler(flagName[0], flagResult);
return flagResult;
},
};
const proxy = new Proxy(project.features.includes, includesHandler);
project.features.includes = proxy;
}
Loading
Loading