| name | write-custom-log-channel |
| description | Extend the abstract LogChannel class for custom sinks — Slack, database, HTTP endpoint, in-memory buffer. Triggers: `LogChannel`, `LogContract`, `LoggingData`, `shouldBeLogged`, `init`, `flush`, `flushSync`, `terminal`; "log to slack", "log to a database", "send logs to datadog / loki HTTP api", "in-memory test capture channel", "build a custom log sink"; typical import `import { LogChannel, type LoggingData, type LogContract } from "@warlock.js/logger"`. Skip: built-in channels — `@warlock.js/logger/pick-log-channel/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing libs `winston-transport`, `pino-transport`. |
Custom channels — extending LogChannel
Build a sink for any destination — Slack, a database, an HTTP endpoint — by extending the abstract LogChannel class.
The 5-line minimum
import { LogChannel, type LoggingData } from "@warlock.js/logger";
export class NullChannel extends LogChannel {
public name = "null";
public log(_data: LoggingData) {}
}
Then:
log.addChannel(new NullChannel());
That's a working channel. LogChannel provides the scaffolding; you only need to supply name and log().
What LogChannel gives you
| Thing | Who provides it |
|---|
name, description, terminal | You (fields on your subclass) |
log(data) | You must implement — abstract |
flushSync() | You (optional sync drain — only if you buffer) |
flush() | You (optional async drain — only if you buffer over async I/O) |
init() | You (optional async hook — see below) |
shouldBeLogged(data) | LogChannel — combines levels + filter |
config<K>(key) | LogChannel — merges user config with defaultConfigurations |
getDateAndTimeFormat() | LogChannel — returns resolved dateFormat |
Complete example — SlackLog
import { LogChannel, type BasicLogConfigurations, type LoggingData } from "@warlock.js/logger";
type SlackConfig = BasicLogConfigurations & {
webhookUrl: string;
};
export class SlackLog extends LogChannel<SlackConfig> {
public name = "slack";
public description = "Posts errors + warnings to a Slack webhook";
public async log(data: LoggingData) {
if (!this.shouldBeLogged(data)) return;
await fetch(this.config("webhookUrl"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `[${data.type.toUpperCase()}] [${data.module}][${data.action}]: ${data.message}`,
}),
});
}
}
Register it alongside built-ins:
log.setChannels([
new ConsoleLog(),
new FileLog({ chunk: "daily" }),
new SlackLog({
webhookUrl: process.env.SLACK_WEBHOOK_URL!,
levels: ["error", "warn"],
}),
]);
The init() hook
Override protected async init() for one-time setup — open a socket, connect to a DB, prepare a write stream. Runs automatically after construction (inside a setTimeout(0)); isInitialized flips to true once resolved.
export class DatabaseLog extends LogChannel<
BasicLogConfigurations & { connectionString: string }
> {
public name = "database";
private client!: SomeDbClient;
protected async init() {
this.client = await SomeDbClient.connect(this.config("connectionString"));
}
public async log(data: LoggingData) {
if (!this.shouldBeLogged(data)) return;
await this.client.insert("logs", data);
}
}
Implementing flushSync()
Only if your channel buffers. Signature: flushSync?(): void. Synchronous — no await, no promises.
export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: string }> {
public name = "batch-http";
private buffer: LoggingData[] = [];
public log(data: LoggingData) {
if (!this.shouldBeLogged(data)) return;
this.buffer.push(data);
if (this.buffer.length >= 100) void this.drain();
}
public flushSync() {
}
private async drain() { }
}
Implementing flush() — async drain
flushSync() is synchronous, so a channel that delivers over the network (or any async I/O) can't drain that way. Implement flush(): Promise<void> so await log.flush() drains it on a graceful shutdown:
export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: string }> {
public name = "batch-http";
private buffer: LoggingData[] = [];
public log(data: LoggingData) {
if (!this.shouldBeLogged(data)) return;
this.buffer.push(data);
if (this.buffer.length >= 100) void this.drain();
}
public async flush() {
await this.drain();
}
private async drain() {
if (this.buffer.length === 0) return;
const batch = this.buffer.splice(0);
await fetch(this.config("url"), { method: "POST", body: JSON.stringify(batch) });
}
}
Logger.flush() isolates each channel — a rejecting flush() won't break the others — but handle your own failures so a shutdown drain doesn't silently drop the batch. Implement flushSync() too (e.g. dump to disk) when you also want a best-effort sync path for autoFlushOn.
The terminal property
terminal = true (ConsoleLog default) → the logger passes the original message, ANSI codes intact.
terminal = false (base default, all file channels) → the logger passes a shallow-cloned copy whose message has ANSI codes stripped.
Set terminal = true on a channel only if its output is a TTY that should render colors.
LogContract — the minimal interface
If you don't want anything LogChannel provides (level filtering, config merging), implement LogContract directly:
import type { LogContract, LoggingData } from "@warlock.js/logger";
class MinimalSlack implements LogContract {
public name = "slack";
public async log(data: LoggingData) {
if (data.type !== "error") return;
await fetch(process.env.SLACK_WEBHOOK!, { });
}
}
Prefer extending LogChannel unless you have a concrete reason not to — the level/filter plumbing is worth keeping.
Don't do
- Don't mutate
data inside log(). Later channels see the mutation if the logger passes the same reference.
- Don't throw synchronously from
log(). The logger fires it without awaiting; an unhandled rejection takes down the process (unless captureAnyUnhandledRejection is wired up — and then it's embarrassing to be the cause).
- Don't block the event loop.
log() may be sync or async; if your work takes >100ms, make it async and return the promise.
- Don't forget
shouldBeLogged(data) at the top of log() — or your channel silently ignores levels / filter config.