@abdoseadaa/raqib
v0.3.0
Published
Lightweight SDK for sending events to your tracker service
Downloads
60
Maintainers
Readme
@abdoseadaa/raqib
Lightweight event-tracking SDK. Works in Node.js microservices and the browser. Two lines of setup, one line per event.
npm install @abdoseadaa/raqibQuick start
import { init } from "@abdoseadaa/raqib"
const tracker = init({
serviceUrl: process.env.TRACKER_URL,
apiKey: process.env.TRACKER_KEY,
})
tracker.on("payment.initiated", { userId: "u_123", amount: 99 })That's it. The event is buffered and sent automatically in the background — your code never waits on the tracker.
Table of contents
- Installation
- init()
- tracker.on()
- tracker.setContext()
- tracker.hook()
- tracker.trackError()
- env callback
- Encryption
- Graceful shutdown
- Backend middleware
- How batching works
- Wire format
- Config reference
Installation
npm install @abdoseadaa/raqibRequirements: Node.js 18+ (uses native fetch and crypto.subtle)
init()
Creates and returns a Tracker instance bound to your config. Throws synchronously if any required field is missing — misconfiguration is caught at startup, not silently at runtime.
import { init } from "@abdoseadaa/raqib"
const tracker = init({
serviceUrl: process.env.TRACKER_URL, // required
apiKey: process.env.TRACKER_KEY, // required
})You can create multiple independent instances in the same process — each carries its own config, queue, context, and hooks. This is useful when sending to different tracker service deployments with different base URLs:
const stagingTracker = init({ serviceUrl: "https://tracker.staging.internal", apiKey: "et_live_stg_..." })
const productionTracker = init({ serviceUrl: "https://tracker.prod.internal", apiKey: "et_live_prd_..." })The
apiKeycontrols authentication only — it does not route events to a different destination. If you are sending to the same service URL, one instance is all you need.
Higher-order event senders
The more useful pattern is creating pre-bound senders from a single instance. Instead of repeating the event name everywhere, wrap it in a function that only takes the payload:
const tracker = init({
serviceUrl: process.env.TRACKER_URL,
apiKey: process.env.TRACKER_KEY,
})
// Pre-bind event names — callers only pass the payload
const onPaymentInitiated = (payload: Record<string, unknown>) => tracker.on("payment.initiated", payload)
const onPaymentSucceeded = (payload: Record<string, unknown>) => tracker.on("payment.succeeded", payload)
const onPaymentFailed = (payload: Record<string, unknown>) => tracker.on("payment.failed", payload)
const onUserSignedIn = (payload: Record<string, unknown>) => tracker.on("user.signed_in", payload)
// Usage — clean, no event name string to remember or mistype
onPaymentInitiated({ userId: "u_123", amount: 99 })
onPaymentSucceeded({ userId: "u_123", transactionId: "txn_456" })
onUserSignedIn({ userId: "u_123", method: "email" })Or use a factory to generate them:
function bindEvent(eventName: string) {
return (payload?: Record<string, unknown>) => tracker.on(eventName, payload ?? {})
}
const events = {
payment: {
initiated: bindEvent("payment.initiated"),
succeeded: bindEvent("payment.succeeded"),
failed: bindEvent("payment.failed"),
},
user: {
signedIn: bindEvent("user.signed_in"),
signedOut: bindEvent("user.signed_out"),
},
}
// Usage — fully typed, autocomplete-friendly
events.payment.initiated({ userId: "u_123", amount: 99 })
events.user.signedIn({ userId: "u_123", method: "oauth" })tracker.on()
Fire-and-forget event tracking. Always returns synchronously. Never throws.
tracker.on("event.name")
tracker.on("event.name", { key: "value" })Examples:
// Simple event
tracker.on("page.viewed", { page: "/dashboard" })
// With nested properties
tracker.on("order.placed", {
orderId: "ord_001",
total: 149,
items: [{ sku: "SKU-01", qty: 2 }],
})
// No properties needed
tracker.on("app.started")tracker.setContext()
Sets persistent fields that are automatically merged into every subsequent event. Calling it again extends (never replaces) the existing context.
tracker.setContext({ userId: "u_123", plan: "pro" })
tracker.on("page.viewed", { page: "/dashboard" })
// → properties: { userId: "u_123", plan: "pro", page: "/dashboard" }
tracker.on("button.clicked", { button: "upgrade" })
// → properties: { userId: "u_123", plan: "pro", button: "upgrade" }
// Extend context later — merges with existing
tracker.setContext({ sessionId: "sess_abc" })
// Now every event also carries sessionIdPriority: setContext fields are overridden by hook() and by explicit on() properties.
tracker.hook()
Registers a dynamic callback that is called fresh on every event. Returns an unregister function.
Unlike setContext() which stores static values, a hook runs a function each time — so it can read live request data, session state, cookies, or anything else available in the current scope.
const unhook = tracker.hook(() => ({
userId: getCurrentUserId(),
sessionId: getCurrentSessionId(),
}))
tracker.on("page.viewed", { page: "/profile" })
// → properties: { userId: "u_123", sessionId: "sess_abc", page: "/profile" }
unhook() // remove the hook when doneUse in Express middleware
The most powerful use case — inject request-scoped data without touching every tracker.on() call:
import express from "express"
const app = express()
app.use((req, res, next) => {
const unhook = tracker.hook(() => ({
userId: req.user?.id,
requestId: req.headers["x-request-id"],
ip: req.ip,
userAgent: req.headers["user-agent"],
}))
res.on("finish", unhook) // automatically clean up when response ends
next()
})
// Now every tracker.on() in any route automatically carries userId and requestId
app.post("/api/checkout", (req, res) => {
tracker.on("checkout.started", { cartId: req.body.cartId })
// → properties: { userId: "u_123", requestId: "req_abc", ip: "...", cartId: "cart_1" }
})Multiple hooks
Each hook() call registers an independent callback. All hooks run on every event and their results merge:
const unhook1 = tracker.hook(() => ({ userId: req.user.id }))
const unhook2 = tracker.hook(() => ({ region: getRegion() }))
const unhook3 = tracker.hook(() => ({ featureFlags: getFlags() }))
// All three inject into every event
// Clean up each independently
res.on("finish", () => { unhook1(); unhook2(); unhook3() })Priority: hook data overrides setContext but is overridden by explicit on() properties.
tracker.trackError()
Shorthand for exception tracking. Normalises an Error object into { name, message, stack } and fires it under the reserved event name sdk.error.
try {
await chargeCard({ userId: "u_123", amount: 99 })
} catch (err) {
tracker.trackError(err as Error, { route: "/api/checkout", userId: "u_123" })
}The resulting event:
{
"event_name": "sdk.error",
"properties": {
"route": "/api/checkout",
"userId": "u_123",
"error": {
"name": "CardDeclinedError",
"message": "Card was declined by the issuer",
"stack": "CardDeclinedError: ...\n at ..."
}
}
}Any active setContext or hook data is also merged in automatically.
env callback
A function defined once in init() that runs on every event and injects its result into properties.env. Use it for metadata you always want attached: timestamps, runtime info, browser data, session identifiers.
// Node.js / backend
const tracker = init({
serviceUrl: process.env.TRACKER_URL,
apiKey: process.env.TRACKER_KEY,
env: () => ({
timestamp: new Date().toISOString(),
serviceName: "payment-service",
nodeVersion: process.version,
env: process.env.NODE_ENV,
}),
})// Browser / frontend
const tracker = init({
serviceUrl: process.env.TRACKER_URL,
apiKey: process.env.TRACKER_KEY,
env: () => ({
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
language: navigator.language,
screenSize: `${window.innerWidth}x${window.innerHeight}`,
userId: localStorage.getItem("userId"),
sessionId: sessionStorage.getItem("sessionId"),
}),
})The env object is deep-merged from all sources. Priority (lowest → highest):
config.env() callback
↓ merged with
setContext({ env: { ... } })
↓ merged with
hook() returning { env: { ... } }
↓ merged with
tracker.on("x", { env: { ... } }) ← explicit call always winsEncryption
When encryptionKey is set, the entire batch payload is AES-256-GCM encrypted before sending. The network tab shows only an opaque base64 blob — event names, property keys, and values are all hidden.
const tracker = init({
serviceUrl: process.env.TRACKER_URL,
apiKey: process.env.TRACKER_KEY,
encryptionKey: process.env.TRACKER_ENCRYPTION_KEY,
})What the network sees:
{ "encrypted": true, "d": "A1b2C3d4...base64gibberish...==" }Key rules:
- Any string length — the key is SHA-256 hashed internally to produce a 32-byte AES-256 key
- Both sides must use the exact same string
- Store it as an environment variable — never hardcode it
- Generate a strong key:
openssl rand -base64 32
Backend decryption — use the included middleware (see Backend middleware) or implement manually:
async function decryptPayload(d: string, encryptionKey: string) {
const keyBytes = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(encryptionKey)
)
const key = await crypto.subtle.importKey(
"raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]
)
const combined = Buffer.from(d, "base64")
const iv = combined.subarray(0, 12) // first 12 bytes = IV
const ciphertext = combined.subarray(12) // rest = ciphertext
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext)
return JSON.parse(new TextDecoder().decode(plaintext))
// → { events: [{ event_name: "...", properties: { ... } }] }
}Graceful shutdown
The SDK runs a background flush timer. On process termination, stop the timer and drain the buffer so no events are lost:
process.on("SIGTERM", async () => {
tracker.destroy() // stop background timer
await tracker.flush() // send remaining buffered events
process.exit(0)
})You can also call flush() at any time to force an immediate send:
// After a critical event, flush immediately without waiting for the timer
tracker.on("payment.succeeded", { transactionId: "txn_456" })
await tracker.flush()Backend middleware
Import the Express middleware to transparently decrypt incoming payloads from SDK clients that have encryptionKey set.
import express from "express"
import { decryptMiddleware } from "@abdoseadaa/raqib/middleware"
const app = express()
app.use(express.json())
app.use(decryptMiddleware(process.env.TRACKER_ENCRYPTION_KEY))
app.post("/v1/events/webhook/bulk", (req, res) => {
// req.body is always plain JSON here — decrypted transparently if needed
const { events } = req.body
// events → [{ event_name: "...", properties: { ... } }, ...]
})Unencrypted requests pass through untouched. The middleware handles both encrypted and plain payloads on the same route.
Error responses:
| Scenario | Status | Code |
|---|---|---|
| encrypted: true but no d field | 400 | INVALID_ENCRYPTED_PAYLOAD |
| Wrong key or corrupted data | 400 | DECRYPT_FAILED |
| Not encrypted | Pass-through | — |
How batching works
tracker.on() never blocks. Events go into an in-memory queue and are sent in batches automatically.
tracker.on("x", {...})
↓
in-memory buffer
↓
flush when EITHER:
→ flushIntervalMs elapsed (default: 2000ms) time trigger
→ batchSize events buffered (default: 20) count trigger
↓
POST /v1/events/webhook/bulk
{ events: [...up to 20...] }
↓
retry on failure (up to 3×, exponential backoff + jitter)Time trigger — prevents events from going stale in low-traffic periods. Count trigger — prevents oversized payloads during traffic spikes.
Tune both in init():
const tracker = init({
serviceUrl: process.env.TRACKER_URL,
apiKey: process.env.TRACKER_KEY,
flushIntervalMs: 5000, // flush every 5s (default: 2000)
batchSize: 50, // or when 50 events buffer up (default: 20)
})Wire format
Every flush is a single POST request:
POST /v1/events/webhook/bulk
Authorization: Bearer <apiKey>
Content-Type: application/jsonPlain (no encryption):
{
"events": [
{
"event_name": "payment.initiated",
"properties": {
"userId": "u_123",
"amount": 99,
"env": {
"timestamp": "2026-04-15T10:23:45.123Z",
"serviceName": "payment-service"
}
}
},
{
"event_name": "payment.succeeded",
"properties": {
"userId": "u_123",
"transactionId": "txn_456",
"env": {
"timestamp": "2026-04-15T10:23:45.890Z",
"serviceName": "payment-service"
}
}
}
]
}Encrypted (encryptionKey set):
{
"encrypted": true,
"d": "A1b2C3d4E5f6...base64gibberish...=="
}Config reference
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| serviceUrl | string | Yes | — | Base URL of your tracker service |
| apiKey | string | Yes | — | Org API key (et_live_…) sent as Authorization: Bearer |
| env | () => object | No | — | Called on every event — result injected into properties.env |
| encryptionKey | string | No | — | AES-256-GCM encrypt the batch before sending |
| flushIntervalMs | number | No | 2000 | Max ms an event waits before being sent |
| batchSize | number | No | 20 | Flush immediately when buffer reaches this size |
