Integrating Watchtower into a NestJS App
Unhandled errors in a NestJS API tend to be discovered the same way: a user complains, someone greps the logs, and by the time anyone's looking, the request context is gone. @sadeem-watchtower/node closes that gap — it captures unhandled HTTP errors, process-level crashes, and manual events, and ships them to a dashboard instead of a log file nobody's tailing.
This post walks through wiring it into a NestJS app: installation, config validation, module registration, and the shutdown handling that keeps you from dropping events on deploy. It also covers a couple of non-obvious integration details that aren't in the SDK's README.

Once this is wired up you get, for free:
- Automatic capture of unhandled HTTP errors (5xx) via a global interceptor
- Automatic capture of
uncaughtException/unhandledRejection - A DI-injectable client (
WATCHTOWER_CLIENT) for manualcaptureException/ structured logging - A clean shutdown that flushes buffered events before the process exits
1. Install
pnpm add @sadeem-watchtower/node
@nestjs/common, @nestjs/core, reflect-metadata, and rxjs are peer dependencies — a NestJS project already has these. The NestJS adapter lives under the @sadeem-watchtower/node/nestjs subpath so plain Node consumers aren't forced to pull in Nest.
Requirements: Node >= 18, @nestjs/common / @nestjs/core >= 10.
2. Get a DSN
Every project in the Watchtower dashboard has a DSN — the credential that tells the SDK which project to ship events to:
https://<public_key>@<host>/<project_id>
Put it in .env (never hardcode it), and document a placeholder in .env.example:
# .env
WATCHTOWER_DSN=https://wt_xxxxxxxx@watchtower.sadeeminfo.com/your-project-id
WATCHTOWER_DEBUG=false
.env should be gitignored — only .env.example gets committed, with a placeholder DSN.
3. Validate the env vars
Add both vars to your env validation class so the app refuses to boot with a missing or malformed DSN instead of silently dropping every event:
@IsNotEmpty()
@IsUrl({ require_tld: false, protocols: ['http', 'https'] })
WATCHTOWER_DSN: string;
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true')
WATCHTOWER_DEBUG: boolean;
DSNs point at internal hosts like watchtower.sadeeminfo.com, and some class-validator IsUrl configs reject internal-looking hostnames without a public TLD depending on the rest of the options.
4. Register the module
WatchtowerModule.forRoot(options) is a global dynamic module. It does three things in one call:
- Calls the SDK's
init()— boots the client, installs the globaluncaughtException/unhandledRejectionhandlers, registers the global free-function client. - Exposes the client through DI under the
WATCHTOWER_CLIENTtoken. - Registers
WatchtowerInterceptoras anAPP_INTERCEPTORso HTTP errors are captured automatically.
In src/app.module.ts:
import { WatchtowerModule } from '@sadeem-watchtower/node/nestjs';
@Module({
imports: [
ConfigModule.forRoot({
validate,
expandVariables: true,
isGlobal: true,
cache: true,
load: [
/* ...other config factories */
],
}),
// Must come after ConfigModule.forRoot() above: forRoot() has no async
// variant, so it reads WATCHTOWER_DSN off process.env synchronously —
// ConfigModule.forRoot() has already loaded .env into process.env by then.
WatchtowerModule.forRoot({
dsn: process.env.WATCHTOWER_DSN!,
environment: process.env.NODE_ENV ?? 'development',
debug: process.env.WATCHTOWER_DEBUG === 'true',
}),
// ...rest of the imports
],
})
export class AppModule {}
Why process.env directly, and not a registerAs config factory
Every other integration in a typical Nest app (app.config.ts, redis.config.ts, etc.) reads env vars through a registerAs factory plus ConfigType injection. Watchtower can't follow that pattern: WatchtowerModule only exposes forRoot(options) — there's no forRootAsync, so there's no useFactory / inject hook to pull a ConfigService value in. The options object has to be fully resolved synchronously at the point WatchtowerModule.forRoot(...) is evaluated.
This works because ConfigModule.forRoot() also loads .env into process.env synchronously, inside its own forRoot() call — and JS evaluates array literal elements left-to-right. So as long as WatchtowerModule.forRoot() appears after ConfigModule.forRoot() in the imports array, process.env.WATCHTOWER_DSN is already populated by the time it's read.
Reordering the two forRoot() calls (or reading process.env before ConfigModule.forRoot() runs) reads undefined and fails env validation before the app even starts. Don't add a registerAs('watchtower', ...) config file for this either — it would never be injected anywhere and is dead code.
Common forRoot options
| Option | Default | Purpose |
|---|---|---|
dsn (required) | — | Project DSN from the dashboard |
environment | — | e.g. "production", "development" |
release | — | App version / git SHA, 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 error event is kept |
captureUncaughtExceptions | true | Install uncaughtException handler |
captureUnhandledRejections | true | Install unhandledRejection handler |
beforeSend | — | Inspect/modify/drop each event (return null to drop) |
5. What you get automatically
Once the module is registered, unhandled errors from HTTP handlers are captured for free by WatchtowerInterceptor — no code in your controllers.
The interceptor is an observer: it taps the error stream, captures the error, then re-throws it unchanged — exception filters and response shaping are untouched.
It's deliberately quiet about expected client errors:
HttpExceptionwith status < 500 (400/401/404/…) → skipped (noise)HttpExceptionwith status >= 500 → captured- Any non-
HttpExceptionthrown error → captured
Each captured HTTP error is tagged framework: "nestjs" and enriched with the request method and url.
6. Manual capture & logging via DI
Inject the client anywhere using the WATCHTOWER_CLIENT token — a string token, not the class, so DI doesn't depend on emitted decorator metadata:
import { Inject, Injectable } from '@nestjs/common';
import {
WATCHTOWER_CLIENT,
type WatchtowerClient,
} from '@sadeem-watchtower/node/nestjs';
@Injectable()
export class SomeService {
constructor(
@Inject(WATCHTOWER_CLIENT) private readonly watchtower: WatchtowerClient,
) {}
captureHandled(): void {
try {
doRiskyThing();
} catch (err) {
this.watchtower.captureException(err, { tags: { source: 'demo' } });
}
}
}
The client also exposes captureMessage, level helpers (debug / info / warn / error / fatal), log(level, message), flush(), and close().
You can also use the free functions exported from @sadeem-watchtower/node (captureException, log, setUser, …) — they operate on the global client forRoot() initialised, so they work outside Nest's DI graph too (cron jobs, scripts, etc.).
7. Graceful shutdown
Watchtower buffers events and logs in memory and ships them asynchronously, so an immediate process.exit() on shutdown can drop whatever hasn't sent yet. Close the Nest app first, then flush Watchtower, then actually exit.
In src/main.ts:
import { close as closeWatchtower } from '@sadeem-watchtower/node';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// ...
app.enableShutdownHooks();
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
void (async () => {
await app.close();
await closeWatchtower(); // flush buffered events before exit
process.exit(0);
})();
});
}
await app.listen(appEnv.port);
}
close() accepts a timeout in ms (flushTimeout, default 2000ms) so a slow or unreachable ingestion endpoint can't hang shutdown forever.
If you also want every Logger.log/warn/error/... call (yours and Nest's own framework logs) forwarded into Watchtower, provide a custom LoggerService that extends ConsoleLogger and also calls the SDK's log(), then wire it in main.ts with bufferLogs: true plus app.useLogger(...). Skip this unless you have a concrete need — it doubles every log line through the SDK's queue.
8. Verify it works
- Start the app in dev mode.
- Confirm the module wired up — you should see in the boot log:
[InstanceLoader] WatchtowerModule dependencies initialized - Trigger an error path (temporarily add a route that throws, or use an existing 500 path) and confirm the event lands in the dashboard.
- Set
WATCHTOWER_DEBUG=truetemporarily if you need to see the SDK's own diagnostics ([watchtower] event delivered (status ...),[watchtower] event undelivered after retries...). Revert tofalseonce confirmed — the debug-level "delivered" line fires on every single event and is noisy in normal operation. Delivery failures (warn/errorlevel) are logged regardless of the debug flag, so you don't needdebug: trueon to notice something's wrong — only to see the happy-path confirmation.
You can sanity-check the DSN itself independent of the app by POSTing directly to the store endpoint:
curl -i -X POST "https://<host>/api/<project_id>/store/" \
-H "x-api-key: <public_key>" \
-H "Content-Type: application/json" \
-d '{"test":true}'
A 201 {"message":"Event accepted",...} response confirms the DSN, API key, and network path are all valid — useful for isolating "is it my app or is it the DSN" when nothing shows up in the dashboard.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Nothing arrives in the dashboard | dsn wrong/empty, enabled: false, or outbound network blocked. Set debug: true and curl the DSN directly (step 8) to isolate. |
| 4xx errors not captured | By design — the interceptor skips HttpException < 500. |
| Events lost on shutdown | Missing the close() call in the signal handler (step 7). |
Cannot resolve WATCHTOWER_CLIENT | WatchtowerModule.forRoot() not imported in AppModule. |
DSN reads as undefined at boot | WatchtowerModule.forRoot() registered before ConfigModule.forRoot() in the imports array, or .env not loaded at all (see step 4). |
App won't boot: WATCHTOWER_DSN env validation error | DSN missing from .env, or fails the IsUrl check (malformed, missing protocol). |
Production Checklist
- DSN loaded from
.env, never hardcoded, with a placeholder in.env.example -
WATCHTOWER_DSN/WATCHTOWER_DEBUGvalidated at startup -
WatchtowerModule.forRoot()placed afterConfigModule.forRoot()inAppModuleimports -
WATCHTOWER_DEBUG=falsein production (only flip on temporarily to verify delivery) - Graceful shutdown flushes Watchtower before
process.exit() - DSN sanity-checked with a direct curl to the store endpoint if events aren't showing up
Conclusion
The integration is small — one module registration, one env validation block, one shutdown hook — but each piece has a reason it's placed where it is. The two details worth remembering: WatchtowerModule.forRoot() must come after ConfigModule.forRoot() because it reads process.env synchronously, and shutdown must flush before it exits or you lose whatever was still in flight.
Reference
- npm package:
@sadeem-watchtower/node - NestJS adapter subpath:
@sadeem-watchtower/node/nestjs - Files typically touched:
src/app.module.ts,src/main.ts,src/env.validation.ts,.env.example
