OpenTelemetry tracing plugin for GramIO.
Automatically creates spans for every incoming update and outgoing Telegram API call, giving you full distributed tracing for your Telegram bot.
npm install @gramio/opentelemetry @opentelemetry/api @opentelemetry/sdk-nodeImportant
You must set up the OpenTelemetry SDK before creating the bot.
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Bot } from "gramio";
import { opentelemetryPlugin } from "@gramio/opentelemetry";
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "http://localhost:4318/v1/traces",
}),
serviceName: "my-telegram-bot",
});
sdk.start();
const bot = new Bot(process.env.BOT_TOKEN!)
.extend(opentelemetryPlugin())
.on("message", async (context) => {
await context.send("Hello!");
});
await bot.start();Every incoming update creates a root span with Telegram context, and every API call becomes a child span under it:
gramio.update.message (CONSUMER)
├── telegram.api/sendMessage (CLIENT)
├── telegram.api/deleteMessage (CLIENT)
└── custom spans via record()
Update spans (gramio.update.{type}):
| Attribute | Example |
|---|---|
gramio.update_type |
"message" |
gramio.user_id |
123456789 |
gramio.chat_id |
-100123456 |
gramio.chat_type |
"private" |
API call spans (telegram.api/{method}):
| Attribute | Example |
|---|---|
telegram.api.method |
"sendMessage" |
telegram.api.request.chat_id |
123456 (only with recordApiParams: true) |
telegram.api.request.text |
"Hello!" (only with recordApiParams: true) |
When
recordApiParamsis enabled, only primitive values (string, number, boolean) from the top-level params are recorded as flat attributes following OTEL semantic conventions. Complex objects (reply_markup, etc.) are skipped.
- Handler errors — recorded on the update span via
.onError()with status ERROR and exception details - API errors — recorded as span events with method, error code, and message
- API call failures — the child span gets ERROR status and the exception is re-thrown
opentelemetryPlugin({
tracerName: "gramio", // Custom tracer name (default: "gramio")
recordApiParams: false, // Record API params as attributes (default: false)
})Warning
Enabling recordApiParams may capture sensitive data (tokens, user messages). Use with caution.
Create a child span for a block of code. Automatically ends the span and records exceptions:
import { record } from "@gramio/opentelemetry";
bot.on("message", async (context) => {
const users = await record("database.query", async () => {
return db.query("SELECT * FROM users");
});
await context.send(`Found ${users.length} users`);
});Get the current active span from anywhere in the async chain:
import { getCurrentSpan } from "@gramio/opentelemetry";
function myUtility() {
const span = getCurrentSpan();
span?.addEvent("my.custom.event", { key: "value" });
}Sugar for getCurrentSpan()?.setAttributes(...):
import { setAttributes } from "@gramio/opentelemetry";
function myUtility() {
setAttributes({ "custom.attribute": "value" });
}When using Elysia as a webhook server with @elysiajs/opentelemetry, both frameworks share the same OpenTelemetry context through AsyncLocalStorage. This means a single trace spans the entire request lifecycle:
HTTP POST /webhook (Elysia root span, SERVER)
└── gramio.update.message (CONSUMER)
├── telegram.api/sendMessage (CLIENT)
└── database.query (custom via record())
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Elysia } from "elysia";
import { opentelemetry } from "@elysiajs/opentelemetry";
import { Bot } from "gramio";
import { opentelemetryPlugin } from "@gramio/opentelemetry";
// 1. Initialize OpenTelemetry SDK (once, before everything)
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "http://localhost:4318/v1/traces",
}),
serviceName: "my-bot-service",
});
sdk.start();
// 2. Create GramIO bot with OpenTelemetry plugin
const bot = new Bot(process.env.BOT_TOKEN!)
.extend(opentelemetryPlugin())
.on("message", async (context) => {
await context.send("Hello from webhook!");
});
// 3. Create Elysia server with OpenTelemetry + webhook endpoint
const app = new Elysia()
.use(opentelemetry())
.post("/webhook", async ({ body }) => {
// GramIO processes the update inside Elysia's active span context.
// The gramio.update.* span automatically becomes a child of the HTTP span.
await bot.updates.handleUpdate(body);
return "ok";
})
.listen(3000);
// 4. Set webhook URL with Telegram
await bot.api.setWebhook({
url: "https://your-domain.com/webhook",
});
console.log("Webhook server running on :3000");Both @elysiajs/opentelemetry and @gramio/opentelemetry use @opentelemetry/api which relies on AsyncLocalStorage for context propagation. When Elysia handles the webhook POST:
- Elysia creates a root HTTP span and activates it in the async context
- The request handler calls
bot.updates.handleUpdate(body) - GramIO's
.use()middleware callstracer.startActiveSpan()— which sees the Elysia span as the parent - API calls inside the handler become grandchildren of the HTTP span
- The entire trace flows seamlessly across both frameworks
No additional configuration needed — it just works because they share the same global @opentelemetry/api instance.
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "http://localhost:4318/v1/traces",
}),
serviceName: "my-telegram-bot",
});const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "https://tempo-us-central1.grafana.net/tempo",
headers: {
Authorization: `Basic ${btoa(`${instanceId}:${apiKey}`)}`,
},
}),
serviceName: "my-telegram-bot",
});const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "https://api.axiom.co/v1/traces",
headers: {
Authorization: `Bearer ${process.env.AXIOM_TOKEN}`,
"X-Axiom-Dataset": process.env.AXIOM_DATASET!,
},
}),
serviceName: "my-telegram-bot",
});