(statsig-adapter): Release Statsig Adapter (#43)

* (statsig-adapter): Move Edge Config to peer deps, more consistent api key naming

* Move dynamic imports into initializeStatsig

* Add default adapter pattern

* Add changeset

* Add layerParameter to get config parameters from experiments in a layer

* (statsig-adapter) Focus on feature management (gates, dynamic configs), add origin, add exposure logging option

* (statsig-adapter) Update key handling and default export

* (refactoring, wip): move some runtime details out of the main implementation of the Statsig adapter

* Use statsig-node-lite

* Switch default environment variable to STATSIG_SERVER_SECRET"

* (statsig-adapter) Add readme

* Update README — Remove details on precompute, add optional peer deps to install command

* Add peerDependenciesMeta

* Update changeset text

* Refactor Statsig Adapter

Add experimentation primitives, disable exposure logging by default, identify StatsigUser instead of `{ statsigUser: StatsigUser }`

* (statsig-adapter) Update default environment variables

* use envvar: EXPERIMENTATION_CONFIG_ITEM_KEY

* Update packages/adapter-statsig/README.md

Co-authored-by: Dominik Ferber <dominik.ferber@gmail.com>

* Update packages/adapter-statsig/README.md

Co-authored-by: Dominik Ferber <dominik.ferber@gmail.com>

* Update README, docstrings

* upgrade statsig-node-lite

* remove edge config devDep

* reduce propagation time of Statsig updates

* update deps

* update lockfile

* prefix error

* simplify README

* extend package.json

* fix type

* switch example to gate

* expose types of Statsig SDK

* work around syncing issue

---------

Co-authored-by: Dominik Ferber <dominik.ferber@gmail.com>
This commit is contained in:
Aaron Morris
2025-02-26 10:03:57 -05:00
committed by GitHub
parent f030fc5fdd
commit bd7e10aa39
8 changed files with 1503 additions and 1144 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@flags-sdk/statsig': minor
---
Initial support for Feature Gates and Dynamic Configs
+35
View File
@@ -0,0 +1,35 @@
# Flags SDK — Statsig Provider
The [Statsig provider](https://flags-sdk.dev/docs/api-reference/adapters/statsig) for the [Flags SDK](https://flags-sdk.dev/) contains support for Statsig's Feature Gates, Dynamic Config, Experiments, Autotune and Layers.
## Setup
The Statsig provider is available in the `@flags-sdk/statsig` module. You can install it with
```bash
pnpm i @flags-sdk/statsig
```
## Provider Instance
You can import the default adapter instance `statigAdapter` from `@flags-sdk/statsig`:
```ts
import { statsigAdapter } from '@flags-sdk/statsig';
```
## Example
```ts
import { flag } from 'flags/next';
import { statsigAdapter } from '@flags-sdk/statsig';
export const marketingGate = flag<boolean>({
key: 'marketing_gate',
adapter: statsigAdapter.featureGate((config) => config.value),
});
```
## Documentation
Please check out the [Statsig provider documentation](https://flags-sdk.dev/docs/api-reference/adapters/statsig) for more information.
+23 -10
View File
@@ -1,10 +1,26 @@
{
"name": "@flags-sdk/statsig",
"version": "0.1.0",
"description": "",
"keywords": [],
"description": "Statsig provider for the Flags SDK",
"keywords": [
"flags-sdk",
"statsig",
"dynamic config",
"vercel",
"edge config",
"experiments",
"experimentation",
"ab testing"
],
"homepage": "https://flags-sdk.dev/docs",
"bugs": {
"url": "https://github.com/vercel/flags/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/vercel/flags.git"
},
"license": "MIT",
"author": "",
"sideEffects": false,
"type": "module",
"exports": {
@@ -47,26 +63,23 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@vercel/edge-config": "1.2.0",
"statsig-node": "5.31.0",
"statsig-node-vercel": "0.4.0"
"@vercel/edge-config": "^1.2.0",
"@vercel/functions": "^1.5.2",
"statsig-node-lite": "^0.4.2",
"statsig-node-vercel": "^0.7.0"
},
"devDependencies": {
"@types/node": "20.11.17",
"@vercel/edge-config": "1.2.0",
"eslint-config-custom": "workspace:*",
"flags": "workspace:*",
"msw": "2.6.4",
"rimraf": "6.0.1",
"statsig-node": "5.31.0",
"statsig-node-vercel": "0.4.0",
"tsconfig": "workspace:*",
"tsup": "8.0.1",
"typescript": "5.6.3",
"vite": "5.1.1",
"vitest": "1.4.0"
},
"peerDependencies": {},
"publishConfig": {
"access": "public"
}
@@ -0,0 +1,73 @@
import Statsig from 'statsig-node-lite';
declare global {
var EdgeRuntime: string | undefined;
}
export const isEdgeRuntime = (): boolean => {
return EdgeRuntime !== undefined;
};
/**
* The Edge Config Data Adapter is an optional peer dependency that allows
* the Statsig SDK to retrieve its data from Edge Config instead of over the network.
*/
export async function createEdgeConfigDataAdapter(options: {
edgeConfigItemKey: string;
edgeConfigConnectionString: string;
}) {
// Edge Config adapter requires `@vercel/edge-config` and `statsig-node-vercel`
// Since it is a peer dependency, we will import it dynamically
const { EdgeConfigDataAdapter } = await import('statsig-node-vercel');
const { createClient } = await import('@vercel/edge-config');
return new EdgeConfigDataAdapter({
edgeConfigItemKey: options.edgeConfigItemKey,
edgeConfigClient: createClient(options.edgeConfigConnectionString, {
// We disable the development cache as Statsig caches for 10 seconds internally,
// and we want to avoid situations where Statsig tries to read the latest value,
// but hits the development cache and then caches the outdated value for another 10 seconds,
// as this would lead to the developer having to wait 20 seconds to see the latest value.
disableDevelopmentCache: true,
}),
});
}
/**
* Edge runtime does not support timers outside of a request context.
*
* Statsig syncs config specs outside of the request context,
* so we will support it in triggering config spec synchronization in this case.
*/
export const createSyncingHandler = (): null | (() => void) => {
// Syncing both in Edge Runtime and Node.js for now, as the sync is otherwise
// not working during local development.
//
// This needs to be fixed in statsig-node-lite in the future.
//
// Ideally the Statsig SDK would not sync at all and instead always read from Edge Config,
// this would provide two benefits:
// - changes would propagate immediately instead of being cached for 5s or 10s
// - the broken syncing due to issues in Date.now in Edge Runtime would be irrelevant
//
// if (typeof EdgeRuntime === 'undefined') return null;
const timerInterval = 5_000;
let isSyncingConfigSpecs = false;
let nextConfigSpecSyncTime = Date.now() + timerInterval;
return (): void => {
if (Date.now() >= nextConfigSpecSyncTime && !isSyncingConfigSpecs) {
try {
isSyncingConfigSpecs = true;
const sync = Statsig.syncConfigSpecs().finally(() => {
isSyncingConfigSpecs = false;
nextConfigSpecSyncTime = Date.now() + timerInterval;
});
import('@vercel/functions').then(({ waitUntil }) => {
waitUntil(sync);
});
} catch (e) {
// continue
}
}
};
};
+274 -87
View File
@@ -1,50 +1,80 @@
export { getProviderData } from './provider';
import { Adapter } from 'flags';
import Statsig, { type StatsigUser, type StatsigOptions } from 'statsig-node';
import { EdgeConfigDataAdapter } from 'statsig-node-vercel';
import { createClient } from '@vercel/edge-config';
import type { Adapter } from 'flags';
import Statsig, {
type StatsigUser,
type StatsigOptions,
type DynamicConfig,
type Layer,
} from 'statsig-node-lite';
import {
createEdgeConfigDataAdapter,
createSyncingHandler,
} from './edge-runtime-hooks';
interface StatsigUserEntities {
statsigUser: StatsigUser;
}
// Export the Statsig instance
export {
Statsig,
type StatsigUser,
type StatsigOptions,
type DynamicConfig,
type Layer,
};
export type FeatureGate = ReturnType<
typeof Statsig.getFeatureGateWithExposureLoggingDisabledSync
>;
type AdapterFunction<O> = <T>(
getValue: (obj: O) => T,
opts?: { exposureLogging?: boolean },
) => Adapter<T, StatsigUser>;
type AdapterResponse = {
featureGate: AdapterFunction<FeatureGate>;
dynamicConfig: AdapterFunction<DynamicConfig>;
experiment: AdapterFunction<DynamicConfig>;
autotune: AdapterFunction<DynamicConfig>;
layer: AdapterFunction<Layer>;
initialize: () => Promise<typeof Statsig>;
};
/**
* Create a Statsig adapter for use with the Flags SDK.
*
* The adapter expects to use `statsig-node` and `@vercel/edge-config` to resolve flags via
* Statsig Feature Gates, Experiments, DynamicConfigs, Autotunes, and so on.
*
* It will initialize Statsig and resolve values, and will not log exposures. Exposures
* should be logged on the client side to prevent prefetching or middleware from accidentally
* triggering exposures when the user has not engaged with a page yet.
*
* Methods:
* - `.()` - Checks a feature gate and returns a boolean value
* - `.featureGate()` - Checks a feature gate and returns a value based on the result and rule ID
* - `.experiment()` - Checks an experiment and returns a value based on the result and rule ID
* Can be used to define flags that are powered by Statsig's Feature Management
* products including Feature Gates and Dynamic Configs.
*/
function createStatsigAdapter(options: {
statsigSecretKey: string;
export function createStatsigAdapter(options: {
/** The Statsig server API key */
statsigServerApiKey: string;
/** Optionally override Statsig initialization options */
statsigOptions?: StatsigOptions;
/** Provide the project ID to allow links to the Statsig console in the Vercel Toolbar */
statsigProjectId?: string;
/** Provide Edge Config details to use the optional Edge Config adapter */
edgeConfig?: {
connectionString: string;
itemKey: string;
};
}) {
const dataAdapter = options.edgeConfig
? new EdgeConfigDataAdapter({
edgeConfigItemKey: options.edgeConfig.itemKey,
edgeConfigClient: createClient(options.edgeConfig.connectionString),
})
: undefined;
}): AdapterResponse {
const initializeStatsig = async (): Promise<void> => {
await Statsig.initialize(options.statsigSecretKey, {
let dataAdapter: StatsigOptions['dataAdapter'] | undefined;
if (options.edgeConfig) {
dataAdapter = await createEdgeConfigDataAdapter({
edgeConfigItemKey: options.edgeConfig.itemKey,
edgeConfigConnectionString: options.edgeConfig.connectionString,
});
}
await Statsig.initialize(options.statsigServerApiKey, {
dataAdapter,
// ID list syncing is disabled by default
// Can be opted in using `options.statsigOptions`
initStrategyForIDLists: 'none',
disableIdListsSync: true,
// Set a shorter interval during development so developers see changes earlier
rulesetsSyncIntervalMs:
process.env.NODE_ENV === 'development' ? 5_000 : undefined,
...options.statsigOptions,
});
};
@@ -58,74 +88,231 @@ function createStatsigAdapter(options: {
* You can pre-initialize the SDK by calling `adapter.initialize()`,
* otherwise it will be initialized lazily when needed.
*/
const initialize = async (): Promise<void> => {
const initialize = async (): Promise<typeof Statsig> => {
if (!_initializePromise) {
_initializePromise = initializeStatsig();
}
await _initializePromise;
return Statsig;
};
/**
* Resolve a Feature Gate and return an object with the boolean value and rule ID
*
* The key is based on the `key` property of the flag
* The entities should extend `{ statsigUser: StatsigUser }`
*/
const featureGate = <T>(
mapValue: (value: boolean, ruleId: string) => T,
): Adapter<T, StatsigUserEntities> => {
return {
decide: async ({ key, entities }) => {
await initialize();
const result = Statsig.getFeatureGateWithExposureLoggingDisabledSync(
entities?.statsigUser as StatsigUser,
key,
);
return mapValue(result.value, result.ruleID);
},
};
const isStatsigUser = (user: unknown): user is StatsigUser => {
return user != null && typeof user === 'object';
};
/**
* Resolve a Dynamic Config and return a value based on the result and rule ID.
*
* The key is based on the `key` property of the flag
* The entities should extend `{ statsigUser: StatsigUser }`
*
* Used as the basis for experiments, dynamic configs, and autotunes
*/
const dynamicConfig = <T>(
mapValue: (value: Record<string, unknown>, ruleId: string) => T,
): Adapter<T, StatsigUserEntities> => {
return {
decide: async ({ key, entities }) => {
await initialize();
const result = Statsig.getConfigWithExposureLoggingDisabledSync(
entities?.statsigUser as StatsigUser,
key,
);
return mapValue(result.value, result.getRuleID());
},
};
};
const syncHandler = createSyncingHandler();
/**
* Check a feature gate and return a boolean value
*
* Because the ruleID is not returned, this default usage is not suited for
* feature gates with metric lifts. For such usage, see `adapter.featureGate`
* and include the ruleID in the return value.
*/
function statsigAdapter(): Adapter<boolean, StatsigUserEntities> {
return featureGate((value) => value);
async function predecide(user?: StatsigUser): Promise<StatsigUser> {
await initialize();
syncHandler?.();
if (!isStatsigUser(user)) {
throw new Error(
'@flags-sdk/statsig: Invalid or missing statsigUser from identify. See https://flags-sdk.dev/concepts/identify',
);
}
return user;
}
statsigAdapter.featureGate = featureGate;
statsigAdapter.experiment = dynamicConfig;
statsigAdapter.dynamicConfig = dynamicConfig;
statsigAdapter.autotune = dynamicConfig;
statsigAdapter.initialize = initialize;
return statsigAdapter;
function origin(prefix: string) {
if (!options.statsigProjectId) {
return () => undefined;
}
return (key: string) => {
// Allow unused suffix to be provided to flags to tell them apart.
// Used for different treatments of the same Statsig entities.
const keyPart = key.split('.')[0] ?? '';
return `https://console.statsig.com/${options.statsigProjectId}/${prefix}/${keyPart}`;
};
}
/**
* Resolve a flag powered by a Feature Gate.
*
* Implements `decide` to resolve the Feature Gate with `Statsig.getFeatureGateSync`
*
* If a function is provided, the return value of the function called
* with the feature gate is returned.
*
* Implements `origin` to link to the flag in the Flags Explorer
* if the adapter defines `statsigProjectId`
*/
function featureGate<T>(
getValue: (gate: FeatureGate) => T,
opts?: {
exposureLogging?: boolean;
},
): Adapter<T, StatsigUser> {
return {
origin: origin('gates'),
decide: async ({ key, entities }) => {
const user = await predecide(entities);
const gate = opts?.exposureLogging
? Statsig.getFeatureGateSync(user, key)
: Statsig.getFeatureGateWithExposureLoggingDisabledSync(user, key);
return getValue(gate);
},
};
}
/**
* Resolve a flag powered by a Dynamic Config.
*
* Implements `decide` to resolve the Dynamic Config with `Statsig.getConfigSync`
*
* If a function is provided, the return value of the function called
* with the dynamic config is returned.
*
* Implements `origin` to link to the flag in the Flags Explorer
* if the adapter defines `statsigProjectId`
*/
function dynamicConfig<T>(
getValue: (config: DynamicConfig) => T,
opts?: {
exposureLogging?: boolean;
},
): Adapter<T, StatsigUser> {
return {
origin: origin('dynamic_configs'),
decide: async ({ key, entities }) => {
const user = await predecide(entities);
const configKey = key.split('.')[0] ?? '';
const config = opts?.exposureLogging
? Statsig.getConfigSync(user, configKey)
: Statsig.getConfigWithExposureLoggingDisabledSync(user, configKey);
return getValue(config);
},
};
}
function experiment<T>(
getValue: (experiment: DynamicConfig) => T,
opts?: { exposureLogging?: boolean },
): Adapter<T, StatsigUser> {
return {
origin: origin('experiments'),
decide: async ({ key, entities }) => {
const user = await predecide(entities);
const experiment = opts?.exposureLogging
? Statsig.getExperimentSync(user, key)
: Statsig.getExperimentWithExposureLoggingDisabledSync(user, key);
return getValue(experiment);
},
};
}
function autotune<T>(
getValue: (autotune: DynamicConfig) => T,
opts?: { exposureLogging?: boolean },
): Adapter<T, StatsigUser> {
return {
origin: origin('autotune'),
decide: async ({ key, entities }) => {
const user = await predecide(entities);
const autotune = opts?.exposureLogging
? Statsig.getConfigSync(user, key)
: Statsig.getConfigWithExposureLoggingDisabledSync(user, key);
return getValue(autotune);
},
};
}
function layer<T>(
getValue: (layer: Layer) => T,
opts?: { exposureLogging?: boolean },
): Adapter<T, StatsigUser> {
return {
origin: origin('layers'),
decide: async ({ key, entities }) => {
const user = await predecide(entities);
const layer = opts?.exposureLogging
? Statsig.getLayerSync(user, key)
: Statsig.getLayerWithExposureLoggingDisabledSync(user, key);
return getValue(layer);
},
};
}
return {
featureGate,
dynamicConfig,
experiment,
autotune,
layer,
initialize,
};
}
export { createStatsigAdapter };
let defaultStatsigAdapter: AdapterResponse | undefined;
export function resetDefaultStatsigAdapter() {
defaultStatsigAdapter = undefined;
}
/**
* Equivalent to `createStatsigAdapter` but with default environment variable names.
*
* Required:
* - `STATSIG_SERVER_API_KEY` - Statsig secret server API key
*
* Optional:
* - `STATSIG_PROJECT_ID` - Statsig project ID to enable link in Vercel's Flags Explorer
* - `EXPERIMENTATION_CONFIG` - Vercel Edge Config connection string
* - `EXPERIMENTATION_CONFIG_ITEM_KEY` - Vercel Edge Config item key where data is stored
*/
export function createDefaultStatsigAdapter(): AdapterResponse {
if (defaultStatsigAdapter) {
return defaultStatsigAdapter;
}
const statsigServerApiKey = process.env.STATSIG_SERVER_API_KEY as string;
const statsigProjectId = process.env.STATSIG_PROJECT_ID;
const edgeConfig = process.env.EXPERIMENTATION_CONFIG;
const edgeConfigItemKey = process.env.EXPERIMENTATION_CONFIG_ITEM_KEY;
if (!(edgeConfig && edgeConfigItemKey)) {
defaultStatsigAdapter = createStatsigAdapter({
statsigServerApiKey,
statsigProjectId,
});
} else {
defaultStatsigAdapter = createStatsigAdapter({
statsigServerApiKey,
edgeConfig: {
connectionString: edgeConfig,
itemKey: edgeConfigItemKey,
},
statsigProjectId,
});
}
return defaultStatsigAdapter;
}
/**
* The default Statsig adapter.
*
* This is a convenience object that pre-initializes the Statsig SDK and provides
* the adapter functions for the Feature Gates, Dynamic Configs, Experiments,
* Autotunes, and Layers.
*
* This is the recommended way to use the Statsig adapter.
*
* ```ts
* // flags.ts
* import { flag } from 'flags/next';
* import { statsigAdapter } from '@flags-sdk/statsig';
*
* const flag = flag({
* key: 'my-flag',
* defaultValue: false,
* adapter: statsigAdapter.featureGate((gate) => gate.value),
* });
* ```
*/
export const statsigAdapter: AdapterResponse = {
featureGate: (...args) => createDefaultStatsigAdapter().featureGate(...args),
dynamicConfig: (...args) =>
createDefaultStatsigAdapter().dynamicConfig(...args),
experiment: (...args) => createDefaultStatsigAdapter().experiment(...args),
autotune: (...args) => createDefaultStatsigAdapter().autotune(...args),
layer: (...args) => createDefaultStatsigAdapter().layer(...args),
initialize: () => createDefaultStatsigAdapter().initialize(),
};
@@ -200,7 +200,7 @@ describe('getProviderData', () => {
it('should fetch and return', async () => {
await expect(
getProviderData({
consoleApiKey: 'console-this-is-a-test-token',
statsigConsoleApiKey: 'console-this-is-a-test-token',
projectId: 'project-id-placeholder',
}),
).resolves.toEqual({
@@ -302,7 +302,7 @@ describe('getProviderData', () => {
it('should return appropriate hints', async () => {
await expect(
getProviderData({
consoleApiKey: '',
statsigConsoleApiKey: '',
}),
).resolves.toEqual({
definitions: {},
@@ -44,7 +44,7 @@ interface StatsigExperimentsResponse {
}
export async function getProviderData(options: {
consoleApiKey: string;
statsigConsoleApiKey: string;
/**
* Required to set the `origin` property on the flag definitions.
*/
@@ -52,7 +52,7 @@ export async function getProviderData(options: {
}): Promise<ProviderData> {
const hints = [];
if (!options.consoleApiKey) {
if (!options.statsigConsoleApiKey) {
hints.push({
key: 'statsig/missing-api-key',
text: 'Missing Statsig Console API Key',
@@ -121,7 +121,7 @@ export async function getProviderData(options: {
/**
* Fetch all Feature Gates.
*/
async function getFeatureGates(options: { consoleApiKey: string }) {
async function getFeatureGates(options: { statsigConsoleApiKey: string }) {
const data: StatsigFeatureGateResponse['data'] = [];
let suffix: string | null = '/console/v1/gates';
@@ -131,7 +131,7 @@ async function getFeatureGates(options: { consoleApiKey: string }) {
method: 'GET',
headers: {
'content-type': 'application/json',
'STATSIG-API-KEY': options.consoleApiKey,
'STATSIG-API-KEY': options.statsigConsoleApiKey,
},
// @ts-expect-error some Next.js versions need this
cache: 'no-store',
@@ -157,7 +157,7 @@ async function getFeatureGates(options: { consoleApiKey: string }) {
/**
* Fetch all experiments.
*/
async function getExperiments(options: { consoleApiKey: string }) {
async function getExperiments(options: { statsigConsoleApiKey: string }) {
const data: StatsigExperimentsResponse['data'] = [];
let suffix: string | null = '/console/v1/experiments';
@@ -167,7 +167,7 @@ async function getExperiments(options: { consoleApiKey: string }) {
method: 'GET',
headers: {
'content-type': 'application/json',
'STATSIG-API-KEY': options.consoleApiKey,
'STATSIG-API-KEY': options.statsigConsoleApiKey,
},
// @ts-expect-error some Next.js versions need this
cache: 'no-store',
+1085 -1039
View File
File diff suppressed because it is too large Load Diff