| name | ship-logs-to-sentry |
| description | Forward log entries to Sentry with the SentryLog channel — error/warn become events (captureException/captureMessage), every other level a breadcrumb (no quota). @sentry/node is an OPTIONAL peer, lazily imported. Triggers: `SentryLog`, `@sentry/node`, `eventLevels`, `flushTimeout`, `Sentry.flush`, `captureException`, `addBreadcrumb`, `withScope`; "send logs to Sentry", "report errors to Sentry", "Sentry log channel", "Sentry breadcrumbs from logs", "log channel for Sentry"; typical import `import { SentryLog } from "@warlock.js/logger"`. Skip: file/console channels — `@warlock.js/logger/pick-log-channel/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; graceful-shutdown flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; Slack alerting recipe. |
Ship logs to Sentry — the SentryLog channel
SentryLog forwards log entries to Sentry. It's the one built-in channel that needs an external SDK, so @sentry/node is an optional peer — install it only if you use this channel:
npm install @sentry/node
Two ways to wire it
Reuse an existing Sentry client (existing apps)
If your app already calls Sentry.init(...), pass the namespace as client. The channel forwards through it and never re-imports or re-initializes the SDK:
import * as Sentry from "@sentry/node";
import { log, SentryLog } from "@warlock.js/logger";
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production" });
log.addChannel(new SentryLog({ client: Sentry }));
Let the channel initialize Sentry (new apps)
Pass options; the channel lazily imports @sentry/node and calls Sentry.init once — guarded so it never clobbers an existing client:
import { log, SentryLog } from "@warlock.js/logger";
log.addChannel(
new SentryLog({
options: {
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.GIT_SHA,
},
}),
);
With neither client nor options, the channel reuses whatever global Sentry client the host already initialized.
How levels map to Sentry
This is the quota-control decision. Only eventLevels create Sentry events (which consume your error quota); every other level becomes a breadcrumb that rides along with the next event for free.
| Logger level | Default | Sentry call |
|---|
fatal | event | captureException for an Error message, else captureMessage(…, "fatal") |
error | event | captureException for an Error message, else captureMessage(…, "error") |
warn | event | captureMessage(…, "warning") |
success | breadcrumb | addBreadcrumb({ level: "info" }) |
info | breadcrumb | addBreadcrumb({ level: "info" }) |
debug | breadcrumb | addBreadcrumb({ level: "debug" }) |
- Errors keep their stack. A
message that is an Error goes through captureException, so Sentry parses the real stack and groups correctly — never pre-stringify the error.
module / action become tags and the entry's context becomes a structured Sentry context, both scoped to that single event via withScope.
success has no Sentry severity — it's reported as info.
Tuning what becomes an event
new SentryLog({ client: Sentry, eventLevels: ["error"] });
new SentryLog({ client: Sentry, eventLevels: ["error", "warn", "info"] });
levels and filter from BasicLogConfigurations apply first — a channel-level levels: ["error", "warn"] drops everything else before it reaches Sentry at all.
Draining on shutdown
Sentry sends events asynchronously over the network, so a synchronous flush can't wait on them. SentryLog.flush() calls Sentry.flush(timeout); drain it on your graceful-shutdown path:
async function shutdown() {
await httpServer.close();
await log.flush();
process.exit(0);
}
process.once("SIGTERM", shutdown);
flushTimeout (default 2000 ms) bounds the wait so an unreachable Sentry can't hang shutdown. autoFlushOn uses the synchronous flushSync(), which does not drain Sentry — wire await log.flush() yourself. See flush-logs-on-shutdown.
If @sentry/node isn't installed
The channel never crashes your app: the dynamic import failure is swallowed, the install instructions are written to stderr once, and entries are dropped silently thereafter. So registering SentryLog in shared config is safe even in an environment where Sentry isn't installed.
Config reference
| Option | Type | Default | Description |
|---|
client | Sentry namespace / forwarder | — | Reuse an already-initialized Sentry instance. |
options | SentryInitOptions | — | Sentry init options (mirrors @sentry/node's NodeOptions) — used when the channel owns Sentry. |
eventLevels | LogLevel[] | ["fatal", "error", "warn"] | Levels sent as events; the rest become breadcrumbs. |
flushTimeout | number | 2000 | Ms flush() waits for the transport to drain. |
levels, filter, dateFormat, redact | — | — | Inherited from BasicLogConfigurations. |
Don't do
- Don't double-init Sentry. Pass either
client or options, not both. The channel guards against double-init, but a single owner of Sentry.init is cleaner.
- Don't send every level as an event.
eventLevels with info/debug/success will flood your Sentry quota — keep them as breadcrumbs.
- Don't rely on
autoFlushOn for Sentry. It's synchronous; a network channel needs await log.flush().
See also