Skip to main content

Integrating Watchtower into a NestJS App

· 8 min read
Mohamed El Amine Meghni
Mohamed El Amine Meghni
Software & DevOps Engineer

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.

Watchtower NestJS integration

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 manual captureException / 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;
require_tld: false matters

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:

  1. Calls the SDK's init() — boots the client, installs the global uncaughtException / unhandledRejection handlers, registers the global free-function client.
  2. Exposes the client through DI under the WATCHTOWER_CLIENT token.
  3. Registers WatchtowerInterceptor as an APP_INTERCEPTOR so 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.

Order matters

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

OptionDefaultPurpose
dsn (required)Project DSN from the dashboard
environmente.g. "production", "development"
releaseApp version / git SHA, attached to every event
enabledtrueMaster switch; false drops everything silently
debugfalseEmit internal SDK diagnostics to the console
sampleRate1Probability (0..1) an error event is kept
captureUncaughtExceptionstrueInstall uncaughtException handler
captureUnhandledRejectionstrueInstall unhandledRejection handler
beforeSendInspect/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:

  • HttpException with status < 500 (400/401/404/…) → skipped (noise)
  • HttpException with status >= 500 → captured
  • Any non-HttpException thrown 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.

Optional: mirror all Nest Logger output

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

  1. Start the app in dev mode.
  2. Confirm the module wired up — you should see in the boot log:
    [InstanceLoader] WatchtowerModule dependencies initialized
  3. Trigger an error path (temporarily add a route that throws, or use an existing 500 path) and confirm the event lands in the dashboard.
  4. Set WATCHTOWER_DEBUG=true temporarily if you need to see the SDK's own diagnostics ([watchtower] event delivered (status ...), [watchtower] event undelivered after retries...). Revert to false once confirmed — the debug-level "delivered" line fires on every single event and is noisy in normal operation. Delivery failures (warn/error level) are logged regardless of the debug flag, so you don't need debug: true on 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

SymptomLikely cause
Nothing arrives in the dashboarddsn wrong/empty, enabled: false, or outbound network blocked. Set debug: true and curl the DSN directly (step 8) to isolate.
4xx errors not capturedBy design — the interceptor skips HttpException < 500.
Events lost on shutdownMissing the close() call in the signal handler (step 7).
Cannot resolve WATCHTOWER_CLIENTWatchtowerModule.forRoot() not imported in AppModule.
DSN reads as undefined at bootWatchtowerModule.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 errorDSN missing from .env, or fails the IsUrl check (malformed, missing protocol).

Production Checklist

Before you ship
  • DSN loaded from .env, never hardcoded, with a placeholder in .env.example
  • WATCHTOWER_DSN / WATCHTOWER_DEBUG validated at startup
  • WatchtowerModule.forRoot() placed after ConfigModule.forRoot() in AppModule imports
  • WATCHTOWER_DEBUG=false in 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