observability
log enough to debug production without leaking telegram tokens or user data.
what to collect
| signal | fields |
|---|---|
| update latency | update type, handler route, duration, success/failure |
| api calls | method, duration, status, retry count, error code |
| queue health | queued, active, failed, skipped, next wake |
| webhook delivery | status, body size, secret mismatch count |
| business events | command names, broadcast job ids, payment payloads without pii |
import { createBot } from "yaebal";
// stand-ins for whatever logger/metrics client you actually use (pino,
// winston, prom-client, OpenTelemetry, ...) — only the shape matters here.
interface Logger {
info(fields: Record<string, unknown>, msg: string): void;
warn(fields: Record<string, unknown>, msg: string): void;
error(fields: Record<string, unknown>, msg: string): void;
}
interface Counter {
add(value: number, labels?: Record<string, string>): void;
}
interface Histogram {
record(value: number, labels?: Record<string, string>): void;
}
declare const logger: Logger;
declare const metrics: {
apiStarted: Counter;
apiOk: Counter;
apiFailed: Counter;
updateDuration: Histogram;
};
const bot = createBot(process.env.BOT_TOKEN!);structured logs — the first-party way
@yaebal/audit-log wires the same bot.use/bot.api.before/after/onError hooks
shown by hand below, but turns every update and every outgoing api call into a correlated,
redacted AuditEvent automatically — no separate logger boilerplate, and secrets are
masked by default instead of opt-in.
import { createBot } from "yaebal";
import { auditAdmin, auditLog } from "@yaebal/audit-log";
const ADMIN_ID = 12345;
// every incoming update and every outgoing api call becomes a correlated,
// redacted AuditEvent — no hand-rolled before/after/onError hooks needed.
const logging = auditLog();
export const bot = createBot(process.env.BOT_TOKEN!)
.install(logging)
.install(auditAdmin({ logger: logging, isAdmin: (ctx) => ctx.from?.id === ADMIN_ID }));
// chat commands: /audit stats, /audit flushdon't install both this and the hand-rolled hooks below on the same bot — they'd double-log every call. reach for the manual pattern only when you need custom numeric metrics (counters, histograms) that a structured-log package doesn't produce on its own.
update latency
raw middleware registered before everything else sees the whole chain, including plugins and the matched handler — it's the one place to time an update end-to-end.
import { createBot } from "yaebal";
declare const logger: { info(fields: Record<string, unknown>, msg: string): void };
declare const metrics: { updateDuration: { record(value: number, labels?: Record<string, string>): void } };
const bot = createBot(process.env.BOT_TOKEN!);
// update latency: wrap every update in raw middleware, registered first so it
// times everything chained after it.
bot.use(async (ctx, next) => {
const start = performance.now();
await next();
const durationMs = performance.now() - start;
metrics.updateDuration.record(durationMs, { updateType: ctx.updateType });
logger.info({ updateType: ctx.updateType, durationMs }, "update handled");
});api hooks (manual)
bot.api.onError can do more than count failures: returning { retry: true, delayMs } retries the same call. TelegramError carries parameters.retry_after straight from Telegram's 429 response. @yaebal/again wraps the retry itself (backoff, jitter, a retry
budget) so most bots shouldn't hand-roll that part — write your own hook when you need custom
metrics alongside it, like below.
import { createBot, TelegramError } from "yaebal";
declare const logger: { error(fields: Record<string, unknown>, msg: string): void };
declare const metrics: {
apiStarted: { add(value: number, labels?: Record<string, string>): void };
apiOk: { add(value: number, labels?: Record<string, string>): void };
apiFailed: { add(value: number, labels?: Record<string, string>): void };
};
const bot = createBot(process.env.BOT_TOKEN!);
bot.api.before((method, params) => {
metrics.apiStarted.add(1, { method });
return params;
});
bot.api.after((method, params, result) => {
metrics.apiOk.add(1, { method });
return result;
});
bot.api.onError((method, error, attempt) => {
metrics.apiFailed.add(1, { method, attempt: String(attempt) });
// ask the client to retry: 429s carry retry_after; back off and try again
// instead of failing the update outright. this is what @yaebal/again wraps
// as a reusable plugin — reach for that instead of hand-rolling it per bot.
if (error instanceof TelegramError && attempt < 3) {
const retryAfterMs = (error.parameters?.retry_after ?? 1) * 1000;
return { retry: true, delayMs: retryAfterMs };
}
});
bot.onError((error, ctx) => {
logger.error({ error, updateId: ctx.update.update_id, updateType: ctx.updateType }, "handler failed");
});queue health
broadcast clients expose live counters on .metrics — poll it on an interval and export whatever's queued, active or failing.
import { createBroadcast } from "@yaebal/broadcast";
declare const broadcasts: ReturnType<typeof createBroadcast>;
declare const metrics: { queueDepth: { record(value: number, labels?: Record<string, string>): void } };
setInterval(() => {
const m = broadcasts.metrics; // BroadcastMetrics: active, queued, failed, skipped, nextWakeAt, ...
metrics.queueDepth.record(m.queued, { state: "queued" });
metrics.queueDepth.record(m.active, { state: "active" });
metrics.queueDepth.record(m.failed, { state: "failed" });
}, 15_000);health and readiness
give your orchestrator (Kubernetes, ECS, a load balancer) a liveness/readiness path that doesn't depend on Telegram being reachable — a Telegram outage shouldn't make your platform kill and restart a bot that's otherwise fine.
import { createServer } from "node:http";
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
let ready = false;
bot.onStart(() => {
ready = true;
});
// separate from the webhook/polling path — a platform's liveness/readiness
// probe should never depend on Telegram being reachable.
const health = createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200).end("ok");
return;
}
if (req.url === "/ready") {
res.writeHead(ready ? 200 : 503).end(ready ? "ready" : "starting");
return;
}
res.writeHead(404).end();
});
health.listen(9090);
process.once("SIGTERM", async () => {
ready = false;
await bot.stop();
health.close();
});redaction
never log bot tokens, file download urls, raw upload bytes, full user profiles or payment secrets.
auditLog() redacts by default, but the same masking is available standalone —
useful for logging anything outside the audit pipeline, like a webhook body:
import { applyRedaction, DEFAULT_SECRET_KEYS } from "@yaebal/audit-log";
// masks DEFAULT_SECRET_KEYS (token, secret_token, provider_token, phone_number, ...)
// at any depth, truncates long strings, and replaces binary buffers — works on any
// object, not just api params, so it's useful even without the full auditLog() plugin.
function safeParams(params: Record<string, unknown> | undefined) {
return applyRedaction(params, { secretKeys: [...DEFAULT_SECRET_KEYS, "file_id"] });
}not pulling in @yaebal/audit-log? the same idea, hand-rolled:
function safeParams(params: Record<string, unknown> | undefined) {
if (!params) return undefined;
const copy = { ...params };
delete copy.token;
delete copy.file;
delete copy.photo;
delete copy.document;
return copy;
}alerts
- handler failures above the normal baseline.
- sustained 429s or retry delays above 10 seconds.
- webhook 401/403/405 spikes.
- broadcast queue not draining or failed delivery rate rising.
- disk usage on local bot api servers.