Skip to content
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