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

@ravenkeep/node

v0.3.0

Published

Ravenkeep SDK for Node.js backends: signups with UTM attribution, ops events, revenue, identify, crash capture and widget identity verification.

Readme

@ravenkeep/node

The Ravenkeep SDK for Node.js backends. Report signups with their first-touch UTM attribution, identify customers, track operational events (ticket.created, file.uploaded, document.created, members.invited…), record revenue, capture exceptions, and sign user ids for the support widget.

  • Node.js 18+ (also Bun and Deno with Node compatibility). Zero runtime dependencies.
  • ESM and CommonJS, with TypeScript declarations.
  • Never throws: request/response methods resolve to { data, error }, fire-and-forget methods queue.

Installation

The package is public on npm:

npm install @ravenkeep/node
# pnpm add @ravenkeep/node · yarn add @ravenkeep/node · bun add @ravenkeep/node

Quickstart

import { Ravenkeep } from '@ravenkeep/node'

export const ravenkeep = new Ravenkeep({
  secretKey: process.env.RAVENKEEP_SECRET_KEY!, // rk_secret_… (Project settings → Keys)
  release: process.env.GIT_SHA,
  environment: process.env.NODE_ENV === 'production' ? 'production' : 'staging',
})

await ravenkeep.signup({ userId: user.id, email: user.email, attribution: req.body.attribution })
ravenkeep.track('ticket.created', { userId: user.id, properties: { priority: 'high' } })

Create one client per process and reuse it. The secret key is server-side only: never ship it to a browser (the browser uses @ravenkeep/browser with the publishable key).

Configuration

| Option | Default | Description | |---|---|---| | secretKey | — | The project secret key (rk_secret_…). An empty key never throws; calls report missing_secret_key. | | host | https://api.ravenkeep.io | API origin (self-hosted installations). | | identitySecret | — | Project identity secret, enables widgetUserHash. | | flushAt | 50 | Queued events that trigger a send (max 500). | | flushIntervalMs | 2000 | Longest time an event waits in the queue. | | maxQueueSize | 10000 | Items kept per queue while the API is unreachable; the oldest are dropped beyond. | | maxRetries | 5 | Retries of a queued batch; request/response methods retry at most 2 times. 0 disables retries. | | timeoutMs | 10000 | Per-request timeout. | | environment | — | Environment of captured crashes (the API defaults to production). | | release | — | Release of captured crashes (version or commit). | | captureUncaught | false | Capture uncaughtException / unhandledRejection (see Crashes). | | fetch | global fetch | Custom fetch (proxies, tests). | | onError | warn once per code | Receives every failure of the background pipeline. |

Signups and attribution

signup records the signup once per userId and stores the contact's first-touch attribution (utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid, msclkid, referrer, landing page). The first attribution a contact gets is kept forever; later signups never overwrite it. Calling signup again for the same user is safe.

The browser captures the attribution on the visitor's first page view (@ravenkeep/browser getAttribution()), your signup form sends it along, and your backend forwards it:

// Browser (your signup form)
await fetch('/api/signup', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email, password, attribution: ravenkeepBrowser.getAttribution() }),
})

Express:

import express from 'express'
import { ravenkeep } from './ravenkeep'

app.post('/api/signup', express.json(), async (req, res) => {
  const user = await users.create({ email: req.body.email, password: req.body.password })

  const { error } = await ravenkeep.signup({
    userId: user.id,
    email: user.email,
    name: user.name,
    account: { id: user.organizationId, name: user.organizationName },
    traits: { plan: 'free' },
    attribution: req.body.attribution, // Attribution | null from the browser SDK
  })
  if (error) req.log.warn({ code: error.code }, 'ravenkeep signup not recorded') // never blocks the signup

  res.status(201).json({ id: user.id })
})

Fastify:

fastify.post<{ Body: { email: string; attribution?: Attribution } }>('/api/signup', async (request, reply) => {
  const user = await users.create(request.body)
  await ravenkeep.signup({ userId: user.id, email: user.email, attribution: request.body.attribution })
  return reply.code(201).send({ id: user.id })
})

Without the browser SDK, build the attribution on the server from the landing URL you kept (for example in a first-party cookie) and the referrer. Referrers from your own site are internal navigation and are dropped when you pass siteHost:

import { parseAttribution } from '@ravenkeep/node'

const attribution = parseAttribution(req.cookies.landing_url, req.cookies.landing_referrer, {
  siteHost: 'bitstorage.io', // also matches www. and subdomains
})
// { utmSource: 'google', utmMedium: 'cpc', utmCampaign: 'launch', gclid: '…',
//   landingPage: 'https://bitstorage.io/pricing?utm_source=google…', referrer: 'https://www.google.com/',
//   capturedAt: '2026-10-19T12:00:00.000Z' }

parseAttribution(url, referrer?, { siteHost?, capturedAt? }) never throws: values are trimmed and capped, query keys match case-insensitively, the landing page loses its #hash, and capturedAt is set only when something was captured. isEmptyAttribution(attribution) tells whether there is any source.

Identify

await ravenkeep.identify({
  userId: user.id,
  email: user.email,
  name: user.name,
  traits: { plan: 'pro', seats: 12, locale: 'pt-BR' },
  account: { id: org.id, name: org.name },
})

Creates or updates the contact. Call it when profile data that matters to operations changes (plan, company, seats). Values set here win over the browser: @ravenkeep/browser identify() only fills a name or an email the contact does not have yet, and an email set with the secret key is a trusted address that campaign emails can go to (an address sent from a browser without identity verification never is). The trait locale (en or pt-BR) sets the language of the contact's campaign emails (unsubscribe footer and page); without it the project's default language is used.

Ops events

track returns immediately: events are queued, sent in batches (up to 500 per request) every flushIntervalMs or as soon as flushAt are waiting, and retried with backoff. Each event gets a UUID messageId, so a retry is never counted twice.

ravenkeep.track('ticket.created', { userId, properties: { ticketId: ticket.id, priority: ticket.priority } })
ravenkeep.track('file.uploaded', { userId, properties: { sizeBytes: file.size, mime: file.type } })
ravenkeep.track('document.created', { userId, properties: { template: doc.template } })
ravenkeep.track('members.invited', { userId, properties: { count: invites.length, role: 'editor' } })

// Visitors without an account yet
ravenkeep.track('pricing.viewed', { anonymousId: req.cookies.anon_id })

// Your own idempotency key and occurrence time (imports, backfills)
ravenkeep.track('document.created', { userId, messageId: `doc-${doc.id}-created`, timestamp: doc.createdAt })

// In the browser's visit: the frontend sends ravenkeep.getSessionId() (@ravenkeep/browser 0.2.0+)
ravenkeep.track('report.exported', { userId, sessionId: req.body.sessionId })

sessionId (8–64 characters of A–Z a–z 0–9 _ -, 0.2.0 and later) places the event in that visit for Metrics → Sessions. Without one (null, empty or blank count as none), the API groups the event with the person's other events that have no id, by the 30-minute gap; such a group counts as a visit only when it has a page or screen view. A malformed id drops the event (validation_failed).

Names are free-form (≤ 100 characters, no control characters); names starting with $ are reserved. Properties are a JSON object up to 32 KiB. Invalid events are dropped and reported to onError.

Revenue

Amounts are integers in minor units (the currency's ISO 4217 exponent: cents for USD, yen for JPY) in the project currency. reference (your invoice or charge id) makes the call idempotent per project and kind, so webhook retries are safe; a repeated call answers duplicate: true and changes nothing.

Always pass reference. With it, timeouts, network errors, 429 and 5xx are retried automatically (the API deduplicates the retry). Without it, revenue() is never retried: a request whose response was lost may already have been recorded, and a blind retry would count the payment twice. The first failure is returned as is, and retrying it is your decision.

// Stripe webhook
if (event.type === 'invoice.paid') {
  const invoice = event.data.object
  await ravenkeep.revenue({
    userId: invoice.metadata.userId,
    amount: invoice.amount_paid, // 4900 = $49.00
    currency: invoice.currency, // 'usd' → 'USD'
    reference: invoice.id,
  })
}
if (event.type === 'charge.refunded') {
  const charge = event.data.object
  await ravenkeep.revenue({
    userId: charge.metadata.userId,
    amount: charge.amount_refunded,
    currency: charge.currency,
    kind: 'refund',
    reference: `refund_${charge.id}`,
  })
}

Revenue feeds each contact's LTV and the LTV / CAC report by acquisition source.

Crashes

try {
  await billing.charge(user)
} catch (error) {
  ravenkeep.captureException(error, {
    userId: user.id,
    tags: { feature: 'checkout' },
    context: { invoiceId: invoice.id },
  })
  throw error
}

// Express error handler (after your routes)
app.use((error, req, res, next) => {
  ravenkeep.captureException(error, { userId: req.user?.id, tags: { route: req.route?.path ?? req.path } })
  next(error)
})

Each occurrence carries the exception type, message, stack and parsed frames (your code is marked in-app relative to the working directory, dependencies and Node internals are not), the cause chain, the runtime and OS, plus your release and environment. Ravenkeep groups occurrences of the same bug (same exception type and top in-app frames) into one crash group even when line numbers change between releases. To group differently, pass fingerprint: ['checkout', 'stripe-timeout']. Levels: error (default), warning, fatal.

captureUncaught: true captures uncaught exceptions and unhandled rejections as fatal, waits up to 2 s for them to be sent, then keeps Node's behavior: the process prints the error and exits with code 1 (for rejections, according to --unhandled-rejections). If your application registers its own uncaughtException / unhandledRejection listener, the SDK captures and leaves the decision to it.

Widget identity verification

The support widget (@ravenkeep/browser) proves who the user is with a hash your backend computes:

const ravenkeep = new Ravenkeep({ secretKey, identitySecret: process.env.RAVENKEEP_IDENTITY_SECRET })

app.get('/api/me', (req, res) => {
  res.json({ id: req.user.id, email: req.user.email, ravenkeepUserHash: ravenkeep.widgetUserHash(req.user.id) })
})
// browser: ravenkeep.identify({ userId: me.id, userHash: me.ravenkeepUserHash, email: me.email })

widgetUserHash(userId) is hex(HMAC-SHA256(identitySecret, userId)). Without identitySecret it returns '' and reports missing_identity_secret. Never send the identity secret to the browser.

Serverless and shutdown

Timers never keep your process alive, so short-lived environments must flush before they finish:

// AWS Lambda / Vercel / Netlify functions
export const handler = async event => {
  ravenkeep.track('file.uploaded', { userId: event.userId })
  await ravenkeep.flush() // resolves when delivered, never rejects
  return { statusCode: 200 }
}

Long-running servers flush on shutdown:

process.on('SIGTERM', async () => {
  await server.close()
  await ravenkeep.shutdown() // flushes (≤ 5 s), stops timers, removes process listeners
  process.exit(0)
})

flush(timeoutMs?) waits for everything queued so far (optionally at most timeoutMs). Scripts that end naturally are flushed on beforeExit (bounded to 2 s). After shutdown(), track and captureException are ignored and reported as client_closed.

Errors

Request/response methods (ping, identify, signup, revenue) resolve to exactly one of data and error, and never throw:

const { data, error } = await ravenkeep.signup({ userId })
if (error) {
  console.warn(error.code, error.status, error.message) // never contains your keys
}

error is a RavenkeepError with code, status (HTTP status, 0 when the request never reached the API), issues (validation problems with their path) and details.

| code | Meaning | |---|---| | validation_failed | Invalid input, refused before sending (issues), or by the API (422). | | missing_secret_key / secret_key_required | No key, or the publishable key was used. | | invalid_api_key | Unknown, rotated or deleted key (401). | | rate_limited | Too many requests (429); retried automatically when Retry-After is short. | | currency_mismatch | Revenue currency differs from the project currency (422). | | network_error / timeout | The API could not be reached, or the response body was cut (status 0); retried automatically (revenue() only with a reference). | | invalid_response | The API answered 2xx without a JSON body (a proxy or gateway page in between); not retried. |

Fire-and-forget failures go to onError (by default one console.warn per code): validation_failed (an invalid event or crash was dropped), queue_overflow (details.dropped), items_rejected (details.rejected), an API code with details.dropped after the retries, client_closed, flush_timeout, missing_identity_secret.

new Ravenkeep({ secretKey, onError: error => logger.warn({ code: error.code, details: error.details }, error.message) })

Delivery guarantees

  • Batches of at most 500 events / 50 crashes and under 1 MiB; one request in flight per queue.
  • Network errors, timeouts, 408, 429 and 5xx are retried with exponential backoff and jitter (1 s → 30 s, maxRetries), honoring Retry-After. A batch the API finds too large is split. Other 4xx are not retried.
  • Past maxQueueSize the oldest items are dropped (reported once per burst). The queue is a FIFO with O(1) push and shift, so a large maxQueueSize during a long outage costs the same per track() as a small one.
  • A batch whose response body is cut by the timeout or the network is retried like any network failure: it is never counted as delivered.
  • messageId deduplication on the API makes retries safe.

Self-hosting and testing

new Ravenkeep({ secretKey, host: 'https://ravenkeep.internal.example.com' })
new Ravenkeep({ secretKey: 'rk_secret_test', fetch: myFakeFetch, flushIntervalMs: 0 })
const { data } = await ravenkeep.ping() // { projectId, projectName, keyKind: 'secret' }