@yaebal/audit-log
structured logging for production monitoring: every incoming update and every outgoing api call
is turned into an AuditEvent, correlated back to the update that triggered it,
masked of known secrets by default, then handed to configurable sinks — with filters and
sampling to keep volume under control. observation-only — nothing on ctx changes.
install
pnpm add @yaebal/audit-logusage
install auditLog() with bot.install(). by default it logs every
incoming update (via middleware) and every outgoing api call — its params, its result, and any
error — as structured, redacted JSON printed to console.log.
import { createBot } from "yaebal";
import { auditLog } from "@yaebal/audit-log";
const bot = createBot(process.env.BOT_TOKEN!);
// default: jsonFormatter + consoleSink() — every update and every api call, as JSON
bot.install(auditLog());
bot.command("start", (ctx) => ctx.reply("hello!"));
bot.start();event kinds
six kinds of AuditEvent:
| kind | hook | fields |
|---|---|---|
"update" | bot.use middleware | updateId, correlationId, updateType, chatId?, userId?, durationMs, error?, update |
"api.call" | api.before | callId, method, params, attempt, updateId?, correlationId? |
"api.result" | api.after | callId, method, params, result, durationMs?, updateId?, correlationId? |
"api.error" | api.onError | callId, method, params, error, attempt, durationMs?, updateId?, correlationId? |
"bot.start" | bot.onStart | info |
"bot.stop" | bot.onStop | — |
every event also carries a level ("info" | "warn" | "error"), used by chatSink to decide
what's worth paging on.
"update" event logged — with error set to a plain, JSON-safe SerializedError (a bare Error stringifies to "{}" — this never does) — and the error still
propagates to your normal error handling; audit logging never swallows it. getUpdates is excluded from api.* logging by default
(DEFAULT_EXCLUDED_METHODS) since it fires every poll tick.correlation
every api.* event fired while an update is being handled carries that update's updateId and correlationId (plus chatId/userId when known) — filter a log by one correlationId to see the update and every api call
it made, in order: a trace, not just a stream. built on node:async_hooks, wired
automatically by bot.install(auditLog()); a call made outside update handling (a cron
job, bot.onStart) carries no correlation. degrades gracefully — never throws — on a
runtime without AsyncLocalStorage; set correlate: false to turn it off
outright. api.call also carries a callId stable across retries (e.g. via @yaebal/again) and an attempt number,
and api.result/api.error carry the call's total durationMs.
security & redaction
redacted by default. before anything reaches a sink, every event is masked: known
secret keys (secret_token, token, phone_number, …) are
replaced wherever they occur, at any depth, in update/params/ result; long strings are truncated; media buffers (sendPhoto's Uint8Array, …) become a "[binary N bytes]" placeholder instead of raw
bytes in your logs. message text itself is not masked by default — audit logging needs it
— but hiding it is one paths entry away.
import { auditLog } from "@yaebal/audit-log";
bot.install(
auditLog({
redact: {
// paths are masked regardless of key name — for hiding message content itself,
// not just known secrets (secret_token, phone_number, ... are masked by default,
// wherever they occur, with no config at all).
paths: ["update.message.text", "params.text"],
maxStringLength: 500, // default 2000; 0 disables truncation
stripBinary: true, // default — media buffers become "[binary N bytes]"
},
}),
);
// redact: false turns masking off outright — not recommended for anything that ships logs
// off-box."[object X]" placeholder instead of throwing or attempting to
clone something that might not tolerate it.formatters, filters, sampling
formatter shapes an event before it reaches a sink — jsonFormatter (default) passes the (already redacted) event through as-is for sinks that want structured
fields; textFormatter renders one human-readable line, including the correlation
trace and the telegram error code a bare Error.message would drop. filter drops events outright, before sampling or redaction; sample keeps
only a fraction — a flat number or a per-event function, so you can keep every error while
sampling routine calls.
import { auditLog, textFormatter } from "@yaebal/audit-log";
bot.install(
auditLog({
formatter: textFormatter, // default is jsonFormatter (structured, sink-friendly)
filter: (event) => event.kind !== "api.result", // drop the noisiest kind entirely
sample: (event) => (event.kind === "api.error" ? 1 : 0.1), // keep every error, sample the rest
onError: (error, event, stage) => console.error(`audit ${stage} failed`, error, event),
}),
);sampleKey makes sampling deterministic instead of per-event random — events that
share a key (e.g. a chat) are kept or dropped together, so a trace never gets cut mid-update. byChatId is a ready-made key for the common case.
import { auditLog, byChatId } from "@yaebal/audit-log";
// deterministic sampling: an entire chat's trace is kept or dropped together, instead of
// a random per-event coin-flip cutting a trace mid-update.
bot.install(auditLog({ sample: 0.1, sampleKey: byChatId }));filter, sample, redaction, formatter, and
each sink's write/flush — is isolated: a stage that throws or rejects is
reported via onError and drops just that one event (or, for flush, just
that one sink) — never the request the event came from.sinks
a sink is { write(entry, event), flush?() }. entry is whatever formatter returned; event is the redacted AuditEvent, for
sinks that want structured fields regardless of formatting (a db row, a metrics counter) —
still masked, even if they bypass entry. built in, beyond consoleSink() (the default): memorySink() (a bounded ring buffer, handy for a /status endpoint or tests), fileSink() (JSONL, size-based rotation, serialized writes) and batchSink(inner, opts) (buffer-and-flush wrapper for a sink billed per call, not per
row).
import type { AuditSink } from "@yaebal/audit-log";
import { auditLog, consoleSink, fileSink, memorySink } from "@yaebal/audit-log";
function sqliteSink(db: SqliteLike): AuditSink {
const insert = db.prepare("INSERT INTO audit_log (kind, method, at) VALUES (?, ?, ?)");
return {
write(_entry, event) {
insert.run(event.kind, "method" in event ? event.method : event.updateType, event.timestamp);
},
};
}
bot.install(
auditLog({
sinks: [
sqliteSink(db),
fileSink("./logs/audit.jsonl"), // rotates at 10MB by default
memorySink({ limit: 500 }), // a ring buffer — pair with a /status endpoint
consoleSink(),
],
}),
);telegram-native: chatSink & auditAdmin
chatSink ships events straight into an admin chat via the bot's own sendMessage — no separate log aggregator to stand up just to get paged when
something breaks. gated to minLevel (default: errors only), deduped by event
signature, and rate-limited, so a failure storm sends one alert, not one message per occurrence —
and it never loops on its own traffic, even under a permissive minLevel.
import { auditLog, chatSink } from "@yaebal/audit-log";
bot.install(
auditLog({
sinks: [
consoleSink(),
// ships errors straight into an admin chat via the bot's own sendMessage — no
// separate log aggregator needed to get paged. deduped and rate-limited; never
// loops on its own traffic even under a permissive minLevel.
chatSink(bot, { chatId: process.env.ADMIN_CHAT_ID!, minLevel: "error" }),
],
}),
);auditAdmin is a telegram-native ops surface for the running counters auditLog() tracks — no dashboard or metrics scrape needed to ask "is the audit
pipeline healthy" from a chat. isolated via Composer.filter (the same pattern flagsAdmin uses), not guard —
a rejected isAdmin check continues the outer chain instead of halting it.
import { auditAdmin, auditLog } from "@yaebal/audit-log";
const audit = auditLog();
bot.install(audit);
bot.install(auditAdmin({ logger: audit, isAdmin: (ctx) => ctx.from?.id === OWNER_ID }));
// /audit -> received/written/filtered/sampled counts + a per-stage error breakdown
// /audit flush -> force audit.flush() now, and confirmflushing & lifecycle
a sink that buffers writes needs a flush before shutdown. with a real Bot, auditLog() wires this up automatically — autoFlush (default true) calls .flush() on bot.onStop(), and "bot.start"/"bot.stop" events are logged the same way
(logLifecycle, default true). both are feature-detected: a target with
no onStart/onStop (a hand-built { use, api } pair)
simply skips them.
const audit = auditLog({ sinks: [sqliteSink(db)] });
bot.install(audit);
// autoFlush (default true) already does this on bot.onStop() for a real Bot — this is
// only for a target that doesn't expose onStop (a hand-built { use, api } pair, or
// autoFlush: false).
bot.onStop(() => audit.flush());direct install
skip .install() and wire hooks straight onto any { use, api } pair — a real Bot, or a hand-built stand-in.
import { auditLog } from "@yaebal/audit-log";
// skip .install() — wire hooks straight onto a { use, api } pair
auditLog(bot, { sinks: [consoleSink()] });api
| export | signature | description |
|---|---|---|
auditLog | (options?) => AuditLogPlugin | the plugin form — install with bot.install(); the returned function carries .flush() and .stats() |
auditLog | (target: { use, api }, options?) => AuditLogHandle | direct-install overload — no .install() needed |
createAuditLogger | (options?) => AuditLogger | standalone log(event) / flush() / stats(), independent of any bot |
auditAdmin | (options) => Plugin | telegram-native /audit ops command |
jsonFormatter | (event) => AuditEvent | default formatter — passes the (redacted) event through unchanged |
textFormatter | (event) => string | one human-readable line per event, including the correlation trace |
prettyFormatter | (event) => string | indented multi-line JSON |
consoleSink | () => AuditSink | default sink — prints formatted entries via console.log |
memorySink | (options?) => MemorySink | bounded in-process ring buffer |
fileSink | (path, options?) => AuditSink | JSONL to disk, size-based rotation |
batchSink | (inner, options?) => AuditSink | buffer-and-flush wrapper around another sink |
chatSink | (target, options) => AuditSink | ships events into an admin chat via the bot |
applyRedaction | (value, options?) => value | the redaction pass, exported standalone |
serializeError | (error) => SerializedError | normalizes any thrown value into a json-safe shape |
byChatId | (event) => string | number | undefined | a ready-made sampleKey |
DEFAULT_EXCLUDED_METHODS | readonly string[] | ["getUpdates"] |
DEFAULT_SECRET_KEYS | readonly string[] | the default redaction denylist |
AuditLogOptions
| field | type | default | description |
|---|---|---|---|
sinks | AuditSink | AuditSink[] | [consoleSink()] | where formatted entries go; an empty array throws at construction |
formatter | AuditFormatter | jsonFormatter | shape events before they reach a sink |
filter | (event) => boolean | — | drop events outright, before sampling |
sample | number | (event) => number | — | fraction of matching events actually written, 0–1 |
sampleKey | (event) => string | number | undefined | — | make sampling deterministic (see byChatId) |
redact | RedactOptions | false | on, defaults below | mask secrets/text/binaries before a sink sees an event |
onError | (error, event, stage) => unknown | — | observe a pipeline stage that threw or rejected |
logUpdates | boolean | true | log incoming updates via middleware |
logApiCalls | boolean | true | log outgoing calls via api.before |
logApiResults | boolean | true | log successful outgoing calls via api.after |
logApiErrors | boolean | true | log failed outgoing calls via api.onError |
logLifecycle | boolean | true | log bot.start/bot.stop when the target exposes them |
excludedMethods | readonly string[] | DEFAULT_EXCLUDED_METHODS | api methods excluded from api.* logging; trailing * matches by prefix |
includeMethods | readonly string[] | — | if set, only these methods (same prefix syntax) are logged |
correlate | boolean | true | correlate api.* events with the triggering update |
autoFlush | boolean | true | flush automatically on bot.onStop() |
now | () => number | Date.now | clock override, mainly for tests |
random | () => number | Math.random | RNG override for sample, mainly for tests |
RedactOptions
| field | type | default | description |
|---|---|---|---|
paths | string[] | — | dot-paths masked regardless of key name ("*" matches any key at that depth) |
secretKeys | string[] | DEFAULT_SECRET_KEYS | replaces the default denylist entirely |
maxStringLength | number | 2000 | truncate longer strings; 0 disables |
stripBinary | boolean | true | replace binary payloads with a byte-length placeholder |
testing
createTestEnv dispatches updates through your bot but routes outgoing calls through
its own mock env.api — never a real Bot.api. use the direct-install
overload and hook onto env.api instead of bot.install().
import { createTestEnv } from "@yaebal/test";
import { auditLog } from "@yaebal/audit-log";
const bot = new Composer<Context>();
const env = createTestEnv(bot);
// createTestEnv routes outgoing calls through env.api, not a real bot.api — hook onto that
auditLog({ use: (...mw) => bot.use(...mw), api: env.api }, { sinks: [mySink] });
bot.on("message", (ctx) => ctx.reply("hi"));
await env.createUser().sendMessage("hello");
// mySink saw an "update" event and matching "api.call" / "api.result" events, correlated
// by a shared correlationId@yaebal/analytics tracks product events (ctx.track) for funnels and dashboards; audit-log is the lower-level, correlated trace of every update and every telegram api call — reach for it
when you need to debug a specific incident or feed a log aggregator, not to build product metrics.