Featurevisor

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

Command
npm install @featurevisor/sdk

Create an instance

Fetch a datafile and create an instance with it:

your-app/index.js
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:

OptionSets
datafileThe datafile to evaluate
contextThe instance context
stickyFeatures, stickyVariablesSticky values
logLevel, onDiagnosticDiagnostics
modulesModules

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 | null

Get 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.

MethodReturns
getVariableBooleanboolean
getVariableStringstring
getVariableIntegernumber, an integer
getVariableNumbernumber, 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:

  1. A sticky value for the whole variable, which skips everything after it.
  2. Root conditions. When they miss, no source is read and no override runs. The result is disabledValue when supplied, otherwise null, with reason conditions_unmet.
  3. The sources, with their own sticky values, the context and modules taking part as usual.
  4. 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',
])
Result
{
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',
])
Result
{
supportEmail: '[email protected]',
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, and getVariableKeys('buyButton') lists that feature's variables.
  • hasVariations('pricing') is true when a feature has variations, and false for 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 server
const child = f.spawn({ userId: '8f2a' })
const stickyFeatures = child.getFeatureEvaluations()
const stickyVariables = child.getVariableEvaluations()
child.close()
// in the browser
const 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: {
supportEmail: '[email protected]',
},
})

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 revision and schemaVersion are 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 * 1000
let refreshing = false
const 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, on and close.
  • 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:

Result, trimmed
{
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 it
  • bucketKey and bucketValue, from 0 to 100,000, and the ruleKey that matched
  • enabled, variation and variationValue
  • variableKey, variableValue and variableSchema
  • variableOverrideKey and variableOverrideIndex when a keyed override matched, and variableOverridePath for a nested one
  • variableOverridePaths, every override path matched in order, for accumulating overrides. The singular fields describe the last match
  • conditions when a root gate stopped the evaluation
  • error when evaluation failed

Evaluation reasons

Test specs can assert them, with expectedEvaluation.reason in a variable spec and expectedEvaluations.flag.reason in a feature spec.

ReasonMeaning
feature_not_foundThe feature is not in the datafile
disabledThe feature is disabled
no_variationsA variation was requested from a feature without variations
variation_disabledA disabled feature returned its disabledVariationValue
variable_not_foundThe feature variable or global variable is not in the datafile
variable_defaultThe variable returned its default value
variable_composedA global variable read its composition sources with no override match
variable_disabledA disabled feature returned the variable's disabled value
variable_override_variationA feature variable override matched inside a variation
variable_override_ruleA feature variable override or global variable override matched
conditions_unmetRoot conditions did not match. See evaluation.conditions.
no_matchNo rule, allocation or variation matched, or the bucket fell outside the rollout
forcedA force entry supplied the result
stickyA sticky value supplied the result
ruleA matching rule supplied the result
allocatedBucketing allocated the result
errorEvaluation 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)
// later
await 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()
Previous
SDK overview