@epilot/feature-flags

中文文档
0.7.0发布于 2026/09/23UNLICENSEDTypeScriptCommonJS近 30 天下载 9154

Backend feature flags + product analytics client for epilot Node services. PostHog under the hood, OpenFeature on the surface.

原始使用文档
文档目录16 节
  1. Why
  2. Install
  3. Quick start
  4. API
  5. init(options)
  6. Flag evaluation
  7. Org-scoped flags
  8. Several flags for one org
  9. Analytics
  10. Direct OpenFeature client
  11. Context shape
  12. Environment scoping
  13. Evaluation caching
  14. Local development
  15. Releasing
  16. Extending to other providers

@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-node

posthog-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: 5000ms
    featureFlagsPollingInterval?: number; // default: 30s
    personalApiKey?: string;
  };
  env?: string;          // deployment stage — see "Environment scoping" below
  logger?: Logger;       // any object with debug/info/warn/error
  cacheTtlMs?: number;   // evaluation cache TTL — default: 60s, 0 disables
  bulkOrgEval?: boolean; // isOrgFeatureEnabled resolves via the org map — default: true
};

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 methodPostHog call
getBooleanValueisFeatureEnabled
getStringValuegetFeatureFlag (multivariate variant)
getNumberValuegetFeatureFlagPayload
getObjectValuegetFeatureFlagPayload

A bulk counterpart sits alongside them — see Several flags for one org.

Org-scoped flags

Most epilot backend flags target the organization group only — an org allowlist, an env group property, or an org percentage rollout. For those flags the user cannot change the result, so use the org-scoped check:

await isOrgFeatureEnabled('o365-outlook-connect', orgId, false);

This matters for cost, not just ergonomics. The evaluation cache is keyed by (kind, flagKey, orgId, distinctId) and distinctId embeds the user — so passing { orgId, userId } to a group-targeted flag creates one cache entry (and one billable PostHog request) per user of the org, even though the answer is identical for all of them. On high-traffic paths this fragments the cache to a ~0% hit rate. Keyed by org alone, a warm container makes at most one request per org per cache window.

Keep using isFeatureEnabled with a full { orgId, userId } context when the flag has person-level conditions or user-bucketed percentage rollouts.

isOrgFeatureEnabled resolves through the whole-org map: one request answers every org-scoped key for that org, and later reads in the same cache window come out of it. Batching is the behaviour of the ordinary getter, not something each caller has to reach for. A map that cannot be resolved is no answer, not nothing enabled, so the key falls through to its own request rather than taking defaultValue — every flag keeps a real evaluation attempt before its default applies. Pass bulkOrgEval: false to init to always fetch per key; that is worth it only for a service reading exactly one org-scoped flag that wants the smaller response.

Several flags for one org

Org-scoped reads already share one whole-project map, so a fan-out of isOrgFeatureEnabled calls does not cost a request each. Reach for these two when the caller wants the map itself — a known key set, or every flag:

// Known keys — always a full map, never null:
const flags = await areOrgFeaturesEnabled(['portals-engagement-center', 'portals-mcp'], orgId);
if (flags['portals-mcp']) { /* ... */ }

// Or the whole project's flags, when the set isn't known up front:
const all = await getOrgFeatureFlags(orgId);
if (all?.['portals-engagement-center']) { /* ... */ }

Both cost one request. Prefer areOrgFeaturesEnabled: it fills defaultValue in for any flag the project does not carry or that could not be resolved, so there is no null to guard. Use getOrgFeatureFlags when the caller needs every flag, or needs to tell unresolved apart from disabled (to log it, say).

Both share one cache entry per org — the whole-project map. Narrowing the request to just the requested keys would key the cache by the requested set, fragmenting it per call site and costing a request each. Because they share it, any later org-scoped isOrgFeatureEnabled in the same process reads its answer out of that entry for free, including flags on unrelated request paths.

PostHog charges one request per /flags call and the number of active flags in a project does not affect the cost of each request, which is why one map is cheaper than N keys and why the org-scoped getter takes it. (The 10-requests-per-call multiplier applies to local evaluation's definition polls, an endpoint this package does not use.) The bulk call does cost more response size and more cache memory, since one entry holds every project flag rather than a single boolean.

Returns null when no PostHog provider is registered or the request produced no usable map — apply your own per-flag defaults then, and note that a key absent from a non-null map is a flag the project does not have. Multivariate flags collapse to true when a variant matched; read variants and payloads with getStringFlag / getObjectFlag.

Analytics

capture({
  event: 'message_sent',
  context: { orgId, userId },
  properties: { channel: 'email', size: 1024 },
});

await shutdown(); // flushes pending events

capture() 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.

Evaluation caching

Every uncached flag evaluation is a billable PostHog request — posthog-node has no result cache of its own (without personalApiKey every call is a network /decide//flags request, and its only internal cache dedupes the $feature_flag_called analytics event, not the request). The provider therefore caches evaluation results per (kind, flagKey, orgId, distinctId) for 60s by default, bounded at 1,000 entries per process.

  • A cache miss costs exactly the one request that would have been made anyway; a cold container is never worse off than without the cache.
  • Flag changes propagate within the TTL on warm containers; cold containers see them immediately.
  • Failed evaluations cache their fallback default for the TTL, which doubles as backpressure when PostHog is unreachable.
  • Tune or disable via init({ cacheTtlMs }) (0 disables).
  • The bulk resolutions cache their whole map under one entry per (orgId, distinctId), and a single-flag boolean check reads through that entry when one is cached for the same context. An org-scoped bulk result never answers a person-scoped lookup: the bulk key embeds the same distinctId, which a person-scoped context changes. A key the map does not hold falls through to a request rather than resolving to false. Unlike the per-flag getters it does not cache a failure, because one cached failure would pin every flag to its default for the TTL.

The package never calls posthog.groupIdentify(). Flag targeting only needs the groups + groupProperties sent with each evaluation, and per-process dedup of identify calls doesn't work on Lambda (each container is a fresh process, so identify volume scales with container churn, and PostHog bills every $groupidentify event). Persisting env on org group profiles — so orgs are filterable by env in the PostHog UI — is handled out-of-band (e.g. a one-off backfill script or at org-creation time), not from the request path.

If process.env.STAGE isn't set and no explicit env is passed, the package skips groupProperties 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-tags

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

版本与发布记录

@epilot/feature-flags 共发布 13 个版本,首次发布于 2026/05/01;过去 12 个月发布了 13 个版本。

版本发布日期距今
0.7.0latest2026/09/231 个月前
0.6.02026/09/022 个月前
0.5.22026/07/084 个月前
0.5.12026/07/084 个月前
0.5.02026/07/084 个月前
0.4.12026/07/034 个月前

关于 @epilot/feature-flags 的常见问题

@epilot/feature-flags 是什么?

Backend feature flags + product analytics client for epilot Node services. PostHog under the hood, OpenFeature on the surface.

如何安装 @epilot/feature-flags?

在项目目录运行 npm install @epilot/feature-flags,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:posthog-node(^4.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@epilot/feature-flags 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@epilot/feature-flags 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@epilot/feature-flags 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@epilot/feature-flags 可以商用吗?

@epilot/feature-flags 使用 UNLICENSED 许可。作者声明为 UNLICENSED,即不授予公开使用许可(与公共领域的 Unlicense 不同)。使用前需获得作者授权。以上为概括,不构成法律意见,请以许可原文为准。

@epilot/feature-flags 的下载量有多少?

根据 npm 官方下载统计,@epilot/feature-flags 在 2026/10/14 – 2026/11/12 这 30 天内被下载 9,154 次,日均约 305 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@epilot/feature-flags 还在维护吗?

最新版本 0.7.0 发布于 2026/09/23(1 个月前);至今共发布 13 个版本,其中过去 12 个月发布了 13 个;首次发布于 2026/05/01。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。