npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@epilot/feature-flags

v0.5.2

Published

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

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-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
};

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 |

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.

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