Skip to content

Repository files navigation

WarnWire

A lightweight alert orchestration layer for Node.js applications.

WarnWire converts important application failures into controlled, actionable notifications for Discord, Telegram, Resend email, and generic webhooks. It redacts sensitive values, groups duplicate errors, routes by severity, applies bounded retries, and supports explicit recovery notifications.

WarnWire complements Pino rather than replacing it. Pino should keep recording structured logs efficiently; WarnWire should receive the smaller set of events that may need a human response.

The problem

Sending every log line directly to a chat platform is dangerous. A repeated database or dependency failure can create thousands of outbound requests, bury the first useful alert, trigger provider rate limits, and make an incident channel unreadable. WarnWire places a small control layer between application errors and notification providers.

Installation

npm install warnwire

Node.js 20 or newer is required. Pino and Express are optional peer dependencies; install only the integrations you use.

If the unscoped warnwire npm name is unavailable at publication time, publish the unchanged source under a scope such as @username/warnwire and update the package/import names.

Five-minute quick start

import { createWarnWire, discordProvider } from "warnwire";

const warnwire = createWarnWire({
  service: "payment-api",
  environment: "production",
  providers: [
    discordProvider({
      webhookUrl: process.env.DISCORD_WEBHOOK_URL!,
    }),
  ],
  routes: [
    {
      levels: ["warn", "error", "fatal"],
      providers: ["discord"],
    },
  ],
});

await warnwire.warn("Queue depth is increasing", {
  context: { queue: "payments", depth: 1_240 },
  tags: ["queue"],
});

try {
  throw new Error("Payment processor unavailable");
} catch (error) {
  const result = await warnwire.error(error, {
    context: { orderId: "order_123", requestId: "request_456" },
    tags: ["payments"],
  });

  // Call this only after the application has positively observed recovery.
  await warnwire.resolve(result.fingerprint, {
    message: "Payment processor connectivity restored",
  });
}

await warnwire.close();

Alert calls normalize and enqueue work; they do not wait for provider network requests. Use flush() or close() when delivery completion matters.

Processing architecture

flowchart TD
  A["Application / Pino / Express"] --> B["Normalize input"]
  B --> C["Serialize and redact"]
  C --> D["Fingerprinting"]
  D --> E["Incident deduplication"]
  E --> F["Routing"]
  F --> G["Delivery queue"]
  G --> H["Retry + timeout handling"]
  H --> I["Discord / Telegram / Email / Webhook"]
Loading

Providers

All network providers use native fetch, AbortController, and the configured request timeout.

Discord

import { discordProvider } from "warnwire";

discordProvider({
  name: "discord",
  webhookUrl: process.env.DISCORD_WEBHOOK_URL!,
  username: "WarnWire",
});

Discord receives compact plain-text webhook messages with mentions disabled.

Telegram

import { telegramProvider } from "warnwire";

telegramProvider({
  name: "telegram",
  botToken: process.env.TELEGRAM_BOT_TOKEN!,
  chatId: process.env.TELEGRAM_CHAT_ID!,
  disableWebPagePreview: true,
});

Telegram messages use plain text to avoid fragile Markdown escaping.

Email through Resend

import { resendEmailProvider } from "warnwire";

resendEmailProvider({
  name: "email",
  apiKey: process.env.RESEND_API_KEY!,
  from: "alerts@example.com",
  to: ["developer@example.com"],
  cc: ["team@example.com"],
  subjectPrefix: "Production",
});

This provider targets the Resend HTTP API. It is not an SMTP or Nodemailer adapter.

Generic webhook

import { webhookProvider } from "warnwire";

webhookProvider({
  name: "internal-webhook",
  url: process.env.INTERNAL_WEBHOOK_URL!,
  headers: {
    authorization: `Bearer ${process.env.INTERNAL_WEBHOOK_TOKEN}`,
  },
});

The webhook body is { schemaVersion: "1", event: ProviderAlert }. Request signing is intentionally out of scope in version 0.1.

Routing

Routes are static and intentionally small:

routes: [
  { levels: ["warn"], providers: ["discord"] },
  {
    levels: ["error", "fatal"],
    providers: ["discord", "telegram"],
  },
  { levels: ["fatal"], providers: ["email"] },
  {
    levels: ["error"],
    providers: ["internal-webhook"],
    tagsAny: ["payments", "database"],
  },
];

Overlapping routes are allowed. Each matching provider receives at most one delivery, in provider declaration order. An alert that matches no route returns reason: "no-route".

Deduplication and suppression summaries

The first event for a fingerprint is delivered immediately. Equivalent events inside windowMs are suppressed and counted:

deduplication: {
  enabled: true,
  windowMs: 5 * 60_000,
  summaryIntervalMs: 5 * 60_000,
}

A single unreferenced maintenance interval sends a summary when new suppressed occurrences exist. Summaries contain the original message, total and newly suppressed counts, first/last seen timestamps, and current duration. They go directly to the incident's original providers and are not deduplicated again.

Deduplication is local to one Node.js process. Disable it with deduplication.enabled: false.

The default fingerprint uses service, error name, normalized message, and a small set of stack frames. UUIDs, long hexadecimal identifiers, numeric identifiers, and repeated whitespace are normalized before SHA-256 hashing. Only the opaque ww_... prefix is exposed. A custom value is also normalized and hashed:

const result = await warnwire.error(error, {
  fingerprint: "primary-database-connectivity",
});

Recovery notifications

WarnWire never guesses that an incident recovered. Resolve it after your own health check or successful operation confirms recovery:

const recovery = await warnwire.resolve(result.fingerprint, {
  message: "Primary database connectivity restored",
  context: { region: "ap-south-1" },
});

The recovery message includes the original alert, duration, first/resolved timestamps, and total occurrence count. Missing and already-resolved incidents return non-throwing results.

Retry and timeout behavior

retry.attempts means total attempts, including the initial request:

retry: {
  attempts: 3,
  baseDelayMs: 250,
  maxDelayMs: 5_000,
  jitter: true,
},
timeoutMs: 5_000,

WarnWire retries network errors, timeouts, HTTP 408, 429, and 5xx responses. Most other 4xx responses fail immediately. Backoff is exponential, capped, optionally jittered, and respects a valid Retry-After value up to the cap. Final provider failures are reported through onInternalError and do not throw into normal alert calls.

Queue and graceful shutdown

The in-memory FIFO queue has bounded waiting capacity and delivery concurrency:

queue: {
  concurrency: 3,
  maxSize: 1_000,
}

Each provider delivery is one job. When the waiting queue is full, WarnWire rejects the new job, returns queue-full, increments dropped statistics, and keeps older work. flush() waits for active and waiting jobs. close() stops new alerts, clears the maintenance timer, and flushes existing work.

async function shutdown(signal: string) {
  console.log(`Received ${signal}`);
  await warnwire.close();
  process.exit(0);
}

process.once("SIGINT", () => {
  void shutdown("SIGINT");
});

process.once("SIGTERM", () => {
  void shutdown("SIGTERM");
});

WarnWire does not register process handlers automatically.

Sensitive-data redaction

Outbound context and error causes are copied and recursively redacted before routing. Matching is case-insensitive. Defaults include passwords, tokens, authorization, cookies, API keys, secrets, client secrets, and private keys. Arrays, circular references, throwing getters, dates, BigInt values, maximum depth, and maximum string length are handled without mutating input.

redaction: {
  keys: ["accountNumber"],
  replacement: "[REDACTED]",
  maxDepth: 6,
  maxStringLength: 4_000,
}

The configured keys extend, rather than replace, the defaults.

Pino integration

import pino from "pino";
import { createPinoStream } from "warnwire/pino";

const logger = pino(
  createPinoStream(warnwire, {
    minimumLevel: "warn",
  }),
);

This is a Node.js destination stream adapter, not a Pino worker transport. It parses newline-delimited JSON across chunk boundaries and maps Pino 40/50/60+ to WarnWire warn/error/fatal. Lower levels are ignored. Malformed lines are reported safely without crashing the stream.

Express integration

import { createExpressErrorMiddleware } from "warnwire/express";

app.use(createExpressErrorMiddleware(warnwire));

The four-argument middleware captures an error, adds method, URL, route, and request ID where available, then immediately calls next(error). It never sends a response. Bodies, full headers, cookies, and authorization headers are not captured. Client IP is opt-in:

createExpressErrorMiddleware(warnwire, {
  requestIdHeader: "x-correlation-id",
  includeIp: true,
});

Testing providers

const report = await warnwire.testProvider("discord");

This bypasses routing and deduplication, performs a real provider delivery with normal bounded retries, and returns credential-safe metadata. Unknown provider names throw as configuration/programming errors.

API reference

createWarnWire(config)

Validates configuration synchronously and returns a WarnWireClient.

warn, error, and fatal

warnwire.warn(messageOrError, options?)
warnwire.error(messageOrError, options?)
warnwire.fatal(messageOrError, options?)

Options support context, tags, and fingerprint. The promise resolves after enqueueing with:

interface AlertResult {
  accepted: boolean;
  suppressed: boolean;
  fingerprint: string;
  alertId: string;
  matchedProviders: string[];
  reason?: "duplicate" | "queue-full" | "closed" | "no-route";
}

Lifecycle and inspection

await warnwire.resolve(fingerprint, options?);
await warnwire.flush();
await warnwire.close();
const report = await warnwire.testProvider(name);
const stats = warnwire.getStats();

getStats() returns a frozen process-local snapshot of alert, delivery, retry, incident, and queue counters.

Exported pure helpers

generateFingerprint, normalizeFingerprintText, serializeError, redact, and DEFAULT_SENSITIVE_KEYS are exported for focused testing and controlled reuse. Important public types are exported from warnwire.

Delivery semantics and limitations

WarnWire provides controlled best-effort delivery, not a durable message bus:

  • Queue contents are stored only in memory.
  • Alerts may be lost if the process crashes.
  • Deduplication is local to one process.
  • Exactly-once delivery is not guaranteed.
  • Provider APIs may reject or truncate payloads.
  • WarnWire is not a durable incident-management backend.

Retries can produce duplicate provider messages if a remote service accepted a request but its response was lost.

Security considerations

  • Keep webhook URLs, bot tokens, API keys, and custom authorization values in a secret manager or environment variables.
  • Do not place secrets directly in error messages; key-based redaction cannot understand arbitrary prose.
  • Treat custom webhook destinations as data recipients.
  • Review any extra redaction keys needed by your domain.
  • onInternalError receives only safe provider metadata. Do not recursively send that callback through WarnWire.
  • Request bodies are excluded from the Express adapter by default.

Comparison

Tool Primary purpose
Pino High-performance structured logging
Winston Flexible application logging with transports
Sentry Full error monitoring and observability platform
WarnWire Lightweight multi-channel error-alert orchestration

WarnWire does not claim feature parity with Sentry and is not a replacement for logging, tracing, durable paging, or a full observability platform.

Development

npm ci
npm run format:check
npm run lint
npm run typecheck
npm test
npm run test:coverage
npm run build
npm pack --dry-run

npm run check runs the main formatting, lint, type, test, and build gates. Tests mock fetch; they do not contact real provider APIs.

Publishing

  1. Confirm the package name and update it to a scope if needed.
  2. Update CHANGELOG.md and the version.
  3. Run npm ci && npm run check && npm pack --dry-run.
  4. Inspect the tarball file list for credentials or unnecessary files.
  5. Configure npm trusted publishing or an NPM_TOKEN.
  6. Create a v* tag to invoke the optional release workflow, or run npm publish --provenance --access public manually.

This repository prepares WarnWire for publication but does not publish it.

Roadmap

Version 0.1 intentionally remains process-local. Possible future work includes a documented incident-store adapter boundary for distributed deduplication, additional carefully scoped providers, and richer provider formatting. Redis, persistent queues, dashboards, automatic recovery detection, and a rules DSL are not present today.

License

MIT

About

Node.js error-alerting SDK with multi-channel routing, deduplication, bounded retries, recovery notifications, and sensitive-data redaction.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages