retainly
v0.2.1
Published
Retainly server-side analytics for Node and any fetch-based runtime
Maintainers
Readme
retainly
Server-side Retainly analytics for Node.js 18+ and any other fetch-based runtime (Bun, Deno, Cloudflare Workers, etc.). This package is framework-neutral: no Next.js imports, no React, and no runtime npm dependencies beyond what your environment already provides.
This repo intentionally ships one SDK: retainly. Use the same package in:
- Node backends (Express/Fastify/Hono/Nest/etc.)
- Next.js (Route Handlers, Server Actions, API routes, middleware)
- React (recommended: send events via your backend so your API key stays secret)
Requirements
- Node.js ≥ 18, or another runtime that exposes global
fetch.
Installation
npm install retainlypnpm add retainlyyarn add retainlyInstallation
npm install retainlypnpm add retainlyyarn add retainlyQuick start (Node / any backend)
import { RetainlyServer } from 'retainly'
const retainly = new RetainlyServer(process.env.RETAINLY_API_KEY!)
await retainly.track('subscription_created', {
userId: user.id,
accountId: org.id,
idempotencyKey: stripeEvent.id,
properties: {
plan: 'pro',
amount: 29,
currency: 'USD',
},
})Usage by framework
Node (Express / Fastify / Hono / etc.)
Create one client (per process / per server instance) and call track / identify wherever you need.
import { RetainlyServer } from 'retainly'
export const retainly = new RetainlyServer(process.env.RETAINLY_API_KEY!, {
onError(err) {
console.error('[retainly] failed to send event', err)
},
})
// later
await retainly.track('user_signed_in', { userId: user.id })Next.js (App Router / Pages Router)
You still install only retainly. The patterns differ slightly depending on where you run code.
Route Handler (App Router)
// app/api/checkout/route.ts
import { RetainlyServer } from 'retainly'
const retainly = new RetainlyServer(process.env.RETAINLY_API_KEY!)
export async function POST(req: Request) {
// ... your logic
await retainly.track('checkout_started', {
userId: req.headers.get('x-user-id'),
properties: { path: new URL(req.url).pathname },
})
return Response.json({ ok: true })
}Server Action
'use server'
import { RetainlyServer } from 'retainly'
const retainly = new RetainlyServer(process.env.RETAINLY_API_KEY!)
export async function createProjectAction(input: { name: string; userId: string }) {
// ...create project
await retainly.track('project_created', { userId: input.userId, properties: { name: input.name } })
}Middleware (Edge)
In middleware you typically don’t want to block the response on analytics. Keep it simple and fire-and-forget.
// middleware.ts
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { buildServerEvent, DEFAULT_ENDPOINT } from 'retainly'
export function middleware(req: NextRequest) {
const userId = req.headers.get('x-user-id')
if (userId) {
const event = buildServerEvent('request', {
userId,
properties: { path: req.nextUrl.pathname, method: req.method },
})
fetch(`${DEFAULT_ENDPOINT}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.RETAINLY_API_KEY!,
},
body: JSON.stringify(event),
}).catch(() => {})
}
return NextResponse.next()
}React
Don’t put your Retainly API key in the browser. Instead, send events to your backend and call retainly.track(...) there.
Example: call your own API from React, then track server-side in that API route.
// React component
await fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'button_clicked', properties: { id: 'upgrade' } }),
})API reference
new RetainlyServer(apiKey, options?)
import { RetainlyServer } from 'retainly'
const retainly = new RetainlyServer('rt_test_...', {
endpoint: 'https://retainly-ingest.kashyap11ayush02.workers.dev/ingest',
onError(err, droppedEvents) {
console.error(err, droppedEvents)
},
})apiKey: required string.options.endpoint: optional ingest base URL. Defaults toDEFAULT_ENDPOINT(https://retainly-ingest.kashyap11ayush02.workers.dev/ingest).options.onError: optional callback when an event fails to send permanently or fails to serialize.
retainly.track(eventName, options?)
await retainly.track('api_request', {
userId: 'user_123',
accountId: 'org_456',
idempotencyKey: 'evt_789',
properties: { path: '/api/projects', method: 'POST' },
context: {
environment: process.env.NODE_ENV,
request: {
requestId: 'req_1',
path: '/api/projects',
method: 'POST',
status: 201,
durationMs: 42,
},
},
})retainly.identify(userId, traits?, options?)
await retainly.identify('user_123', { email: '[email protected]', plan: 'pro' }, { accountId: 'org_456' })This sends a $identify event with { userId, traits } in properties.
Utilities
This package also exports primitives you can use to build your own request instrumentation:
buildServerEvent(name, options?): constructs aRetainlyServerEvent.matchesRoute(path, matcher): route matching helper (string /*prefix /RegExp/ predicate).shouldTrackByMode(mode, status, durationMs, slowThresholdMs): shared “track gating” logic.userIdFromHeader(headerName),chainUserIdResolvers(...): helpers for resolving a stable user id from aRequest.
License
MIT — see the repository root.
