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 two 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 } from '@atlaskit/feature-gate-js-client';
try {
await FeatureGates.initialize(
{
apiKey: 'client-test',
environment: FeatureGateEnvironment.Production,
targetApp: 'jira_web',
fetchTimeoutMs: 1000,
useGatewayUrl: true,
perimeter: '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 } 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()
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.
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
- 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
to create a version.ts file thats required for some tests
- Deployment instructions
- run
yarn build
- this should create a dist directory
- there are two tsconfig files, one for the dev loop and some additional settings for the final dist build
- run
yarn publish
- the "new version" in the CLI is the version that will be published
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
Before you release please ensure that the appropriate CHANGELOG.md
entry is in place for your changes.
If your change is minor
or major
change in terms of semantic versioning then you will need to manually update the version number in the package.json.
In order to release a new version of this package you will need to use Bitbucket Pipelines.
Once your change has been deployed to the main
branch, you can run the release-js-client
custom pipeline to deploy it to Artifactory.
Hit "Run pipeline", and select the following:
Branch
: mainPipeline
: custom: release-js-client
After a release the version will automatically be incremented by one patch version on the main branch.
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.