@pug-sh/node sends events from your Node.js backend and reads analytics
back - profiles, activity and insights. It authenticates with your private
key (prv_...), so it belongs on the server only. It follows the
same model as every Pug SDK with one exception: a server has no ambient
user or session to reset, so you name the distinctId on each call instead.
Install
npm install @pug-sh/node
# or: pnpm add @pug-sh/node | yarn add @pug-sh/node | bun add @pug-sh/nodeThe Node SDK is server-side and ships as ESM - it needs Node.js 18+. Import
the Pug class:
import { Pug, PugError } from '@pug-sh/node'Unlike the client SDKs it authenticates with your private key (prv_...), so
keep it on the server - in an environment variable, never in code shipped to a
browser or app. Construct one instance at boot and reuse it across requests.
Working with a coding agent? Paste this instead - it points the agent at the markdown copy of this page, so it works from the reference below rather than from memory. More on the approach in Install with AI.
Add Pug product analytics to this Node backend. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/node/index.md - SDK reference - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the service actually does: its routes or handlers, its main models, and the state changes that matter to the business. On a server the events worth tracking are the ones a browser cannot observe - a payment settling, a webhook arriving, a job finishing, a subscription lapsing - not request logging. If you cannot state what this service is for in one sentence, keep reading. Then: 1. Install @pug-sh/node. It is ESM and needs Node 18+. 2. Construct one Pug instance at boot and reuse it across requests - do not build one per request. Its apiKey is the private key (prv_...), read from an environment variable, and it must never be imported into client-side code. 3. Remember the argument order: pug.track(distinctId, kind, props?, options?). The distinctId comes first because a server has no ambient user - use our own user ID, or the anon-... ID forwarded from the client. 4. Add track calls for the three or four state changes you identified. 5. Call identify() when a user record is created or changes materially, not on every request. 6. Await pug.close() in the graceful-shutdown path so buffered events flush, and wire an onError handler so dropped events are logged rather than lost. Name events from the catalog before you invent one. The server SDK type-checks 114 of the 127 well-known events, and the ones a backend is placed to see are exactly the ones whose schemas are worth having - purchase, subscription_started, invoice_paid, payment_failed, order_refunded. Search the catalog for every action you are about to track, take property names and types from that page, and fall back to a custom snake_case name only when nothing fits. When you do, tell me which event you considered and why it did not. There is no reset() or destroy() on the server SDK, and no immediate option on track - use flush(). Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this service does, then list the files you changed, the event names you chose, and the environment variables I need to set.
Initialize
Construct a Pug instance once at boot with your private key - every other
call needs it first. Private keys live under Settings > API Keys, but one is
only shown when you create it - if you don’t have
yours, issue a new one.
import { Pug } from '@pug-sh/node'
// Instance-based - construct once at boot, reuse across requests.
const pug = new Pug({
apiKey: process.env.PUG_PRIVATE_KEY!, // prv_... - server-side only, never ship to a client
})new Pug(options) returns an instance immediately. The constructor throws
if apiKey is missing or doesn’t start with prv_ - the server SDK refuses a
public key up front rather than failing later on the first read.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
string |
required | Private key (prv_...), sent as x-api-key. Server-side only. |
endpoint |
string |
Pug Cloud | Pug server origin. Defaults to https://api.pugs.dev - set it only when self-hosting. |
batch |
Partial<BatchConfig> |
maxSize 100, maxWaitMs 5000, maxQueueSize 10000 |
Ingestion buffer size and flush timing. |
onError |
OnError |
no-op | Dead-letter hook for buffered track() events that couldn’t be delivered - permanent send failure, queue overflow, or still-undelivered at close(). (identify() failures are logged, not routed here.) |
Call await pug.close() on graceful shutdown so buffered events flush -
anything still undeliverable is reported through onError. The Node SDK has no
destroy(), reset(), or rotate(): a server has no ambient user or session
to reset.
Track events
Events are the foundation of every analytic in Pug. After the distinctId, the
next argument is the event name (the kind): custom names work without a
schema, while well-known events add typed
properties and richer dashboards. Names starting with pug. are reserved.
// distinctId (who the event is for) comes first - a server has no ambient user.
pug.track('user-123', 'order.completed', { amount: 49, currency: 'USD' })
// Well-known events get typed, validated properties.
pug.track('user-123', 'purchase', { productId: 'sku_123', amount: 29.99, currency: 'USD' })pug.track(distinctId, kind, props?, options?) puts the distinctId first -
a server has no ambient user, so you name who each event is for (your external
ID, or an anon-... ID forwarded from the client). It never throws and is
non-blocking: events are validated, batched, and flushed in the background.
There is no { immediate } on the server - flush explicitly instead:
await pug.flush() // send what's buffered right now
await pug.close() // drain on graceful shutdown so nothing buffered is lostflush() resolves when that batch attempt settles - not a guarantee the queue
is empty, since a transient failure re-queues for a later flush. Use close()
to drain fully on shutdown.
track(distinctId, kind, props?, options?) options:
| Option | Type | Description |
|---|---|---|
timestamp |
number |
Override the event time, as epoch milliseconds (e.g. Date.now()). |
sessionId |
string |
Use this session ID for the call - otherwise the instance uses one for its lifetime. |
location |
EventLocation |
Where the visitor is. Without it a server event carries no geo at all. See Visitor location. |
Property values accept the same JSON scalar types as the other SDKs.
A well-known event’s props are checked against
its schema before the event is queued. A prop that fails - a wrong type, or a
rule such as currency needing three uppercase letters - drops the whole
event, and the log names the field (quantity: expected int32, got string).
Keys outside the schema pass through as custom properties. Two field types take
a particular shape:
- int64 fields such as
sizeBytestake a safe integer or abigint. A string is rejected rather than parsed. - Duration fields such as
positiontake{ seconds, nanos }, sent as a string like"90.5s".
The distinctId must not start with cookieless-. The server reserves that
prefix and rejects a whole batch over one such event, so track() logs and
drops it before it can take other users’ events down with it.
Visitor location
Pug resolves geo from CDN headers, which describe whoever opened the
connection - your backend, on every request this SDK makes. So the server skips
geo enrichment on a private key entirely: an event carries the location you give
it, or none at all. location (added in 0.0.6) is how you give it one.
pug.track('user-123', 'purchase',
{ productId: 'sku_123', amount: 29.99, currency: 'USD' },
{ location: { country: 'DE', city: 'Berlin' } })| Field | Type | Notes |
|---|---|---|
country |
string |
ISO 3166-1 alpha-2 (DE). Case and surrounding spaces are normalized; a code Pug doesn’t recognize is dropped. |
continent, region, city, postalCode, metroCode |
string |
Sent as given, trimmed. |
timezone |
string |
IANA name (Europe/Berlin). |
latitude, longitude |
number |
Decimal degrees, -90 to 90 and -180 to 180. Sent only as a pair - one without the other reads as a real position with the other axis at 0, so neither is sent. |
Each field becomes the matching $-prefixed
auto-property: city
lands as $city. That makes location the only way to set an auto-property
from this SDK - props become custom properties, which may not start with
$.
A field that is absent, null or empty is skipped silently. One that looks like
a mistake - unknown key, wrong type, a country code Pug doesn’t know, a
coordinate out of range - is dropped with a warning and the rest of the event
still sends. Nothing is merged at ingestion, so a dropped field is a permanent
hole rather than one the edge fills in later.
Identify and sessions
Identity turns anonymous events into actionable profiles. Use a stable, unique external ID - your database user ID, not an email (which can change). Only the first identify after an anonymous session merges the prior activity; the same external ID always resolves to the same profile, and traits are merged (never removed) server-side.
// On the first identify, pass the SDK's anonymousId to merge prior anonymous activity.
await pug.identify('user-123', { email: 'user@example.com', plan: 'pro' }, {
anonymousId: 'anon-abc123',
})pug.identify(externalId, traits?, options?) returns a promise and never
throws - failures are logged. traits must be a plain object: a string,
array, Date or Map is logged and nothing is sent, as is an externalId
starting with the reserved cookieless- prefix. Pass { anonymousId } (must
start with anon-) on the first identify to merge a client’s prior anonymous
activity into the profile, and { deviceId } to attach a device. There is no
reset() or rotate(): identity isn’t ambient on the server - you name the
distinctId on every track() call instead.
Sessions
The SDK uses a single session ID for the instance’s lifetime - override it per
call with track(..., { sessionId }), since sessions are a client concept. For
the anonymous->identified model, see Core concepts.
Auto-tracking
There is no client-side auto-tracking on the server - you send every event
explicitly with pug.track(distinctId, ...). The server doesn’t fill in geo or
user agent either: both read the connection, which on a private key is your
backend, so pass the visitor’s location yourself. Server
events are also never tagged $bot, so reads never leave
them out as bot traffic.
Consent and privacy
The Node SDK has no consent gate and no redaction hooks, so both are yours:
respect the visitor’s consent choice before you call track(), and keep PII
out of the properties you pass.
Server-side reads
The private key authorizes ingestion and reads - the same insights,
profiles, and activity data the dashboard shows. Reads are request/response and
throw PugError (which carries the Connect code and cause - see the
error model); they run in your control flow, so you own
timeout and retry. Ingestion (track / identify) still never throws.
import { PugError } from '@pug-sh/node'
try {
// Look up a profile by your external ID.
const profile = await pug.profiles.getByExternalId('user-123')
// Auto-paginating async iterator over every matching profile.
for await (const p of pug.profiles.list()) {
console.log(p.externalId)
}
// Run an insights query - the same spec the dashboard uses.
const result = await pug.insights.query({ /* spec, timeRange, granularity */ })
} catch (err) {
// Reads throw; ingestion (track/identify) never does.
if (err instanceof PugError) console.error(err.code, err.message)
}| Namespace | Methods |
|---|---|
pug.profiles |
get(id, opts?), getByExternalId(externalId, opts?), delete(id), list(req?): an auto-paginating async iterator |
pug.activity |
feed, eventExplorer, heatmap, profileSessions, profileStats, filterSchema, propertyValues |
pug.insights |
query, segmentUsers, filterSchema, propertyValues |
Reads leave out traffic tagged as a bot at ingest: web-SDK events from crawler
user agents and datacenter networks. Events from this SDK are never tagged. Set
includeBots: true (0.0.7+) to count bot traffic - profiles.get and
getByExternalId take it in their second argument, and profiles.list,
insights.query (on its spec) and the activity feed, eventExplorer,
heatmap, profileSessions and profileStats take it in the request:
const profile = await pug.profiles.getByExternalId('user-123', { includeBots: true })
const { sessions } = await pug.activity.profileSessions({ distinctId: 'user-123', includeBots: true })The request/response shapes match the shared.* Connect services - see the
API reference for field-by-field schemas. From a non-Node backend, call
those services directly with your private key (see
Authentication).
Upgrading
v0.0.7
Nothing here breaks compilation. The changes are in what reaches Pug: input
that 0.0.6 altered and sent anyway is now dropped with a [Pug SDK] error, so
check your logs after upgrading.
- A well-known event with a wrong-typed prop is dropped. 0.0.6 sent it without that field, under an “unsupported scalar type” warning. Now nothing is sent, and the error names the field and the type it expected - see Track events.
identify()needs plain-object traits. A string, array,DateorMapused to go out as empty traits ({}). Now the call logs and sends nothing.- int64 and Duration props are sent. A number in an int64 field such as
sizeBytesused to be skipped with a warning. A Duration such aspositionwas skipped too, or dropped the whole event whensecondswas a number. Both now go out, a Duration as"90.5s". - A
distinctIdstarting withcookieless-is dropped, since the server rejects the whole batch over one. - Server-side drops are logged. When the server refuses events from a batch
it reports them in
droppedrather than failing the request, and the SDK now logs a warning with the reasons. includeBotsis new on reads. The server leaves bot-tagged traffic out of reads by default whichever SDK version you run; 0.0.7 is the first that can ask for it back. See Server-side reads.