observability

log enough to debug production without leaking telegram tokens or user data.

what to collect

signalfields
update latencyupdate type, handler route, duration, success/failure
api callsmethod, duration, status, retry count, error code
queue healthqueued, active, failed, skipped, next wake
webhook deliverystatus, body size, secret mismatch count
business eventscommand names, broadcast job ids, payment payloads without pii
observability.ts
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.

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

don'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.

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

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

queue-health.ts
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.

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

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

redact-manual.ts
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.