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

@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 fetch transport — 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/node

The 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 uses warn/fatal — not the warning/critical named 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 classified TransportResult for every path — 2xx, bad status, network error, timeout abort. It never throws.
  • sendWithRetry loops only on retryable; returns on success/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/)

  1. http.ts — single request. Native fetch + AbortController for the timeout. Classifies the outcome:

    • 2xxsuccess
    • 429 / 5xxretryable (parses Retry-After, seconds or HTTP-date → ms)
    • other 4xxfatal (bad key/payload — retrying can't help)
    • network error / timeout → retryable The timeout timer is unref()'d so an in-flight SDK request never keeps the host process alive.
  2. retry.ts — resilience. Retries retryable up to maxRetries with 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 server Retry-After overrides the computed delay.

  3. 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: at maxQueueSize it drops the newest item (rather than block or grow until OOM). A throwing worker can't kill the loop.

  4. 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 at warn; fatal rejections (bad key/payload — an SDK/config bug) at error.

  5. transport.ts — assembly + lifecycle. Wires queue→retry→http→fallback against ResolvedConfig, and exposes flush(timeout) / close(timeout). flush races 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 other uncaughtException listener exists (so we never override a host that manages its own). Overridable via exitOnUncaughtException: 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?) — a DynamicModule (not an app). forRoot calls init() (setting the global client + handlers) and provides the client under the WATCHTOWER_CLIENT token.
  • DI uses an explicit string token + @Inject — not type-based injection. The esbuild-based build does not emit design:paramtypes metadata, so type-based DI would fail at runtime; the explicit token sidesteps that entirely.
  • WatchtowerInterceptor taps 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 than instanceof HttpException, because a consumer may resolve a different @nestjs/common copy/version (which breaks instanceof).

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 --watch

The 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 test

runs 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

  1. Create src/<framework>/ importing only what it needs from the core.
  2. Add an entry in tsup.config.ts and a matching exports["./<framework>"] block (mirroring the /nestjs one) in package.json.
  3. Declare framework packages as optional peerDependencies.
  4. Keep the core framework-free — adapters depend on the core, never the reverse.