@sadeem-watchtower/node
v1.0.0
Published
Official Watchtower SDK for Node.js — captures errors and logs and ships them to the Watchtower ingestion API.
Readme
@sadeem-watchtower/node
Official Watchtower SDK for Node.js. Captures errors and logs from your backend and ships them to the Watchtower ingestion API — automatically, in the background, and without ever slowing down or crashing the host application.
- Native
fetchtransport — zero HTTP dependencies - Non-blocking background queue with exponential-backoff retries
- Silent local-log fallback when the server is unreachable
- Automatic capture of
uncaughtException/unhandledRejection - Structured logs at five levels
- First-class NestJS adapter (
@sadeem-watchtower/node/nestjs) - Works on Node 18+ (Express, Fastify, NestJS, or plain Node)
Installation
npm install @sadeem-watchtower/node
# or
pnpm add @sadeem-watchtower/nodeThe NestJS adapter additionally needs the Nest runtime you already have (@nestjs/common, @nestjs/core, rxjs, reflect-metadata) — these are optional peer dependencies, so plain-Node users install nothing extra.
Quick start
Plain Node
import { init, captureException, log, flush } from '@sadeem-watchtower/node';
init({
dsn: process.env.WATCHTOWER_DSN!, // https://<apiKey>@<host>/<projectId>
environment: 'production',
release: 'v1.4.2',
});
// Manual capture
try {
riskyThing();
} catch (err) {
captureException(err, { tags: { feature: 'checkout' } });
}
// Structured logs
log('info', 'order processed');
// Drain before a short-lived process exits (serverless, CLI)
await flush();uncaughtException and unhandledRejection are captured automatically once init() runs — no extra wiring.
NestJS
import { Module } from '@nestjs/common';
import { WatchtowerModule } from '@sadeem-watchtower/node/nestjs';
@Module({
imports: [
WatchtowerModule.forRoot({
dsn: process.env.WATCHTOWER_DSN!,
environment: process.env.NODE_ENV,
}),
],
})
export class AppModule {}That single import:
- boots the SDK,
- registers a global interceptor that auto-captures HTTP errors (5xx and any non-HTTP error; 4xx client errors are skipped),
- exposes the client via DI.
Inject it anywhere for manual capture/logging:
import { Inject, Injectable } from '@nestjs/common';
import { WATCHTOWER_CLIENT, type WatchtowerClient } from '@sadeem-watchtower/node/nestjs';
@Injectable()
export class OrdersService {
constructor(@Inject(WATCHTOWER_CLIENT) private readonly wt: WatchtowerClient) {}
process() {
this.wt.info('processing order');
}
}Configuration
Only dsn is required.
| Option | Default | Description |
| --- | --- | --- |
| dsn | — | Project DSN: https://<apiKey>@<host>/<projectId> |
| environment | — | e.g. "production" — attached to every event/log |
| release | — | App version / git SHA |
| serverName | — | Logical host name |
| tags | {} | Tags attached to every event |
| enabled | true | Master switch; false drops everything silently |
| debug | false | Emit internal SDK diagnostics to the console |
| sampleRate | 1 | Probability (0–1) an event is kept (logs are never sampled) |
| maxQueueSize | 100 | Buffered events before new ones are dropped |
| maxRetries | 3 | Retry attempts after the first send |
| requestTimeout | 5000 | Per-request network timeout (ms) |
| flushTimeout | 2000 | Max time flush() waits to drain (ms) |
| captureUncaughtExceptions | true | Install the uncaughtException handler |
| captureUnhandledRejections | true | Install the unhandledRejection handler |
| beforeSend | — | (event) => event \| null — scrub or drop events before queueing |
API
Global functions
init(options), captureException(error, context?), captureMessage(message, context?), log(level, message), flush(timeout?), close(timeout?), getClient(), isInitialized(), and scope helpers setTag, setTags, setUser, setExtra.
WatchtowerClient
The same surface as an instance (for advanced/multi-client use): captureException, captureMessage, log plus debug/info/warn/error/fatal, flush, close, and .scope.
Capture vs. log — two destinations
| Call | Endpoint | Sampled | beforeSend |
| --- | --- | --- | --- |
| captureException / captureMessage | /store/ (events) | yes | yes |
| log / debug…fatal | /logs/ (logs) | no | no |
Severity levels
debug · info · warn · error · fatal
⚠️ These follow the API DTO (
ingest-log.dto.ts), which useswarn/fatal— not thewarning/criticalnamed in the original spec doc. The code is the source of truth.
Internal mechanisms
This section is for contributors and anyone who wants to understand how the SDK behaves under the hood.
Guiding principle
The SDK must never slow down, block, or crash the host application.
Every design choice below follows from that. When in doubt, the SDK drops data or logs locally — it never propagates a failure into the host.
Architecture at a glance
init(options)
│ resolveOptions() ── validate DSN + apply defaults → frozen ResolvedConfig
▼
WatchtowerClient ── owns ── Scope (ambient tags/user/extra)
│
├─ captureException / captureMessage ─┐ sampling → beforeSend → buildEvent
│ ▼
│ Transport.sendEvent(event) → /store/
└─ log / debug…fatal ───────────────► Transport.sendLog(log) → /logs/
Transport
sendEvent/sendLog → BackgroundQueue.enqueue (instant, non-blocking)
│ serial drain loop (off the host's hot path)
▼
sendWithRetry → exponential backoff on "retryable"
▼
sendPayload → native fetch + AbortController
│ classifies: success | retryable | fatal
▼ (non-success after retries / queue full)
local-log fallback (never throws)The "never throws" contract
The transport stack is built so that no layer throws into the host:
sendPayload(transport/http.ts) returns a classifiedTransportResultfor every path — 2xx, bad status, network error, timeout abort. It never throws.sendWithRetryloops only onretryable; returns onsuccess/fatal.- The queue's drain loop wraps each worker call in try/catch.
- The fallback functions are themselves wrapped in try/catch.
Config resolution — the trust boundary
resolveOptions() (types/options.ts) runs once in the client constructor. It parses the DSN, validates sampleRate, applies every default, and returns a frozen ResolvedConfig. Every other module consumes ResolvedConfig, never raw user options — so downstream code never re-validates or sees undefined for an optional field. A bad DSN throws here, at startup, not at the first capture.
The transport pipeline (§ files in src/transport/)
http.ts— single request. Nativefetch+AbortControllerfor the timeout. Classifies the outcome:2xx→success429/5xx→retryable(parsesRetry-After, seconds or HTTP-date → ms)- other
4xx→fatal(bad key/payload — retrying can't help) - network error / timeout →
retryableThe timeout timer isunref()'d so an in-flight SDK request never keeps the host process alive.
retry.ts— resilience. Retriesretryableup tomaxRetrieswith equal-jitter backoff:delay = exp/2 + random()·exp/2,exp = min(maxDelay, base·2^attempt). Half fixed (guaranteed minimum wait), half random (avoids a thundering herd of crashing instances). A serverRetry-Afteroverrides the computed delay.queue.ts— non-blocking buffer.enqueue()pushes and returns synchronously (the host never awaits the network). A single serial drain loop processes items one-at-a-time. Bounded: atmaxQueueSizeit drops the newest item (rather than block or grow until OOM). A throwing worker can't kill the loop.fallback.ts— last resort. When delivery is exhausted or an item is dropped, the payload is written to the local logger (so it's still recoverable from the host's own logs). Transient failures log atwarn;fatalrejections (bad key/payload — an SDK/config bug) aterror.transport.ts— assembly + lifecycle. Wires queue→retry→http→fallback againstResolvedConfig, and exposesflush(timeout)/close(timeout).flushraces queue drain against a timeout so shutdown can't hang on a dead network.
Event construction (build.ts, scope.ts)
buildEvent() merges a base (exception or message) with config defaults, the scope snapshot, and per-call context. Precedence: context > scope > config. errorToException() normalizes any thrown value (Error or not).
Stack parsing (stacktrace.ts)
parseStack() turns a V8 error.stack into structured frames (function, filename, lineno, colno), handling the parens form, bare locations, the async prefix, and native frames. Frames are returned oldest-first (throwing frame last). Falls back to { raw } if a stack can't be parsed — never loses data.
Global handlers (handlers.ts)
installGlobalHandlers() attaches uncaughtException / unhandledRejection listeners.
- uncaughtException: capture → flush → then preserve Node's default crash by
exit(1)— only if no otheruncaughtExceptionlistener exists (so we never override a host that manages its own). Overridable viaexitOnUncaughtException: false. - unhandledRejection: capture the reason; no forced exit.
The process object is injectable, which is how the handlers are tested without touching the real one.
The globalThis carrier (global.ts)
The singleton client/handlers live on globalThis[Symbol.for('@sadeem-watchtower/node:carrier')], not in module-local variables. The package ships two bundles (main + /nestjs), and a consumer's bundler may include a copy of global.ts in each. Storing state on the carrier guarantees they all share one client — so captureException() imported from @sadeem-watchtower/node sees the client that WatchtowerModule created.
The NestJS adapter (src/nestjs/)
WatchtowerModule.forRoot(options, deps?)— aDynamicModule(not an app).forRootcallsinit()(setting the global client + handlers) and provides the client under theWATCHTOWER_CLIENTtoken.- DI uses an explicit string token +
@Inject— not type-based injection. The esbuild-based build does not emitdesign:paramtypesmetadata, so type-based DI would fail at runtime; the explicit token sidesteps that entirely. WatchtowerInterceptortaps each handler's error stream (catchError), captures, then re-throws unchanged — it observes, it does not handle, so Nest's normal exception/response flow is untouched.- It duck-types
err.getStatus()rather thaninstanceof HttpException, because a consumer may resolve a different@nestjs/commoncopy/version (which breaksinstanceof).
Contributing
Repository layout
src/
types/ options, event & log payloads, severity levels (§1)
dsn.ts DSN parser
transport/ http · retry · queue · fallback · transport (§2)
logger.ts internal/console logger
scope.ts ambient context
build.ts Error → event construction (§3)
client.ts WatchtowerClient
global.ts init() + global API (globalThis carrier)
stacktrace.ts V8 stack parser (§4)
handlers.ts uncaughtException / unhandledRejection
nestjs/ NestJS adapter (built to the /nestjs subpath) (§5)Build & checks
pnpm build # tsup → dual ESM + CJS + .d.ts, two entries (main + nestjs)
pnpm typecheck # tsc --noEmit
pnpm dev # tsup --watchThe package targets Node 18 (tsconfig lib ES2022, tsup target: node18), so the type checker enforces the support floor.
Testing & the no-build dev loop
Tests live in the sibling sdk-test-app (a real NestJS app that consumes the SDK via a link: dependency). Its Jest config maps @sadeem-watchtower/node to the SDK's TypeScript source, so:
cd ../../sdk-test-app && pnpm testruns the suite against src/ directly — no SDK rebuild needed between edits.
Testing philosophy: every side-effecting dependency is injectable — fetchImpl, sleep, random, sampleRandom, now, logger, and process. Tests pass deterministic doubles, so the whole suite runs instantly with no real network, no real timers, and no flakiness. New code should follow the same pattern: take its effects as injectable deps with production defaults.
Adding a framework adapter
- Create
src/<framework>/importing only what it needs from the core. - Add an entry in
tsup.config.tsand a matchingexports["./<framework>"]block (mirroring the/nestjsone) inpackage.json. - Declare framework packages as optional
peerDependencies. - Keep the core framework-free — adapters depend on the core, never the reverse.
