@epilot/feature-flags
v0.3.3
Published
Backend feature flags + product analytics client for epilot Node services. PostHog under the hood, OpenFeature on the surface.
Downloads
1,127
Maintainers
Keywords
Readme
@epilot/feature-flags
Backend feature flags + product analytics for epilot Node services. PostHog under the hood, OpenFeature on the surface.
Why
- A common feature flags interface across all epilot services
- OpenFeature abstraction means call sites don't change if we ever swap providers
- Lambda-friendly defaults baked in (
flushAt: 1,flushInterval: 0, request timeouts)
Install
npm install @epilot/feature-flags posthog-nodeposthog-node is a peer dependency. Check Extending to other providers for more information on how to extend to other providers.
Requires Node ≥18.
Quick start
import { capture, init, isFeatureEnabled, shutdown } from '@epilot/feature-flags';
export const handler = async (event) => {
await init({
posthog: {
apiKey: process.env.POSTHOG_API_KEY,
host: process.env.POSTHOG_HOST,
},
// env defaults to process.env.STAGE — pass an explicit value only to override
// <provider>: {
// apiKey: process.env.<PROVIDER>_API_KEY,
// host: process.env.<PROVIDER>_HOST,
// },
logger: powertoolsLogger,
});
const ctx = { orgId: event.org, userId: event.user };
if (await isFeatureEnabled('ai-spam-detection', ctx, false)) {
capture({ event: 'spam_check_started', context: ctx, properties: { messageId: event.id } });
// ...
}
await shutdown(); // flush analytics before Lambda freezes
};init() is safe to call concurrently — the first call wires up the provider, subsequent calls share the same in-flight promise.
API
init(options)
type InitOptions = {
posthog?: {
apiKey: string;
host?: string;
flushAt?: number; // default: 1
flushInterval?: number; // default: 0
requestTimeout?: number; // default: 2000ms
featureFlagsPollingInterval?: number; // default: 30s
personalApiKey?: string;
};
env?: string; // deployment stage — see "Environment scoping" below
logger?: Logger; // any object with debug/info/warn/error
};Returns true when the configured provider is registered and ready, false if no config was supplied or initialization failed (in which case all flag evals fall back to defaults and capture() is a no-op). A failed init clears the cached promise so the next caller can retry.
Flag evaluation
const ctx = { orgId, userId };
await isFeatureEnabled('flag-name', ctx, false); // boolean
await getStringFlag('flag-name', ctx, 'default'); // multivariate variant
await getNumberFlag('flag-name', ctx, 0); // numeric payload
await getObjectFlag('flag-name', ctx, { tier: 'free' }); // object payload| OpenFeature method | PostHog call |
|---|---|
| getBooleanValue | isFeatureEnabled |
| getStringValue | getFeatureFlag (multivariate variant) |
| getNumberValue | getFeatureFlagPayload |
| getObjectValue | getFeatureFlagPayload |
Analytics
capture({
event: 'message_sent',
context: { orgId, userId },
properties: { channel: 'email', size: 1024 },
});
await shutdown(); // flushes pending eventscapture() routes through OpenFeature's track() and the active provider, so it's a no-op when no provider is configured.
Direct OpenFeature client
import { getFeatureFlagsClient } from '@epilot/feature-flags';
const client = getFeatureFlagsClient();
const details = await client.getBooleanDetails('flag', false, { targetingKey: 'org_user' });
logger.debug(details.reason); // 'TARGETING_MATCH' | 'DEFAULT' | 'ERROR' | ...Context shape
type EpilotFlagContext = {
orgId?: string;
userId?: string;
};Translated to:
distinctId = ${orgId}_${userId}groups = { organization: orgId }for org-level flag rollout
You can also pass a raw OpenFeature EvaluationContext directly for full control.
Environment scoping
The package reads process.env.STAGE automatically and sends it as groupProperties: { organization: { env } } on every flag evaluation. Every epilot Lambda service has STAGE set, so you don't need to wire it through manually.
This matches the convention existing epilot PostHog flags use — multiple release groups, each filtering by the env group property — so flags rolled out to dev only won't accidentally match in prod, and vice versa.
// default behavior — STAGE picked up from env
await init({ posthog: { ... } });
// override only when you need to (e.g. tests, multi-tenant service)
await init({ posthog: { ... }, env: 'preview-42' });capture() events get env attached as a regular event property as well.
The package also calls posthog.groupIdentify({ groupType: 'organization', groupKey: orgId, properties: { env } }) once per (orgId, env) pair seen in a process. That persists env on the org group itself, so:
- The org shows up in the PostHog UI with
envset $feature_flag_calledevents include the persisted property- Flag rules can target the persisted property without needing the runtime
groupPropertieshint (the hint is still sent on every eval to cover the cold-start race before identify lands)
If process.env.STAGE isn't set and no explicit env is passed, the package skips both groupProperties and groupIdentify entirely.
Local development
npm install
npm run lint # tsc --noEmit
npm test # vitest
npm run build # emit dist/Releasing
Tag the commit:
npm version patch # or minor / major
git push --follow-tagsCI publishes to npm on any tag matching v*.
Extending to other providers
To extend this package to work with other providers, you can create a custom provider that implements the OpenFeature provider interface. The provider should be able to handle flag evaluation and analytics capture.
So if you end up using an in-memory or in-house FF alternative to PostHog specific to your use case, this package just gives you an interface to work with it. The only thing you will have to do is implement the provider interface and pass it to the init function.
