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

@sheepit-ai/server

v0.9.1

Published

Sheepit server-side SDK — flags, experiments, and event tracking for Node.js / Next.js

Readme

@sheepit-ai/server

Read Sheepit flags and experiments, and track events, from Node.js.

npm version license

Server-side SDK for Sheepit. Reads flag values and experiment assignments for a user and tracks events from Node.js: Next.js server code, Fastify, Express, and serverless functions.

Install

npm install @sheepit-ai/server

Key type

Use a secret key (lp_sec_…). The constructor throws on publishable (lp_pub_…) and developer (lp_dev_…) keys. A secret key grants full project access: keep it on the server.

Quick start

import { SheepitServer } from "@sheepit-ai/server";

const sheepit = new SheepitServer({ apiKey: process.env.SHEEPIT_SECRET_KEY! });

sheepit.track("order_placed", { total_usd: 99 }, "user_abc");
await sheepit.flush(); // before a serverless function returns

Usage

import { SheepitServer } from "@sheepit-ai/server";

const sheepit = new SheepitServer({
  apiKey: process.env.SHEEPIT_SECRET_KEY!,
  // optional: release attribution. Defaults to the env chain below.
  release: process.env.SHEEPIT_RELEASE,
});

// Flags: async; `default` if absent or config is unavailable. Evaluated for the identity "user_abc" as a device id, not as an identified user — see "How flags and experiments resolve"
const showBeta = await sheepit.flag("show_beta_ui", { userId: "user_abc" }, false);

// Experiments: userId is required; returns { variant: "control" } when not enrolled
const { variant, payload } = await sheepit.experiment("hero_headline", "user_abc");

// Events: userId is optional
sheepit.track("order_placed", { total_usd: 99 }, "user_abc");

// Identity: emits an `$identify` event with the traits as properties
sheepit.identify("user_abc", { plan: "pro" });

// Revenue: after your server has confirmed the payment with the provider
sheepit.revenue({
  amount: 19.99,
  currency: "USD",
  userId: "user_abc",
  id: order.id,
  occurredAt: order.paidAt,
});
sheepit.refund({
  amount: 5,
  currency: "USD",
  userId: "user_abc",
  id: refund.id,
  occurredAt: refund.createdAt,
});

// Send buffered events now
await sheepit.flush();

// Shutdown: stops the timer, removes error handlers, flushes
await sheepit.close();

There is no reset(): the server SDK keeps no per-user session state.

Revenue

revenue() and refund() feed the dashboard's Revenue tile. Call them from your server once the payment provider has confirmed the money moved (an App Store / Google Play receipt you validated, a Stripe webhook). Revenue is server-only: the API accepts it from a secret key alone, and the client SDKs have no revenue call.

| Field | Type | Notes | | ------------ | ----------------- | --------------------------------------------------------------------------------------- | | amount | number | Major units (19.99, not 1999), 0.01 to 9,999,999,999.99. A refund is positive too | | currency | string | ISO-4217 code, any case | | userId | string? | The buyer, up to 256 characters. A sale without one still counts toward the total | | id | string? | Your transaction id; for a refund, the refund's own id. Requires occurredAt | | occurredAt | Date \| string? | When the money moved, from the provider: a Date or an ISO string with Z or ±hh:mm | | properties | object? | Extra event properties |

  • Duplicates. Two calls with the same id and the same occurredAt count once, so a webhook delivered twice does not double the total. Pass both from the provider (for Stripe: new Date(event.created * 1000), since a bare epoch number is refused). An id without occurredAt is stamped with the current time, so a repeat counts again; the SDK sends it and warns with transport.revenue_not_deduplicated. The SDK's own retries are always safe: a retry resends the first attempt's timestamp. Two identical calls inside one flush window produce a transport.events_rejected whose reason is "Duplicate event_id within this batch": that is the repeat being counted once, not a lost sale.
  • Dates. A string without a zone is refused: it would be read in each server's local time. The API stores only recent dates: at least the last 30 days, and up to your data retention (90 days by default) on an install that has run that long, and up to 30 days ahead. Anything outside is refused after the flush, with transport.events_rejected.
  • Decimals. Amounts are stored with two decimals; a third is rounded (19.999 → 20.00). For a zero-decimal currency such as JPY, send whole amounts (1200).
  • Currency. The Revenue tile sums one currency, picked in Settings → Project → Reporting currency (USD until you choose). Sales in any other currency are counted and shown apart, never converted.
  • Erasure and privacy. Erasing a user removes their identity from these events, but the amounts stay in your revenue totals. The id stays too, as an unsalted hash: anyone who can guess the value can confirm it, so use an opaque provider id (ch_…, GPA.…), never an email address, a sequential order number or anything else that names a person.
  • Delivery is best-effort, like track(): events wait in memory until the next flush, so call await flush() after reporting a sale from a short-lived handler. Subscribe to onDiagnostic to learn about a sale that did not land (transport.revenue_invalid, transport.events_rejected, transport.events_dropped, lifecycle.events_discarded).
  • An input the API would refuse (a zero or negative amount, a malformed currency or date, an over-long userId) is dropped at the call and reported as transport.revenue_invalid. revenue() and refund() never throw.

Next.js

@sheepit-ai/server/nextjs exports an env-driven singleton:

import { getSheepit } from "@sheepit-ai/server/nextjs";

export async function isDashboardV2Enabled(userId: string): Promise<boolean> {
  const sheepit = getSheepit();
  return (await sheepit.flag("dashboard_v2", { userId }, false)) === true;
}

| Variable | Required | Default | Fallbacks | | --------------------- | -------- | ------------------------ | ---------------------------------- | | SHEEPIT_SECRET_KEY | yes | — | GT_SECRET_KEY, LP_SECRET_KEY | | SHEEPIT_ENVIRONMENT | no | production | GT_ENVIRONMENT, LP_ENVIRONMENT | | SHEEPIT_API_URL | no | https://api.sheepit.ai | GT_API_URL, LP_API_URL |

getSheepit() throws if no key is set. resetSheepit() closes and clears the singleton (useful in tests).

Request outcomes

Opt in with captureRequests: true and the SDK reports every inbound HTTP request your process serves — method, route, status and duration — to POST /v1/performance/ingest. That is what gives the server card on the product health overview a failed-request rate; without it the card reads "No data".

const sheepit = new SheepitServer({
  apiKey: process.env.SHEEPIT_SECRET_KEY!,
  captureRequests: true,
});
  • What fails: a 5xx, or a response that never finished — closed before its headers, or cut off after them (a stream that died mid-body, or a client that hung up). A 4xx is the caller's error, not the server's: its status is sent and it does not count as a failure. Through recordRequest(), a 499 (client closed the request) or no status is a failure too.

  • Where it works: anything built on node:http — Express, Fastify, Koa, next start, a plain http.createServer. No middleware to install. It observes requests your process serves, never the requests it makes. Not node:http2: a Fastify (or other) server created with http2: true reports nothing and raises no diagnostic — use recordRequest() from an onResponse hook there.

  • 🔴 Serverless (Next.js on Vercel, Lambda adapters) may not run on node:http, and then nothing is reported automatically. Call recordRequest() from your handler, and flush() before it returns:

    const started = performance.now();
    const res = await handle(req);
    sheepit.recordRequest({
      method: req.method,
      route: "/orders/[id]",
      status: res.status,
      durationMs: performance.now() - started,
    });
    await sheepit.flush();
  • What is sent as the name: METHOD <route template> when the framework exposes one (Express), otherwise METHOD <path> with the query string, fragment, host, credentials and ;matrix parameters removed and id-like parts (numbers, phone numbers, UUIDs, long hex or tokens, emails, key=value) replaced by :id. A 404 without a template is METHOD (no route): an unmatched path is whatever a scanner sent. 🔴 A short personal value in a path (/account/jane.doe) cannot be recognised — on Fastify, Koa or Next.js, which expose no template here, prefer recordRequest({ route }) if your paths carry names. Nothing else about the request — no headers, no body, no user — is sent.

  • Volume: one row per request. requestSampleRate (default 1) keeps a uniform random share, so the failure rate stays unbiased. maxRequestsPerInterval (default 1000 per flushInterval) bounds a traffic spike; rows over it are dropped and reported as transport.requests_capped.

  • One client per process: two SheepitServer instances with captureRequests both report every request (the rate stays right; the counts double).

  • Request rows carry the same release as your events (context.app_version), and are delivered on their own queue with the same retry rules (see Delivery).

How flags and experiments resolve

flag() and experiment() evaluate nothing in your process. They call GET /v1/config with the user id as the identity (sent as X-Device-ID) and read the values the API has already evaluated for it. Results are cached per identity for cacheTtl (60 s by default) in an LRU of identityCacheSize entries. A call without userId uses one process-wide identity and gets the project-level values.

  • Kill switches and flag changes reach a server within cacheTtl of the change (plus request time), per identity.

  • Exposure: experiment() tracks $experiment_exposure when the user is enrolled: once per user and arm in each process, again if the arm changes, and again once a day while it holds (exposure_reason: first, variant_changed or renewed). Each replica of a scaled service records its own. The dedup keeps up to 10,000 user and experiment pairs, least recently used evicted first. flag() tracks no exposure event.

  • Traits are not sent. context.traits passed to flag() does not affect the result; the SDK emits a flag.traits_unsupported diagnostic instead. Targeting uses what the API already knows about the identity — which, for a server-side call, is nothing beyond the id itself. See the next two bullets.

  • 🔴 Run one experiment from one SDK, not from both. The server SDK sends the user id as the device identity, so the API buckets it as d:alice. A browser or iOS client that has called identify("alice") buckets the same person as u:alice. Those are different hashes, so the same user can land in different arms depending only on which SDK asked — measured on one 50/50 experiment, d: fell in control and u: in treatment. Pick the side that decides the behaviour, and evaluate that experiment only there. The same applies to any flag whose value comes from a percentage rollout.

  • 🔴 A server-side call resolves no device and no user. /v1/config reads the user id from the device row matched on the identity header. What you pass is a user id, so no such row exists: on this side user_id is null and there is no device context. (The one exception: if something has registered a device whose id is literally that string, the row resolves and the call behaves like a client one.) Targeting therefore behaves differently here than in a browser or on iOS — and not uniformly in the safe direction:

    | Depends on | Server SDK | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Percentage rollouts, experiments | Buckets on a different key (above) | | Per-user pins, per-user rollout overrides | 🔴 Skipped, not honoured — evaluation falls through to bucketing. A tester pinned to treatment gets a random arm, and a user you force-excluded can still receive the rollout. | | user_group targeting | 🔴 Inverted. The group set is empty, so in / eq match nobody — but not_in / neq match everybody, and match here only. An "everyone except internal" rule fires on every call. | | Rules on device attributes, country, locale, platform, app version, OS version, user role | Never match. These come from the device row and from request headers this SDK does not send, and a condition on an absent field is false for every operator, neq included. | | Rules on device_id | Match against the user id you passed — or against server_ plus the first 15 characters of your API key when you pass none | | Platform-scoped flags | All present: the platform is unknown, so no platform filter is applied |

    What agrees — and only this: the value of a flag that both sides actually receive, including its default and its kill switch. That excludes a platform-scoped flag: the browser is never sent one outside its platform, so sdk-js returns your compiled-in default and a kill switch on that flag never reaches it. Note the environment comes from the API key itself — an X-Environment header does not change flag resolution.

Request volume

Every distinct userId needs its own /v1/config request each cacheTtl. Unchanged responses come back as 304 Not Modified; full responses count toward your plan's daily flag-evaluation limit. When the limit is hit the SDK emits config.fetch_failed and keeps serving each identity's last good config. Raise cacheTtl to the staleness you can accept for a flag change.

API

| Method | Returns | Notes | | ------------------------------------------ | -------------------------------- | --------------------------------------------------------------------- | | new SheepitServer(config) | — | Throws on a non-secret key | | flag(key, context?, default = false) | Promise<FlagValue> | context is { userId?, traits? }; traits are ignored | | experiment(key, userId) | Promise<{ variant, payload? }> | Tracks $experiment_exposure once per user and arm, renewed daily | | track(event, properties?, userId?) | void | Buffers the event | | identify(userId, traits?) | void | Tracks $identify with { user_id, ...traits } | | revenue(sale) / refund(refund) | void | Tracks $revenue / $refund; see Revenue | | recordRequest(request) | void | Reports one served request; see Request outcomes | | flush() | Promise<void> | Sends buffered events | | close() | Promise<void> | Stops the timer, removes error handlers, flushes; idempotent | | diagnostics() / getRecentDiagnostics() | — | Internal SDK events |

Release attribution

The effective release is the first non-blank (trimmed) of: the release option, the appVersion option, then the environment variables SHEEPIT_RELEASE, VERCEL_GIT_COMMIT_SHA, CI_COMMIT_SHA, GITHUB_SHA. It is sent as context.app.version and must equal the version sheepit ci release registered (default for server deploys: the full commit SHA), so on Vercel, GitLab CI and GitHub Actions it needs no configuration. The value is never altered.

Pass release: null to send no release and not read the environment at all (an explicit appVersion still applies). A release / appVersion over 64 characters throws at construction (the API rejects the whole batch above that). An over-long environment value is skipped, the next one is used, and a lifecycle.release_env_too_long diagnostic is raised. Every batch also carries context.sdk = { name: "sdk-server", version }.

Configuration

| Option | Type | Default | | ------------------------ | ----------------------------------- | -------------------------- | | apiKey | string (required) | — | | environment | string | "production" | | apiUrl | string | "https://api.sheepit.ai" | | release | string | env chain (see below) | | appVersion | string (older name of release) | none | | cacheTtl | number (ms) | 60000 | | identityCacheSize | number | 1000 | | flushInterval | number (ms) | 5000 | | flushSize | number (events) | 50 | | maxBufferSize | number (events; oldest dropped) | 10000 | | debug | boolean | false | | onDiagnostic | (event: DiagnosticEvent) => void | none | | diagnosticBufferSize | number | 100 | | autoCaptureErrors | boolean | true | | captureRequests | boolean | false | | requestSampleRate | number (0–1) | 1 | | maxRequestsPerInterval | number (rows per flushInterval) | 1000 |

autoCaptureErrors adds uncaughtException and unhandledRejection listeners that track $error. They are added alongside your own handlers, not instead of them.

Delivery

Events are buffered in memory and sent when the buffer reaches flushSize, on the flushInterval timer, and on flush() / close(). On a network error or 5xx the batch is put back in the buffer; a 4xx drops it (transport.events_dropped). There is no disk queue: events still buffered when the process exits are lost, so call flush() before a serverless function returns and close() on shutdown.

Diagnostics

| Code | Meaning | | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | config.fetch_failed | Config fetch failed; the last good config for that identity is reused | | config.shape_mismatch | /v1/config returned a payload this SDK version cannot read; upgrade the SDK | | flag.traits_unsupported | context.traits was passed and ignored | | transport.flush_failed | Event send failed; the batch was re-buffered | | transport.events_dropped | Ingest rejected the batch with a 4xx | | transport.buffer_overflow | maxBufferSize reached; oldest events dropped | | transport.events_rejected | The API refused individual events in an accepted request (e.g. a date outside the stored window); data.reasons counts them | | transport.revenue_invalid | A revenue() / refund() input was refused and dropped; data.field names it | | transport.revenue_not_deduplicated | A revenue() / refund() had an id but no occurredAt; it was sent, but a repeat will count again | | transport.request_invalid | A recordRequest() input was refused and dropped; data.field names it | | transport.requests_capped | More request rows than maxRequestsPerInterval in one window; data.droppedCount were dropped | | lifecycle.requests_discarded | close() ended with request rows still undelivered |

Transport diagnostics about request rows carry data.stream: "requests".

Runtime support

Node.js 20+. ES2020 build, ESM and CommonJS. Uses node:crypto and process listeners, so it does not run on edge runtimes.

Deprecated exports

GoaTechServer, getGoaTech, and resetGoaTech are aliases of SheepitServer, getSheepit, and resetSheepit. Use the Sheepit* names.

Links

License

MIT. Copyright (c) 2026 GoaTech AI LLC. See LICENSE.