Evaluate in your application
JavaScript SDK
@featurevisor/sdk evaluates flags, variations and variables in Node.js and browsers, from a datafile it holds in memory.
Install#
npm install @featurevisor/sdkCreate an instance#
Fetch a datafile and create an instance with it:
import { createFeaturevisor } from '@featurevisor/sdk'const datafileUrl = 'https://cdn.example.com/featurevisor-all.json'const response = await fetch(datafileUrl)if (!response.ok) throw new Error(`HTTP ${response.status}`)const datafileContent = await response.json()const f = createFeaturevisor({ datafile: datafileContent,})datafile takes parsed content or a JSON string. If TypeScript infers a JSON import's types more broadly than the SDK's datafile type, pass JSON.stringify(datafile).
Every option is optional, so an instance can start without a datafile and receive one later:
| Option | Sets |
|---|---|
datafile | The datafile to evaluate |
context | The instance context |
stickyFeatures, stickyVariables | Sticky values |
logLevel, onDiagnostic | Diagnostics |
modules | Modules |
Create one instance and reuse it. On a server that handles many users, give each request a child instance.
TypeScript types#
Types come from the same package:
import { createFeaturevisor, type Featurevisor, type FeaturevisorOptions, type FeaturevisorModule, type FeaturevisorDiagnostic,} from '@featurevisor/sdk'The datafile types are exported too. Conditions accepts one Condition, an array or '*'. ConditionLeaf is an AttributeCondition, FeatureCondition, VariableCondition or segment key string. Condition adds recursive groups. PlainCondition remains a deprecated alias of AttributeCondition. Legacy datafile selector types are for reading old files, not authoring new definitions. For keys, variation values and variable types that match your project, use code generation.
Context#
Context is a plain object of attribute values. The instance keeps a context of its own, and each evaluation can add to it.
Set it when creating the instance:
const f = createFeaturevisor({ datafile: datafileContent, context: { country: 'nl', },})Add to it later with setContext(), or replace it by passing true as the second argument:
f.setContext({ userId: '8f2a',})f.setContext( { userId: 'c4e1', country: 'nl', }, true,)Context passed to an evaluation is merged over the instance context for that call only:
const context = { userId: '8f2a', country: 'nl',}f.isEnabled('checkout', context)getContext() returns the instance context, and getContext({ country: 'de' }) shows it merged with extra values without storing them. The object getContext() returns is the stored one, not a copy. Change it only through setContext(), so events and evaluations stay in step.
Check a flag#
isEnabled() reads a flag. Dutch users have the new checkout:
const context = { userId: '8f2a', country: 'nl',}if (f.isEnabled('checkout', context)) { // show the new checkout}Get a variation#
getVariation() returns the variation of a feature, or null if the feature is disabled or has none:
const variation = f.getVariation('pricing', context)if (variation === 'treatment') { // show the new pricing}For 8f2a it returns 'control', and for c4e1 it returns 'treatment'. In TypeScript, name the values the result can take:
type PricingVariation = 'control' | 'treatment'const variation = f.getVariation<PricingVariation>('pricing', context)// PricingVariation | nullGet a variable#
getVariable() reads a feature variable with a feature key and a variable key. For 8f2a, the netherlands rule sets the buy button's label to 'Nu kopen':
const label = f.getVariable('buyButton', 'label', context)With a context as its second argument, it reads a global variable instead. supportEmail returns '[email protected]' for 8f2a:
const email = f.getVariable('supportEmail', context)A string second argument means a feature variable, and an object means context for a global variable. The typed methods below follow the same rule.
Treat returned objects and arrays as read only. The SDK can share them with its datafile rather than copy them on every evaluation, so copy a value before changing it, and never change a datafile object after passing it to the instance.
Typed methods#
Each typed method returns null when the value has a different type, and never converts it. getVariableInteger() returns null for the string '1', and getVariableBoolean() returns null for 'true'.
getVariableNumber() reads a variable of type number, and also double in datafiles built before v4.
| Method | Returns |
|---|---|
getVariableBoolean | boolean |
getVariableString | string |
getVariableInteger | number, an integer |
getVariableNumber | number, whole or fractional |
getVariableArray<T> | T[], or unknown[] by default |
getVariableObject<T> | T, or ObjectValue by default |
getVariableJSON<T> | T, or VariableValue by default |
type CheckoutSettings = { currency: string paymentMethods: string[]}const settings = f.getVariableObject<CheckoutSettings>( 'checkoutSettings', context,)// { currency: 'EUR', paymentMethods: ['card'] }A type argument on getVariable<T>() or a typed method describes what you expect. It does not check the value at runtime. Without one, getVariable() returns VariableValue | null.
Composed variables#
Read a composed global variable like any other global variable. For a string compose alias, choose the getter for the source's type, such as getVariableString for an alias of supportEmail, rather than getVariableObject. It returns what the source's own getter returns, including decoded JSON and null, with no wrapper or copy. Alias overrides start from that value.
A composition is evaluated in this order:
- A sticky value for the whole variable, which skips everything after it.
- Root
conditions. When they miss, no source is read and no override runs. The result isdisabledValuewhen supplied, otherwisenull, with reasonconditions_unmet. - The sources, with their own sticky values, the context and modules taking part as usual.
- The overrides, which run as they do for any global variable.
Compositions have no defaultValue or useDefaultWhenDisabled. With no override match, the reason is variable_composed. A match reports variable_override_rule, with variableOverrideKey, variableOverridePath and the ordered variableOverridePaths. That reason records a match, not a changed value: a nested mutation through null follows the usual missing path behaviour and leaves the value unchanged. See result ownership and nullable members before changing or using the result.
Default values#
Pass defaultVariationValue or defaultVariableValue for when an evaluation produces no value, such as a feature missing from the datafile:
const label = f.getVariable( 'buyButton', 'label', context, { defaultVariableValue: 'Buy' },)A default counts as given whenever the option is present, so '', 0, false and null are all valid defaults.
Evaluate everything at once#
getFeatureEvaluations() evaluates every loaded feature, or only the keys you pass:
f.getFeatureEvaluations(context, [ 'checkout', 'pricing', 'buyButton',]){ checkout: { enabled: true }, pricing: { enabled: true, variation: 'control' }, buyButton: { enabled: true, variation: 'control', variables: { label: 'Nu kopen', colour: '#111827' }, },}getVariableEvaluations() does the same for global variables:
f.getVariableEvaluations(context, [ 'supportEmail', 'checkoutSettings',]){ checkoutSettings: { currency: 'EUR', paymentMethods: ['card'], },}To see what is loaded without evaluating anything:
getFeatureKeys()lists every feature in the datafile, enabled or not.getVariableKeys()lists global variables, andgetVariableKeys('buyButton')lists that feature's variables.hasVariations('pricing')istruewhen a feature has variations, andfalsefor an unknown feature.
Hand results from server to client#
Both results have the shape sticky values take. A server can evaluate once per request and pass the results to the browser, so the first render matches what the server saw:
// on the serverconst child = f.spawn({ userId: '8f2a' })const stickyFeatures = child.getFeatureEvaluations()const stickyVariables = child.getVariableEvaluations()child.close()// in the browserconst clientF = createFeaturevisor({ stickyFeatures, stickyVariables,})The browser can load a datafile afterwards, and release the sticky values when it is ready to evaluate live.
Sticky values#
Sticky values take precedence over the datafile. Use them to keep a user's result fixed, to answer before a datafile has loaded, or in unit tests:
const f = createFeaturevisor({ stickyFeatures: { checkout: { enabled: true }, pricing: { enabled: true, variation: 'treatment', }, }, stickyVariables: { },})A sticky feature can also set variables. Change sticky values later with setStickyFeatures() and setStickyVariables(). Both merge by default, and replace the whole map when the second argument is true:
f.setStickyFeatures( { checkout: { enabled: false }, }, true,)Treat sticky maps as read only once given to an instance, and change them only through these methods.
Update the datafile#
setDatafile() gives the instance a newer datafile:
f.setDatafile(datafileContent)It returns true when the datafile is accepted, even if nothing changed, and false when it is rejected or the instance is closed. A rejected datafile leaves the previous one in place and is reported through diagnostics.
Merge or replace#
By default, the new datafile is merged into the current one:
- Its features, segments and global variables replace those with the same keys.
- Definitions it does not contain are kept.
- Its
revisionandschemaVersionare taken.
Pass true to replace the current datafile entirely. Do this when refreshing a complete datafile, so definitions removed from it do not linger:
f.setDatafile(datafileContent, true)Merging is what lets one instance hold datafiles for several targets, loaded as each part of the application needs them. See Loading datafiles on demand.
Refresh on an interval#
Fetch the datafile again every few minutes, skipping a tick while a fetch is still running:
const interval = 5 * 60 * 1000let refreshing = falseconst refreshTimer = setInterval(async () => { if (refreshing) return refreshing = true try { const response = await fetch(datafileUrl) if (!response.ok) throw new Error(`HTTP ${response.status}`) const datafileContent = await response.json() f.setDatafile(datafileContent, true) } catch (error) { console.error('Could not refresh datafile:', error) } finally { refreshing = false }}, interval)A failed fetch keeps the previous datafile. setDatafile() does not throw for an invalid datafile, it reports it through diagnostics. Set a timeout on the fetch, and call clearInterval(refreshTimer) when you stop, since close() does not cancel your timers or requests.
You can also fetch when something tells you to, such as a message over a WebSocket. Deployment compares the options. Each accepted update emits datafile_set.
Child instances#
A server handles many users at once, so give each request a child with its own context:
const child = f.spawn({ userId: '8f2a', country: 'nl',})child.isEnabled('checkout')- The child copies the parent's context keys when it is spawned, and its own values win. Keys the parent adds later still reach it.
- Sticky values are not inherited. Pass them as the second argument when the child needs some.
- A child has the parent's evaluation methods, plus
getContext,setContext,setStickyFeatures,setStickyVariables,onandclose. close()removes the child's listeners, including the ones it registered on its parent.
const child = f.spawn( { userId: '8f2a' }, { stickyFeatures: { checkout: { enabled: true }, }, },)Events#
on() subscribes to an event and returns a function that unsubscribes.
datafile_set#
Emitted for every accepted datafile:
const unsubscribe = f.on('datafile_set', (event) => { const { revision, previousRevision, revisionChanged, replaced, features, variables, } = event // evaluate the listed features and variables again})unsubscribe()replaced says whether the datafile replaced the previous one or merged into it. features and variables list the keys whose results may have changed: those added, updated or removed, and those that depend on them through conditions, including references reached through segments, however indirectly. Evaluate those again and you have everything that changed.
context_set#
f.on('context_set', ({ context, replaced }) => { // context is the new instance context})sticky_features_set and sticky_variables_set#
f.on('sticky_features_set', ({ features, replaced }) => { // features lists the affected feature keys})f.on('sticky_variables_set', ({ variables, replaced }) => { // variables lists the affected global variable keys})error#
f.on('error', ({ diagnostic }) => { console.error(diagnostic.message, diagnostic)})Diagnostics#
The SDK reports what happens during evaluation as diagnostics, at one of five levels: fatal, error, warn, info and debug. By default, those at info and above go to the console with a [Featurevisor] prefix.
debug shows how each value was evaluated, which helps when a result surprises you. Set the level when creating the instance, or later:
const f = createFeaturevisor({ logLevel: 'debug',})f.setLogLevel('warn')To send diagnostics somewhere other than the console, pass a handler:
const f = createFeaturevisor({ onDiagnostic: (diagnostic) => { const { level, code, message, details } = diagnostic // send to your observability system },})Every diagnostic has level, code, message and a details object. Values about an evaluation, such as featureKey, variableKey, reason and evaluation, are in details. module, moduleName and originalError say where an error came from, when known. A handler that throws is reported to the console, and evaluation carries on.
Evaluation errors#
conditions_parse_error is an error diagnostic for malformed stringified selector JSON. It fires once per selector per datafile load, on first read for v4 datafiles and during setDatafile() for v3 datafiles. Its details contain conditions with the raw string, entityType as 'feature', 'segment' or 'variable', and entityKey.
Replacing an entity, either in a full replacement or a partial merge, allows its diagnostic to fire again. Retained entities do not repeat it.
The whole malformed selector evaluates to false, even under not. A not group cannot turn a parse failure into a match, including when it references a malformed segment. Other entities keep working and setDatafile() returns true for an otherwise valid datafile. A malformed root gate is unmet and reports conditions_unmet. Invalid datafile JSON or a failed datafile shape check still rejects the update. A closed instance also rejects updates.
Dependency cycles fail the whole evaluation with reason error. By default, SDK evaluation errors return the ordinary false or null result and other entities keep working. not cannot invert a dependency error into a match.
Evaluation details#
The evaluate methods return how a value was reached, not only the value:
f.evaluateFlag('checkout', context)f.evaluateVariation('pricing', context)f.evaluateVariable('buyButton', 'label', context)f.evaluateVariable('supportEmail', context)For 8f2a, evaluateFlag returns:
{ type: 'flag', featureKey: 'checkout', reason: 'rule', bucketKey: '8f2a.checkout', bucketValue: 87615, ruleKey: 'netherlands', enabled: true,}Every evaluation has a type and a reason. Others appear when they apply:
featureKey, absent for global variables, so check for it before using itbucketKeyandbucketValue, from 0 to 100,000, and theruleKeythat matchedenabled,variationandvariationValuevariableKey,variableValueandvariableSchemavariableOverrideKeyandvariableOverrideIndexwhen a keyed override matched, andvariableOverridePathfor a nested onevariableOverridePaths, every override path matched in order, for accumulating overrides. The singular fields describe the last matchconditionswhen a root gate stopped the evaluationerrorwhen evaluation failed
Evaluation reasons#
Test specs can assert them, with expectedEvaluation.reason in a variable spec and expectedEvaluations.flag.reason in a feature spec.
| Reason | Meaning |
|---|---|
feature_not_found | The feature is not in the datafile |
disabled | The feature is disabled |
no_variations | A variation was requested from a feature without variations |
variation_disabled | A disabled feature returned its disabledVariationValue |
variable_not_found | The feature variable or global variable is not in the datafile |
variable_default | The variable returned its default value |
variable_composed | A global variable read its composition sources with no override match |
variable_disabled | A disabled feature returned the variable's disabled value |
variable_override_variation | A feature variable override matched inside a variation |
variable_override_rule | A feature variable override or global variable override matched |
conditions_unmet | Root conditions did not match. See evaluation.conditions. |
no_match | No rule, allocation or variation matched, or the bucket fell outside the rollout |
forced | A force entry supplied the result |
sticky | A sticky value supplied the result |
rule | A matching rule supplied the result |
allocated | Bucketing allocated the result |
error | Evaluation failed. See error |
Modules#
A module hooks into evaluation. It can change the context before an evaluation, change the result after it, or decide how users are bucketed. Every part is optional:
import type { FeaturevisorModule } from '@featurevisor/sdk'const auditModule: FeaturevisorModule = { name: 'audit', setup({ getRevision, onDiagnostic, reportDiagnostic }) { onDiagnostic( (diagnostic) => { // diagnostics at this module's own level }, { logLevel: 'warn' }, ) reportDiagnostic({ level: 'info', code: 'module_ready', message: `Ready for revision ${getRevision()}`, }) }, beforeEvaluation(options) { // type, featureKey, variableKey and context options.context = { ...options.context, plan: 'paid', } return options }, afterEvaluation(evaluation, options) { if (evaluation.reason === 'error') { // log the error } return evaluation }, bucketKey({ featureKey, context, bucketBy, bucketKey }) { return bucketKey }, bucketValue({ featureKey, context, bucketKey, bucketValue }) { // between 0 and 100,000 return bucketValue }, close() { // clean up when the instance closes },}beforeEvaluation and afterEvaluation run for flags, variations and both kinds of variable. A variable evaluation without a featureKey is a global one.
The before evaluation and after evaluation phases each use the module membership at the start of that phase. Where an evaluation uses bucketing, the bucket key and bucket value phases each use their own starting membership. Adding or removing a module during a phase does not change the iteration already in progress. The next phase uses the membership then installed. A module added before the after phase can receive after callbacks for evaluations already in progress, even if it did not receive their before callbacks. A module removed before the after phase receives no after callback.
Return a value from evaluation callbacks
beforeEvaluation must return the options and afterEvaluation the evaluation, changed or not. Returning undefined does not skip the callback. It replaces the value, and an afterEvaluation that returns undefined can make every flag disabled and every variation null without an error.
If setup throws, the module is not registered. The SDK removes the diagnostic subscriptions it made, reports module_setup_error, and calls its close if it has one.
Dependency evaluations#
Conditions and composition members are evaluated through modules too. Their options include dependencyOf, naming the requesting evaluation by type, featureKey and variableKey. dependencyPurpose is 'condition' for an eligibility check and 'composition' for a consumed member value, string aliases included. Direct calls have neither marker.
When tracking consumed evaluations, skip eligibility checks, not every read with dependencyOf. A variation read through a composition can be an experiment exposure:
const tracking: FeaturevisorModule = { name: 'tracking', afterEvaluation(evaluation, options) { if (options.dependencyPurpose === 'condition') return evaluation trackEvaluation(evaluation) return evaluation },}Choose which successful feature evaluations your tracker records. The root composed variable is not itself an experiment exposure. Within one root call, repeated reads with the same inputs and purpose are memoized, so callbacks can run fewer times than there are aliases. Do not count callbacks as reads by your application.
Before grouping experiments, review the composition exposure guidance and composition warnings.
Return the same result for the same input during one evaluation, and pass values that change through the context. State captured outside the SDK is not tracked as an input. SDK setters in callbacks can change what later members observe, so composition is not an atomic snapshot. See variable conditions for how values are matched.
Register a module#
Pass modules when creating the instance, or add them later:
const f = createFeaturevisor({ modules: [auditModule],})const remove = f.addModule(tracking)// laterawait remove()await f.removeModule('audit')Close#
close() removes event listeners and closes modules. It returns a promise that resolves once every module's close has finished:
await f.close()
