@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

terminal
pnpm add @yaebal/audit-log

usage

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.

bot.ts
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:

kindhookfields
"update"bot.use middlewareupdateId, correlationId, updateType, chatId?, userId?, durationMs, error?, update
"api.call"api.beforecallId, method, params, attempt, updateId?, correlationId?
"api.result"api.aftercallId, method, params, result, durationMs?, updateId?, correlationId?
"api.error"api.onErrorcallId, method, params, error, attempt, durationMs?, updateId?, correlationId?
"bot.start"bot.onStartinfo
"bot.stop"bot.onStop

every event also carries a level ("info" | "warn" | "error"), used by chatSink to decide what's worth paging on.

a handler that throws still gets its "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.

audit.ts
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.
an unknown/non-plain value (a class instance, a function, a stream) never crashes redaction — it degrades to a safe "[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.

audit.ts
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.

audit.ts
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 }));
every stage — 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).

sinks.ts
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.

bot.ts
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.

bot.ts
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 confirm

flushing & 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.

shutdown.ts
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.

direct.ts
import { auditLog } from "@yaebal/audit-log";

// skip .install() — wire hooks straight onto a { use, api } pair
auditLog(bot, { sinks: [consoleSink()] });

api

exportsignaturedescription
auditLog(options?) => AuditLogPluginthe plugin form — install with bot.install(); the returned function carries .flush() and .stats()
auditLog(target: { use, api }, options?) => AuditLogHandledirect-install overload — no .install() needed
createAuditLogger(options?) => AuditLoggerstandalone log(event) / flush() / stats(), independent of any bot
auditAdmin(options) => Plugintelegram-native /audit ops command
jsonFormatter(event) => AuditEventdefault formatter — passes the (redacted) event through unchanged
textFormatter(event) => stringone human-readable line per event, including the correlation trace
prettyFormatter(event) => stringindented multi-line JSON
consoleSink() => AuditSinkdefault sink — prints formatted entries via console.log
memorySink(options?) => MemorySinkbounded in-process ring buffer
fileSink(path, options?) => AuditSinkJSONL to disk, size-based rotation
batchSink(inner, options?) => AuditSinkbuffer-and-flush wrapper around another sink
chatSink(target, options) => AuditSinkships events into an admin chat via the bot
applyRedaction(value, options?) => valuethe redaction pass, exported standalone
serializeError(error) => SerializedErrornormalizes any thrown value into a json-safe shape
byChatId(event) => string | number | undefineda ready-made sampleKey
DEFAULT_EXCLUDED_METHODSreadonly string[]["getUpdates"]
DEFAULT_SECRET_KEYSreadonly string[]the default redaction denylist

AuditLogOptions

fieldtypedefaultdescription
sinksAuditSink | AuditSink[][consoleSink()]where formatted entries go; an empty array throws at construction
formatterAuditFormatterjsonFormattershape events before they reach a sink
filter(event) => booleandrop events outright, before sampling
samplenumber | (event) => numberfraction of matching events actually written, 01
sampleKey(event) => string | number | undefinedmake sampling deterministic (see byChatId)
redactRedactOptions | falseon, defaults belowmask secrets/text/binaries before a sink sees an event
onError(error, event, stage) => unknownobserve a pipeline stage that threw or rejected
logUpdatesbooleantruelog incoming updates via middleware
logApiCallsbooleantruelog outgoing calls via api.before
logApiResultsbooleantruelog successful outgoing calls via api.after
logApiErrorsbooleantruelog failed outgoing calls via api.onError
logLifecyclebooleantruelog bot.start/bot.stop when the target exposes them
excludedMethodsreadonly string[]DEFAULT_EXCLUDED_METHODSapi methods excluded from api.* logging; trailing * matches by prefix
includeMethodsreadonly string[]if set, only these methods (same prefix syntax) are logged
correlatebooleantruecorrelate api.* events with the triggering update
autoFlushbooleantrueflush automatically on bot.onStop()
now() => numberDate.nowclock override, mainly for tests
random() => numberMath.randomRNG override for sample, mainly for tests

RedactOptions

fieldtypedefaultdescription
pathsstring[]dot-paths masked regardless of key name ("*" matches any key at that depth)
secretKeysstring[]DEFAULT_SECRET_KEYSreplaces the default denylist entirely
maxStringLengthnumber2000truncate longer strings; 0 disables
stripBinarybooleantruereplace 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().

audit-log.test.ts
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
pairs with analytics. @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.