@yaebal/otel

opentelemetry tracing: every update becomes a span, every bot api call made while handling it a child span — so a slow handler, a flood wait or a failing method is one trace away in jaeger, grafana tempo, honeycomb, axiom or any other otel backend.

install

terminal
pnpm add @yaebal/otel @opentelemetry/api

the only dependency is @opentelemetry/api — bring your own sdk and exporter, the same ones the rest of your services use.

usage

bot.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Bot } from "@yaebal/core";
import { otel } from "@yaebal/otel";

// the sdk registers the async context manager that links child spans to their update
new NodeSDK({ serviceName: "my-bot", traceExporter: new OTLPTraceExporter() }).start();

// install first, so the update span wraps every other plugin
const bot = new Bot(process.env.BOT_TOKEN!).install(otel());

bot.command("report", async (ctx) => {
  const rows = await ctx.otel.record("db.query", () => db.report()); // a child span
  ctx.otel.setAttributes({ "report.rows": rows.length });
  await ctx.send(render(rows)); // telegram.api/sendMessage, a child of the update
});

await bot.start();

a trace for one update:

trace
telegram.update/message          CONSUMER  update_id, update_type, chat.id, chat.type, user.id
├── db.query                     INTERNAL  (ctx.otel.record)
└── telegram.api/sendMessage     CLIENT    method, chat.id, error_code on failure

what lands on ctx

fielddoes
ctx.otel.spanthe update's span
ctx.otel.record(name, fn)runs fn in a child span — ended for you, marked failed if it throws
ctx.otel.setAttributes(attrs)adds attributes to the update span

options

bot.ts
bot.install(
  otel({
    tracer: trace.getTracer("my-bot"),          // default: the global provider's "@yaebal/otel"
    recordApiParams: true,                      // params as a json attribute — off by default
    attributes: (ctx) => ({ "tenant.id": tenantOf(ctx) }),
  }),
);
optiondefaultdoes
tracertrace.getTracer("@yaebal/otel")the tracer to record with
recordApiParamsfalseattach each call's params as telegram.params — json, uploads named instead of inlined, cut at 4096 chars. off by default because params carry user text
attributes—(ctx) => Attributes added to every update span

behavior

  • a handler that throws marks the update span ERROR with the exception recorded, then rethrows to bot.onError as usual. a failed api call does the same on its own span, plus telegram.error_code; each retry attempt (with @yaebal/again) is its own span.
  • the bot token never reaches a span — only method names and, opt-in, params.
  • without an async context manager (no sdk registered) api spans still record, but as roots instead of children of their update.

without a bot

worker.ts
import { createApi } from "@yaebal/core";
import { instrumentApi } from "@yaebal/otel";

// a client without a bot — e.g. a broadcast worker
const api = instrumentApi(createApi(process.env.BOT_TOKEN!));

related