telega/telemetry

Telemetry events for production observability.

Telega emits telemetry events at every key point of the update lifecycle, so standard BEAM exporters (PromEx, opentelemetry_telemetry, custom handlers) work out of the box. Emitting an event with no attached handlers is nearly free (a single ETS lookup), so instrumentation costs nothing until you attach a handler.

Event reference

EventMeasurementsMetadata
telega.update.startsystem_timeupdate_type, chat_id, from_id
telega.update.stopdurationupdate_type, chat_id, from_id
telega.update.exceptiondurationerror, update_type, chat_id, from_id
telega.api_call.startsystem_timemethod
telega.api_call.stopdurationmethod, status
telega.api_call.exceptiondurationmethod, error
telega.api_call.retryretry_after (ms)method, attempt
telega.request_queue.depthdepthrule_id, priority
telega.rate_limit.hitcountupdate_type, chat_id, from_id
telega.chat_instance.spawncountchat_id, from_id
telega.chat_instance.terminatecountkey, reason
telega.flow.stepdurationflow_name, step
telega.flow.timeoutcountflow_name, step
telega.flow.cancelcountflow_name, step
telega.shutdown.startsystem_time
telega.shutdown.stopduration, drainedtimed_out

Attaching a handler

attach_many subscribes one handler (identified by a unique id) to a list of events. The handler receives the event name, measurements, and metadata as lists of pairs:

import gleam/int
import gleam/io
import gleam/list
import telega/telemetry

pub fn attach_slow_update_logger() {
  telemetry.attach_many(
    id: "my-bot-slow-updates",
    events: [["telega", "update", "stop"]],
    handler: fn(_event, measurements, metadata) {
      let assert Ok(duration) = list.key_find(measurements, "duration")
      let ms = telemetry.native_to_millisecond(duration)

      case ms > 1000 {
        True -> {
          let update_type = case list.key_find(metadata, "update_type") {
            Ok(telemetry.StringValue(t)) -> t
            _ -> "unknown"
          }
          io.println(
            "slow update: " <> update_type <> " took " <> int.to_string(ms) <> "ms",
          )
        }
        False -> Nil
      }
    },
  )
}

Call it once at startup, before telega.init_for_polling() / telega.init(). Detach with telemetry.detach("my-bot-slow-updates").

Handlers run synchronously in the process that emitted the event. Keep them fast, never call the Telegram API from a handler, and offload anything heavy to another process (see below). A handler that crashes is automatically detached by telemetry.

Forwarding events to a process

To get events out of the hot path, forward them to a subject and consume them from your own process (an actor, a metrics aggregator, a test assertion):

import gleam/erlang/process
import telega/telemetry

pub type Event {
  Event(
    name: List(String),
    measurements: List(#(String, Int)),
    metadata: List(#(String, telemetry.Value)),
  )
}

pub fn attach_forwarder(
  id id: String,
  events events: List(List(String)),
) -> process.Subject(Event) {
  let subject = process.new_subject()
  telemetry.attach_many(id:, events:, handler: fn(name, measurements, metadata) {
    process.send(subject, Event(name:, measurements:, metadata:))
  })
  subject
}

This is also the easiest way to assert on telemetry in tests:

pub fn api_call_emits_stop_test() {
  let subject =
    attach_forwarder(id: "test-api-call", events: [
      ["telega", "api_call", "stop"],
    ])

  // ... call the bot / client ...

  let assert Ok(Event(name:, ..)) = process.receive(subject, 100)
  assert name == ["telega", "api_call", "stop"]

  telemetry.detach("test-api-call")
}

Exporters

Because events follow the standard telemetry span convention, any BEAM exporter can consume them — attach it to the event names from the table above:

Types

Handler invoked for each event: event name, measurements, metadata.

pub type EventHandler =
  fn(List(String), List(#(String, Int)), List(#(String, Value))) -> Nil

Metadata value attached to an event.

pub type Value {
  StringValue(String)
  IntValue(Int)
  FloatValue(Float)
  BoolValue(Bool)
}

Constructors

  • StringValue(String)
  • IntValue(Int)
  • FloatValue(Float)
  • BoolValue(Bool)

Values

pub fn attach_many(
  id id: String,
  events events: List(List(String)),
  handler handler: fn(
    List(String),
    List(#(String, Int)),
    List(#(String, Value)),
  ) -> Nil,
) -> Nil

Attach a handler to several events. The id must be unique.

The handler runs synchronously in the process that emitted the event — keep it fast and never call the Telegram API from it. A handler that crashes is detached by telemetry.

pub fn detach(id id: String) -> Nil

Detach a previously attached handler by its id.

pub fn execute(
  event event: List(String),
  measurements measurements: List(#(String, Int)),
  metadata metadata: List(#(String, Value)),
) -> Nil

Emit a telemetry event.

telemetry.execute(["telega", "update", "start"], [#("system_time", now)], [
  #("update_type", telemetry.StringValue("text")),
])
pub fn monotonic_time() -> Int

Current monotonic time in native units. Use for measuring durations.

pub fn native_to_millisecond(time time: Int) -> Int

Convert a native time unit value (e.g. a duration measurement) to milliseconds.

pub fn span(
  event event: List(String),
  metadata metadata: List(#(String, Value)),
  run run: fn() -> Result(a, e),
) -> Result(a, e)

Wrap a Result-returning function in a start/stop/exception span, following the Phoenix/Ecto span convention:

  • event + [start] with system_time before the function runs
  • event + [stop] with monotonic duration on Ok
  • event + [exception] with duration and inspected error metadata on Error
pub fn system_time() -> Int

Current system time in native units.

Search Document