Ergonomic structured JSON logging for Node.js with an optional, hardened Slack transport. Built on pino and Slack Block Kit.
- Easy by default:
createLogger({ name: "my-app" })and calllog.info("hello"). - Structured: every line is JSON with ISO 8601 timestamps. CloudWatch Logs
Insights, Datadog, journald, Docker
awslogsall parse fields automatically. - Slack is optional and graceful. Partial config is fine. Failing webhooks never crash your app or block your request.
- Two APIs: an options-object form for quick calls, a fluent builder for complex messages.
- TypeScript first. Dual ESM + CJS. Node 20+.
npm install ot-logger-deluxeOptional add-ons:
# Slack Web API (chat.postMessage via bot token)
npm install @slack/web-api
# Pretty console output in dev (also installed automatically as an optional dep)
npm install pino-pretty
# Forward to a remote syslog daemon
npm install pino-syslog pino-socketimport { createLogger } from "ot-logger-deluxe";
const log = createLogger({
name: "my-service",
level: "info",
pretty: process.env.NODE_ENV !== "production",
});
log.info("server started", { fields: { port: 3000 } });
log.warn("slow request", { fields: { durationMs: 2456, path: "/api/things" } });
log.error("db query failed", { error: err, code: "select * from users where ..." });Output (production):
{"level":30,"levelLabel":"info","time":"2026-04-21T15:30:00.123Z","name":"my-service","hostname":"web-1","pid":42,"port":3000,"msg":"server started"}Everything beyond a one-liner goes through the optional second argument.
await log.warn("Program group is inactive.", {
fields: {
action_id: action.id,
action: action.action,
action_time: thisActionTime,
group_id: group.id,
group_name: group.name,
},
code: true, // render every field's value as Slack inline `code`
slack: true,
});Each field value is coerced safely:
| Value type | JSON output | Slack output |
|---|---|---|
string, number, boolean |
as-is | String(value) |
Date |
ISO 8601 string | ISO 8601 string |
Error |
{ type, message, stack, cause } |
*Stack:* code block |
| Array / object | nested JSON | JSON.stringify(...), capped |
Per-field overrides (mix and match):
log.info("cache hit", {
fields: {
key: { value: cacheKey, code: true },
latency_ms: 4,
stale: false,
},
});Equivalent to the options form, useful when composing across branches:
await log
.message("Program group is inactive.")
.level("warn")
.fields({
action_id: action.id,
action: action.action,
action_time: thisActionTime,
group_id: group.id,
group_name: group.name,
}, { code: true })
.toSlack()
.send();Merge context into every record from a derived logger. Slack config is shared.
const req = log.child({ requestId, userId });
req.info("handled"); // includes requestId + userId automatically
req.warn("rate limited");- Create one or more Incoming Webhook URLs at api.slack.com/apps.
- Pass the URLs when constructing the logger:
const log = createLogger({
name: "billing",
slack: {
defaultWebhookUrl: process.env.SLACK_WEBHOOK_URL,
channels: {
error: process.env.SLACK_WEBHOOK_URL_ERROR,
fatal: process.env.SLACK_WEBHOOK_URL_FATAL,
},
mention: { fatal: "@channel", error: "@here" },
},
});
await log.error("payment failed", { error: err, slack: true });Fallback rules (silent skip by design):
- Level-specific URL → used if set.
- Otherwise
defaultWebhookUrl→ used if set. - Otherwise Slack is skipped silently for that level.
slack: truenever throws.
Requires @slack/web-api (declared as an optional peer dependency):
const log = createLogger({
name: "billing",
slack: {
webApi: {
token: process.env.SLACK_BOT_TOKEN!,
defaultChannel: "#alerts",
channels: { fatal: "#alerts-critical" },
},
},
});You can combine Web API and webhooks; both will be dispatched.
Webhook calls retry up to 3 times with exponential backoff, honoring Slack's
Retry-After header on 429. Each attempt has a 5s timeout. On final failure
the error is surfaced through the logger itself at warn level with
slackError: true metadata — it never propagates back to your code.
slack: {
defaultWebhookUrl: "...",
retry: { attempts: 3, baseDelayMs: 250, timeoutMs: 5000 },
}log.<level>(..., { slack: true }) returns to the caller as soon as the
pino record has been written locally. The Slack send runs in the background
so your request isn't blocked by retries, timeouts, or Retry-After delays
(which can add up to tens of seconds in the worst case).
If you need to guarantee Slack delivery before the process exits (graceful
shutdown, tests, CLIs), call await logger.flush() — it drains every
in-flight Slack send and then flushes pino's transports:
process.on("SIGTERM", async () => {
await log.fatal("shutting down", { slack: true });
await log.flush(); // waits for Slack + pino
process.exit(0);
});await log.error("scoped alert", {
slack: { channel: "https://hooks.slack.com/services/override/...", mention: "@here" },
});import { createLoggerFromEnv } from "ot-logger-deluxe";
const log = createLoggerFromEnv({ name: "my-service" });Recognized variables:
| Variable | Purpose |
|---|---|
LOG_LEVEL |
trace, debug, info (default), warn, error, fatal, silent |
LOG_NAME |
Logger name (falls back to overrides or "app") |
LOG_PRETTY |
1/true to enable pino-pretty colorized output |
LOG_FILE |
Append JSON records to this file (in addition to stdout) |
LOG_SYSLOG_HOST |
Enable RFC 5424 syslog transport |
LOG_SYSLOG_PORT |
Default 514 (or 6514 when LOG_SYSLOG_PROTOCOL=tls) |
LOG_SYSLOG_PROTOCOL |
udp (default), tcp, or tls (RFC 5425) |
LOG_SYSLOG_REJECT_UNAUTHORIZED |
0/false to accept self-signed TLS certs |
LOG_SYSLOG_APP_NAME |
APP-NAME field (defaults to logger name) |
SLACK_WEBHOOK_URL |
Fallback Incoming Webhook for all levels |
SLACK_WEBHOOK_URL_INFO |
Per-level webhooks |
SLACK_WEBHOOK_URL_WARN |
|
SLACK_WEBHOOK_URL_ERROR |
|
SLACK_WEBHOOK_URL_FATAL |
|
SLACK_MENTION_WARN |
Raw mrkdwn (@here, @channel, <!subteam^ID>) |
SLACK_MENTION_ERROR |
|
SLACK_MENTION_FATAL |
|
SLACK_BOT_TOKEN |
Enable Slack Web API transport (xoxb-...) |
SLACK_CHANNEL |
Default Web API channel id/name |
SLACK_CHANNEL_INFO |
Per-level Web API channels |
SLACK_CHANNEL_WARN |
|
SLACK_CHANNEL_ERROR |
|
SLACK_CHANNEL_FATAL |
Explicit overrides passed to createLoggerFromEnv({...}) always win.
The default transport writes newline-delimited JSON to stdout. Each record contains:
| Field | Description |
|---|---|
time |
ISO 8601 / RFC 3339 timestamp ("2026-04-21T15:30:00.123Z") |
level |
Numeric pino level (30 = info, 50 = error, ...) |
levelLabel |
Human-readable level ("info", "error", ...) |
name |
Logger name |
hostname |
os.hostname() or the configured override |
pid |
Process id |
msg |
Message string |
... |
Your structured fields (spread into the top-level object) |
err |
Present when you pass { error }; { type, message, stack, ... } |
code |
Present when you pass { code: "..." } |
Works out of the box. Each log shipper tails stdout and forwards JSON lines unchanged. In CloudWatch Logs Insights you can query fields directly:
fields @timestamp, levelLabel, msg, requestId
| filter level >= 50
| filter requestId = "abc"
createLogger({ name: "svc", pretty: true });pino-pretty is loaded lazily and listed as an optional dependency, so
production installs stay lean. Always disable pretty in production.
For rsyslog / syslog-ng / Papertrail users who need strict RFC 5424 wire
format. Requires pino-syslog and pino-socket.
// Plain UDP (RFC 5424)
createLogger({
name: "svc",
transports: {
syslog: { host: "logs.example.com", port: 514, protocol: "udp", format: "RFC5424" },
},
});
// TLS (RFC 5425 syslog over TLS/TCP) - certs verified by default
createLogger({
name: "svc",
transports: {
syslog: {
host: "logs.papertrailapp.com",
port: 6514,
protocol: "tls",
// rejectUnauthorized: false, // uncomment only for self-signed test setups
},
},
});createLogger({
name: "svc",
transports: {
files: ["/var/log/svc.log"],
custom: [{ target: "pino-loki", options: { host: "https://loki.example.com" } }],
},
});| Level | Numeric | Typical use |
|---|---|---|
trace |
10 | Very noisy, usually off |
debug |
20 | Development diagnostics |
info |
30 | Normal operation |
warn |
40 | Concerning but recoverable |
error |
50 | Unrecoverable request failure |
fatal |
60 | Process-ending condition |
silent |
∞ | Disable all output |
Only info, warn, error, and fatal can route to Slack. trace and
debug ignore the slack option.
v2.0 is a clean rewrite and replaces the entire public API. The shape of your code changes, but the intent carries over cleanly.
| v1.x | v2.x |
|---|---|
new OTLoggerDeluxe(opts, name, slackConfig) |
createLogger({ name, level, slack }) |
OTLoggerDeluxeOptions.providerName / logGroupingPattern |
removed (pino handles grouping) |
logInfo, logWarning, logError, logFatal, logDebug, logTrace |
log.info, log.warn, log.error, log.fatal, log.debug, log.trace |
logErrorWithErrorPart(msg, err) |
log.error(msg, { error: err }) |
OTLogableMessage + IOTMessagePart[] |
{ fields: { name: value, name: { value, code: true } } } |
ISlackConfig.keys.<level>ChannelKey (path after /services/) |
slack.channels.<level> (full URL) |
SlackAlertType enum |
removed |
Re-export of LogLevel from typescript-logging |
local string union type |
moment, superagent, typescript-logging, and
typescript-logging-log4ts-style are no longer required. @types/node is now
a dev-only dependency.
See CHANGELOG.md for the full breaking-change list.
MIT © David Trotz