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.
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.
npm install warnwireNode.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.
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.
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"]
All network providers use native fetch, AbortController, and the configured
request timeout.
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.
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.
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.
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.
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".
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",
});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.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.
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.
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.
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.
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,
});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.
Validates configuration synchronously and returns a WarnWireClient.
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";
}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.
generateFingerprint, normalizeFingerprintText, serializeError, redact,
and DEFAULT_SENSITIVE_KEYS are exported for focused testing and controlled
reuse. Important public types are exported from warnwire.
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.
- 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.
onInternalErrorreceives only safe provider metadata. Do not recursively send that callback through WarnWire.- Request bodies are excluded from the Express adapter by default.
| 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.
npm ci
npm run format:check
npm run lint
npm run typecheck
npm test
npm run test:coverage
npm run build
npm pack --dry-runnpm run check runs the main formatting, lint, type, test, and build gates.
Tests mock fetch; they do not contact real provider APIs.
- Confirm the package name and update it to a scope if needed.
- Update
CHANGELOG.mdand the version. - Run
npm ci && npm run check && npm pack --dry-run. - Inspect the tarball file list for credentials or unnecessary files.
- Configure npm trusted publishing or an
NPM_TOKEN. - Create a
v*tag to invoke the optional release workflow, or runnpm publish --provenance --access publicmanually.
This repository prepares WarnWire for publication but does not publish it.
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.