@sheepit-ai/server
v0.9.1
Published
Sheepit server-side SDK — flags, experiments, and event tracking for Node.js / Next.js
Maintainers
Readme
@sheepit-ai/server
Read Sheepit flags and experiments, and track events, from Node.js.
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/serverKey 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 returnsUsage
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
idand the sameoccurredAtcount 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). AnidwithoutoccurredAtis stamped with the current time, so a repeat counts again; the SDK sends it and warns withtransport.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 atransport.events_rejectedwhose 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
idstays 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 callawait flush()after reporting a sale from a short-lived handler. Subscribe toonDiagnosticto 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 astransport.revenue_invalid.revenue()andrefund()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). A4xxis the caller's error, not the server's: its status is sent and it does not count as a failure. ThroughrecordRequest(), a499(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 plainhttp.createServer. No middleware to install. It observes requests your process serves, never the requests it makes. Notnode:http2: a Fastify (or other) server created withhttp2: truereports nothing and raises no diagnostic — userecordRequest()from anonResponsehook there.🔴 Serverless (Next.js on Vercel, Lambda adapters) may not run on
node:http, and then nothing is reported automatically. CallrecordRequest()from your handler, andflush()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), otherwiseMETHOD <path>with the query string, fragment, host, credentials and;matrixparameters removed and id-like parts (numbers, phone numbers, UUIDs, long hex or tokens, emails,key=value) replaced by:id. A404without a template isMETHOD (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, preferrecordRequest({ 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(default1) keeps a uniform random share, so the failure rate stays unbiased.maxRequestsPerInterval(default1000perflushInterval) bounds a traffic spike; rows over it are dropped and reported astransport.requests_capped.One client per process: two
SheepitServerinstances withcaptureRequestsboth 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
cacheTtlof the change (plus request time), per identity.Exposure:
experiment()tracks$experiment_exposurewhen 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_changedorrenewed). 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.traitspassed toflag()does not affect the result; the SDK emits aflag.traits_unsupporteddiagnostic 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 calledidentify("alice")buckets the same person asu: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 andu: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/configreads 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 sideuser_idis 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_grouptargeting | 🔴 Inverted. The group set is empty, soin/eqmatch nobody — butnot_in/neqmatch 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,neqincluded. | | Rules ondevice_id| Match against the user id you passed — or againstserver_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-jsreturns your compiled-in default and a kill switch on that flag never reaches it. Note the environment comes from the API key itself — anX-Environmentheader 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
@sheepit-ai/sdk-js·@sheepit-ai/react·@sheepit-ai/cli- Android and .NET SDKs: coming soon.
- sheepit.ai
License
MIT. Copyright (c) 2026 GoaTech AI LLC. See LICENSE.
