FeatureGateJsClient
Atlassians wrapper for the Statsig js-lite client.
Usage
import FeatureGateJsClient from '@atlaskit/feature-gate-js-client';
Detailed docs and example usage can be found
here.
What is this repository for?
The js-client covers frontend feature gate use cases. This client is modelled around bootstrapping
feature gate values from the backend and does not receive live updates.
Client usage
Installation
The client can be pulled from the Artifactory NPM repository.
yarn add @atlaskit/feature-gate-js-client
Initialization
The client must be initialized before attempted usage or it will throw an error.
There are three ways to initialize your client:
1. Default initialization mechanism
This will initialize the client by calling out to feature-flag-service
(fx3), and bootstrapping the client with the returned values. If the
client fails to initialize for any reason, including taking longer than 2 seconds to fetch the
values, default values will be used.
import FeatureGates, {
FeatureGateEnvironment,
FeatureGateProducts,
PerimeterType,
} from '@atlaskit/feature-gate-js-client';
try {
await FeatureGates.initialize(
{
apiKey: 'client-test',
environment: FeatureGateEnvironment.Production,
targetApp: 'jira_web',
fetchTimeoutMs: 1000,
useGatewayUrl: true,
perimeter: PerimeterType.FEDRAMP_MODERATE,
},
{
analyticsAnonymousId: '<analyticsAnonymousId>',
atlassianAccountId: '<aaid>',
atlassianOrgId: '<orgid>',
tenantId: '<tenantid>',
transactionAccountId: '<transactionAccountId>',
trelloUserId: '<trelloUserId>',
trelloWorkspaceId: '<trelloWorkspaceId>',
},
{
exampleCustomAttribute: '<attributeValue>',
},
);
} catch (err) {
console.error('Failed to initialize FeatureGates client.', err);
}
If your application has a log-in flow or other mechanism that makes it possible for the user to
change during a session, then you can use the updateUser
method to apply this change. The
signature of this method is almost identical to initialize
, except that it only requires options
that relate to the network call it will perform to fetch the new set of values.
IMPORTANT: Calling this method will completely re-initialize the client with a new set of flags.
You will need to re-render the entire page after this completes to ensure everything picks up the
new flag values. You should avoid using this frequently as it has implications on the user
experience.
import FeatureGates, {
FeatureGateEnvironment,
FeatureGateProducts,
PerimeterType,
} from '@atlaskit/feature-gate-js-client';
try {
await FeatureGates.updateUser(
{
apiKey: 'client-test',
environment: FeatureGateEnvironment.Production,
fetchTimeoutMs: 1000,
useGatewayUrl: true,
perimeter: PerimeterType.FEDRAMP_MODERATE,
},
{
analyticsAnonymousId: '<analyticsAnonymousId>',
atlassianAccountId: '<aaid>',
atlassianOrgId: '<orgid>',
tenantId: '<tenantid>',
transactionAccountId: '<transactionAccountId>',
trelloUserId: '<trelloUserId>',
trelloWorkspaceId: '<trelloWorkspaceId>',
},
{
exampleCustomAttribute: '<attributeValue>',
},
);
} catch (err) {
console.error('Failed to update the FeatureGates user.', err);
}
2. Initializing from values
You must fetch the values yourself using one of our wrapper backend clients (Also found in this
repo) and providing them to this frontend client
import FeatureGates, {
FeatureGateEnvironment,
FeatureGateProducts,
} from '@atlaskit/feature-gate-js-client';
try {
await FeatureGates.initializeFromValues(
{
sdkKey: 'client-test',
environment: FeatureGateEnvironment.Production,
products: [FeatureGateProducts.Jira],
},
{
analyticsAnonymousId: '<analyticsAnonymousId>',
atlassianAccountId: '<aaid>',
atlassianOrgId: '<orgid>',
tenantId: '<tenantid>',
transactionAccountId: '<transactionAccountId>',
trelloUserId: '<trelloUserId>',
trelloWorkspaceId: '<trelloWorkspaceId>',
},
{
exampleCustomAttribute: '<attributeValue>',
},
initializeValues,
);
} catch (err) {
console.error('Failed to initialize FeatureGates client.', err);
}
If your application has a log-in flow or other mechanism that makes it possible for the user to
change during a session, then you can use the updateUserWithValues
method to apply this change.
The signature of this method is almost identical to initializeFromValues
, except that it does not
require any options.
IMPORTANT: Calling this method will completely re-initialize the client with a new set of flags.
You will need to re-render the entire page after this completes to ensure everything picks up the
new flag values. You should avoid using this frequently as it has implications on the user
experience.
import FeatureGates, {
FeatureGateEnvironment,
FeatureGateProducts,
} from '@atlaskit/feature-gate-js-client';
try {
await FeatureGates.updateWithValues(
{
analyticsAnonymousId: '<analyticsAnonymousId>',
atlassianAccountId: '<aaid>',
atlassianOrgId: '<orgid>',
tenantId: '<tenantid>',
transactionAccountId: '<transactionAccountId>',
trelloUserId: '<trelloUserId>',
trelloWorkspaceId: '<trelloWorkspaceId>',
},
{
exampleCustomAttribute: '<attributeValue>',
},
initializeValues,
);
} catch (err) {
console.error('Failed to update FeatureGates user.', err);
}
If there are any issues during initialization, then the client will be put in a mode which always
returns default values, and a rejected promise will be returned. You can catch this rejected promise
if you wish to record your own logs and metrics, or if you wish to stop your application from
loading with the defaults.
There is only once instance of the FeatureGates client, so only the first initialize call will start
the initialization. Any subsequent calls will return the existing Promise for the first
initialization, and the argument values will be ignored. In order to confirm whether the client has
started to initialize already you can call FeatureGates.initializeCalled()
.
3. Initializing using a Provider
This initialization is done using an implementation of the Provider in order to fetch the client sdk
key and experiment values needed.
Supported providers are:
@atlaskit/feature-gate-single-fetch-provider
@atlaskit/feature-gate-polling-provider
import FeatureGates, {
FeatureGateEnvironment,
FeatureGateProducts,
PerimeterType,
} from '@atlaskit/feature-gate-js-client';
try {
await FeatureGates.initializeWithProvider(
{
apiKey: 'client-test',
environment: FeatureGateEnvironment.Production,
targetApp: 'jira_web',
perimeter: PerimeterType.FEDRAMP_MODERATE,
},
new Provider(),
{
analyticsAnonymousId: '<analyticsAnonymousId>',
atlassianAccountId: '<aaid>',
atlassianOrgId: '<orgid>',
tenantId: '<tenantid>',
transactionAccountId: '<transactionAccountId>',
trelloUserId: '<trelloUserId>',
trelloWorkspaceId: '<trelloWorkspaceId>',
},
{
exampleCustomAttribute: '<attributeValue>',
},
);
} catch (err) {
console.error('Failed to initialize FeatureGates client.', err);
}
If your application has a log-in flow or other mechanism that makes it possible for the user to
change during a session, then you can use the updateUserWithProvider
method to apply this change.
This method will use the same provider and options provided in initializeWithProvider
. It takes
the identifiers and custom attributes of the new user. that relate to the network call it will
perform to fetch the new set of values.
IMPORTANT: Calling this method will completely re-initialize the client with a new set of flags.
You will need to re-render the entire page after this completes to ensure everything picks up the
new flag values. You should avoid using this frequently as it has implications on the user
experience.
import FeatureGates, {
FeatureGateEnvironment,
FeatureGateProducts,
} from '@atlaskit/feature-gate-js-client';
try {
await FeatureGates.updateUserWithProvider(
{
analyticsAnonymousId: '<analyticsAnonymousId>',
atlassianAccountId: '<aaid>',
atlassianOrgId: '<orgid>',
tenantId: '<tenantid>',
transactionAccountId: '<transactionAccountId>',
trelloUserId: '<trelloUserId>',
trelloWorkspaceId: '<trelloWorkspaceId>',
},
{
exampleCustomAttribute: '<attributeValue>',
},
);
} catch (err) {
console.error('Failed to update the FeatureGates user.', err);
}
Evaluation
In order to evaluate a gate:
import FeatureGates from '@atlaskit/feature-gate-js-client';
if (FeatureGates.checkGate('gateName')) {
}
In order to evaluate an experiment:
import FeatureGates from '@atlaskit/feature-gate-js-client';
if (FeatureGates.getExperimentValue('myExperiment', 'myBooleanParameter', false)) {
}
In order to use more complex experiment configuration:
import FeatureGates from '@atlaskit/feature-gate-js-client';
const isHexCode = (value: unknown) =>
typeof value === 'string' && value.startsWith('#') && value.length === 7;
const buttonColor: string = FeatureGates.getExperimentValue(
'myExperiment',
'myButtonColorStringParameter',
'#000000',
{
typeGuard: isHexCode,
},
);
Exposure Event Logging
Exposure events are batched and sent to Statsig every 10 seconds. Statsig's domain for their event
logging API is blocked by some ad blockers, so by default we are proxying these requests through
xp.atlassian.com
to reduce exposure loss.
Subscriptions
To subscribe to changes to gates. The callback will be called when the check gate value changes.
import FeatureGates from '@atlaskit/feature-gate-js-client';
const unsubscribe = FeatureGates.onGateUpdated('gateName', () => {});
unsubscribe();
To subscribe to changes to experiment values. The callback will be called when the experiment value
changes.
import FeatureGates from '@atlaskit/feature-gate-js-client';
const unsubscribe = FeatureGates.onExperimentValueUpdated(
'myExperiment',
'myButtonColorStringParameter',
'#000000',
() => {},
{
typeGuard: isHexCode,
},
);
unsubscribe();
To subscribe to whenever a new set of values is updated on the client, no matter if the underlying
values have changed.
import FeatureGates from '@atlaskit/feature-gate-js-client';
const unsubscribe = FeatureGates.onAnyUpdated(() => {});
unsubscribe();
Multiple clients on a single page
Typically we don't allow multiple usages of the feature gate client on a single page because the
client makes a lot of heavy network calls which could have a drastic performance impact for
customers if many clients were to exist on a single page. However there are some cases where a
seperate client to the product is absolutely necessary. We expose a way to instantiate a new client
instead of using the static methods for this case.
Due to the performance implications please ask us in #help-statsig-switcheroo before using the
standalone client so that we can check if there are any alternative solutions that won't impact
customers and make us aware of the cases where separate clients are necessary.
import FeatureGateClient from '@atlaskit/feature-gate-js-client/client';
const featureGates = new FeatureGateClient();
await featureGates.initialize({
});
featureGates.checkGate('my-gate');
Testing
Jest
Testing initialization states
You can test the various initialization states by mocking the return values for initialize
and
updateUser
. Note that you will also need to mock initializeCalled
, as this is usually updated by
the real initialize
function.
import FeatureGates from '@atlaskit/feature-gate-js-client';
jest.mock('@atlaskit/feature-gate-js-client', () => ({
...jest.requireActual('@atlaskit/feature-gate-js-client'),
initializeCalled: jest.fn(),
initialize: jest.fn(),
updateUser: jest.fn(),
}));
const MockedFeatureGates = jest.mocked(FeatureGates);
describe('with successful initialization', () => {
beforeEach(() => {
MockedFeatureGates.initializeCalled.mockReturnValue(true);
MockedFeatureGates.initialize.mockResolvedValue();
MockedFeatureGates.updateUser.mockResolvedValue();
});
afterEach(() => jest.resetAllMocks());
});
describe('with failed initialization', () => {
beforeEach(() => {
MockedFeatureGates.initializeCalled.mockReturnValue(true);
MockedFeatureGates.initialize.mockRejectedValue();
MockedFeatureGates.updateUser.mockRejectedValue();
});
afterEach(() => jest.resetAllMocks());
});
describe('with pending initialization', () => {
let resolveInitPromise;
let rejectInitPromise;
beforeEach(() => {
const initPromise = new Promise((resolve, reject) => {
resolveInitPromise = resolve;
rejectInitPromise = reject;
});
MockedFeatureGates.initializeCalled.mockReturnValue(true);
MockedFeatureGates.initialize.mockReturnValue(initPromise);
MockedFeatureGates.updateUser.mockReturnValue(initPromise);
});
afterEach(() => jest.resetAllMocks());
});
Overriding values
There are two ways that you can override values in Jest tests:
- Using mocks
- Using the built-in override methods
Using mocks
import FeatureGates, { DynamicConfig, EvaluationReason } from '@atlaskit/feature-gate-js-client';
jest.mock('@atlaskit/feature-gate-js-client', () => ({
...jest.requireActual('@atlaskit/feature-gate-js-client'),
getExperiment: jest.fn(),
checkGate: jest.fn(),
}));
const MockedFeatureGates = jest.mocked(FeatureGates);
describe('with mocked experiments and gates', () => {
beforeEach(() => {
const overrides = {
configs: {
'example-experiment': {
cohort: 'variation',
},
},
gates: {
'example-gate': true,
},
};
MockedFeatureGates.getExperiment.mockImplementation((experimentName) => {
const values = overrides.configs[experimentName] || {};
return new DynamicConfig(experimentName, values, {
time: Date.now(),
reason: EvaluationReason.LocalOverride,
});
});
MockedFeatureGates.checkGate.mockImplementation((gateName, defaultValue) => {
return overrides.gates[gateName] || defaultValue;
});
});
afterEach(() => jest.resetAllMocks());
});
Using overrides methods
import FeatureGates, { FeatureGateEnvironment } from '@atlaskit/feature-gate-js-client';
describe('with overridden gates and experiments', () => {
beforeAll(async () => {
await FeatureGates.initializeWithValues(
{
environment: FeatureGateEnvironment.Development,
sdkKey: 'client-default-key',
localMode: true,
},
{},
{},
);
});
beforeEach(() => {
const overrides = {
configs: {
'example-experiment': {
cohort: 'variation',
},
},
gates: {
'example-gate': true,
},
};
FeatureGates.setOverrides(overrides);
});
afterEach(() => FeatureGates.clearAllOverrides());
});
Cypress
Overriding values
The .visit
command in Cypress creates a new window with its own instance of FeatureGates, so you
will not be able to simply import the module and apply stubs to it.
import FeatureGates, { LocalOverrides } from '@atlaskit/feature-gate-js-client';
const overrides: LocalOverrides = {
configs: {
'example-experiment': {
cohort: 'variation',
},
},
gates: {
'example-gate': true,
},
};
cy.stub(
FeatureGates,
'checkGate',
(gateName, defaultValue) => overrides.gates[gateName] || defaultValue,
);
cy.stub(FeatureGates, 'getExperiment', (experimentName) => {
const values = overrides.configs[experimentName] || {};
return new DynamicConfig(experimentName, values, {
time: Date.now(),
reason: EvaluationReason.LocalOverride,
});
});
cy.visit('http://localhost:3000/');
cy.get('#test-feature').dblclick();
Instead, you will need to obtain a reference to the client that exists on the generated window, and
apply your overrides to that instead.
We have exposed a window.__FEATUREGATES_JS__
variable which will contain the instance attached to
the window.
import { LocalOverrides } from '@atlaskit/feature-gate-js-client';
const overrides: LocalOverrides = {
configs: {
'example-experiment': {
cohort: 'variation',
},
},
gates: {
'example-gate': true,
},
};
cy.visit('http://localhost:3001', {
onLoad: (contentWindow) => {
const FeatureGates = contentWindow.__FEATUREGATES_JS__;
cy.stub(
FeatureGates,
'checkGate',
(gateName, defaultValue) => overrides.gates[gateName] || defaultValue,
);
cy.stub(FeatureGates, 'getExperiment', (experimentName) => {
const values = overrides.configs[experimentName] || {};
return new DynamicConfig(experimentName, values, {
time: Date.now(),
reason: EvaluationReason.LocalOverride,
});
});
},
});
You can also set up your own custom command which listens to the next window:load
event to do the
same thing, which you can invoke before any page visit:
import { LocalOverrides } from '@atlaskit/feature-gate-js-client';
Cypress.Commands.add('featureGateOverrides', (overrides: LocalOverrides) => {
cy.once('window:load', (contentWindow) => {
const FeatureGates = contentWindow.__FEATUREGATES_JS__;
cy.stub(
FeatureGates,
'checkGate',
(gateName, defaultValue) => overrides?.gates?.[gateName] || defaultValue,
);
cy.stub(FeatureGates, 'getExperiment', (experimentName) => {
const values = overrides?.configs?.[experimentName] || {};
return new DynamicConfig(experimentName, values, {
time: Date.now(),
reason: EvaluationReason.LocalOverride,
});
});
});
});
cy.featureGateOverrides({
configs: {
'example-experiment': {
cohort: 'variation',
},
},
gates: {
'example-gate': true,
},
}).visit('http://localhost:3001');
Storybook
Overriding values
Storybook does not have any mocking or stubbing APIs, but you can use the override*
and
setOverrides
methods on this client as a replacement.
Please note that the client must be initialized before these methods can be called, and that the
overrides will need to be cleared after each storybook is unmounted, since they are persisted to
localStorage.
The easiest way to get set up is to use the FeatureGatesInitializationWithDefaults
component in
our React SDK (@atlassian/feature-gates-react
) with the overrides
prop set, since this manages
the initialization and clean-up for you. Please see the
component documentation for
more information.
Development
How do I get set up?
- Summary of set up
- This repo package uses yarn
- Run
yarn install
in the root directory to set up your git hooks. - In order to get started run
yarn
to install the dependencies
- How to run tests
- In order to run all tests simply run
yarn test packages/measurement/feature-gate-js-client
from the platform directory - In order to run jest tests in watch mode while doing development run
yarn test:jest --watch
- NOTE: You may need to run
yarn build @atlaskit/feature-gate-js-client
to create a version.ts
file thats required for some tests
Contribution guidelines
- All new logic must be tested
- There is no need to test direct pass through of Statsig APIs
- Transformation of arguments counts as new logic
- Code review
- Changes must go through a pull request to be merged
- Other guidelines
Releasing
This package is part of the AFP monorepo. Create a changeset using yarn changeset
and commit.
Documentation
Who do I talk to?
This repo is owned by the experimentation platform team, reach out to !disturbed in
#help-switcheroo-statsig if you need
a hand.