-module(telega@telemetry). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/telega/telemetry.gleam"). -export([execute/3, attach_many/3, detach/1, monotonic_time/0, system_time/0, native_to_millisecond/1, span/3]). -export_type([value/0]). -if(?OTP_RELEASE >= 27). -define(MODULEDOC(Str), -moduledoc(Str)). -define(DOC(Str), -doc(Str)). -else. -define(MODULEDOC(Str), -compile([])). -define(DOC(Str), -compile([])). -endif. ?MODULEDOC( " Telemetry events for production observability.\n" "\n" " Telega emits [`telemetry`](https://hexdocs.pm/telemetry/) events at every\n" " key point of the update lifecycle, so standard BEAM exporters (PromEx,\n" " `opentelemetry_telemetry`, custom handlers) work out of the box.\n" " Emitting an event with no attached handlers is nearly free (a single ETS\n" " lookup), so instrumentation costs nothing until you attach a handler.\n" "\n" " ## Event reference\n" "\n" " | Event | Measurements | Metadata |\n" " |---|---|---|\n" " | `telega.update.start` | `system_time` | `update_type`, `chat_id`, `from_id` |\n" " | `telega.update.stop` | `duration` | `update_type`, `chat_id`, `from_id` |\n" " | `telega.update.exception` | `duration` | `error`, `update_type`, `chat_id`, `from_id` |\n" " | `telega.api_call.start` | `system_time` | `method` |\n" " | `telega.api_call.stop` | `duration` | `method`, `status` |\n" " | `telega.api_call.exception` | `duration` | `method`, `error` |\n" " | `telega.api_call.retry` | `retry_after` (ms) | `method`, `attempt` |\n" " | `telega.request_queue.depth` | `depth` | `rule_id`, `priority` |\n" " | `telega.rate_limit.hit` | `count` | `update_type`, `chat_id`, `from_id` |\n" " | `telega.chat_instance.spawn` | `count` | `chat_id`, `from_id` |\n" " | `telega.chat_instance.terminate` | `count` | `key`, `reason` |\n" " | `telega.flow.step` | `duration` | `flow_name`, `step` |\n" " | `telega.flow.timeout` | `count` | `flow_name`, `step` |\n" " | `telega.flow.cancel` | `count` | `flow_name`, `step` |\n" " | `telega.shutdown.start` | `system_time` | — |\n" " | `telega.shutdown.stop` | `duration`, `drained` | `timed_out` |\n" "\n" " - Update and API call events follow the **span convention**\n" " (`start`/`stop`/`exception` with a monotonic `duration`), the same\n" " pattern used by Phoenix and Ecto.\n" " - `duration` and `system_time` are in **native time units** — convert\n" " with `native_to_millisecond`.\n" " - `telega.update.exception` fires when a handler returns `Error(...)`,\n" " before your `catch_handler` runs.\n" " - `telega.chat_instance.terminate` fires only for abnormal stops;\n" " `reason` tells you which.\n" " - `telega.rate_limit.hit` fires for every update rejected by\n" " `router.with_rate_limit`.\n" " - `telega.shutdown.start`/`stop` wrap `telega.shutdown`'s graceful drain;\n" " `drained` is the number of in-flight updates waited for and `timed_out`\n" " is `True` if the drain timeout elapsed first.\n" "\n" " ## Attaching a handler\n" "\n" " `attach_many` subscribes one handler (identified by a unique id) to a\n" " list of events. The handler receives the event name, measurements, and\n" " metadata as lists of pairs:\n" "\n" " ```gleam\n" " import gleam/int\n" " import gleam/io\n" " import gleam/list\n" " import telega/telemetry\n" "\n" " pub fn attach_slow_update_logger() {\n" " telemetry.attach_many(\n" " id: \"my-bot-slow-updates\",\n" " events: [[\"telega\", \"update\", \"stop\"]],\n" " handler: fn(_event, measurements, metadata) {\n" " let assert Ok(duration) = list.key_find(measurements, \"duration\")\n" " let ms = telemetry.native_to_millisecond(duration)\n" "\n" " case ms > 1000 {\n" " True -> {\n" " let update_type = case list.key_find(metadata, \"update_type\") {\n" " Ok(telemetry.StringValue(t)) -> t\n" " _ -> \"unknown\"\n" " }\n" " io.println(\n" " \"slow update: \" <> update_type <> \" took \" <> int.to_string(ms) <> \"ms\",\n" " )\n" " }\n" " False -> Nil\n" " }\n" " },\n" " )\n" " }\n" " ```\n" "\n" " Call it once at startup, before `telega.init_for_polling()` /\n" " `telega.init()`. Detach with `telemetry.detach(\"my-bot-slow-updates\")`.\n" "\n" " **Handlers run synchronously in the process that emitted the event.**\n" " Keep them fast, never call the Telegram API from a handler, and offload\n" " anything heavy to another process (see below). A handler that crashes is\n" " automatically detached by telemetry.\n" "\n" " ## Forwarding events to a process\n" "\n" " To get events out of the hot path, forward them to a subject and consume\n" " them from your own process (an actor, a metrics aggregator, a test\n" " assertion):\n" "\n" " ```gleam\n" " import gleam/erlang/process\n" " import telega/telemetry\n" "\n" " pub type Event {\n" " Event(\n" " name: List(String),\n" " measurements: List(#(String, Int)),\n" " metadata: List(#(String, telemetry.Value)),\n" " )\n" " }\n" "\n" " pub fn attach_forwarder(\n" " id id: String,\n" " events events: List(List(String)),\n" " ) -> process.Subject(Event) {\n" " let subject = process.new_subject()\n" " telemetry.attach_many(id:, events:, handler: fn(name, measurements, metadata) {\n" " process.send(subject, Event(name:, measurements:, metadata:))\n" " })\n" " subject\n" " }\n" " ```\n" "\n" " This is also the easiest way to assert on telemetry in tests:\n" "\n" " ```gleam\n" " pub fn api_call_emits_stop_test() {\n" " let subject =\n" " attach_forwarder(id: \"test-api-call\", events: [\n" " [\"telega\", \"api_call\", \"stop\"],\n" " ])\n" "\n" " // ... call the bot / client ...\n" "\n" " let assert Ok(Event(name:, ..)) = process.receive(subject, 100)\n" " assert name == [\"telega\", \"api_call\", \"stop\"]\n" "\n" " telemetry.detach(\"test-api-call\")\n" " }\n" " ```\n" "\n" " ## Exporters\n" "\n" " Because events follow the standard telemetry span convention, any BEAM\n" " exporter can consume them — attach it to the event names from the table\n" " above:\n" "\n" " - **Prometheus**: the [`prometheus`](https://hex.pm/packages/prometheus)\n" " Erlang package — register counters/histograms at startup and update\n" " them from an `attach_many` handler (convert durations with\n" " `native_to_millisecond`).\n" " - **Elixir releases**: [`telemetry_metrics`](https://hexdocs.pm/telemetry_metrics/) +\n" " [`telemetry_metrics_prometheus`](https://hexdocs.pm/telemetry_metrics_prometheus/),\n" " or a custom [PromEx](https://hexdocs.pm/prom_ex/) plugin — declare\n" " metrics like `counter(\"telega.update.stop.duration\")`.\n" " - **OpenTelemetry**: [`opentelemetry_telemetry`](https://hexdocs.pm/opentelemetry_telemetry/)\n" " bridges the `start`/`stop`/`exception` spans into traces.\n" ). -type value() :: {string_value, binary()} | {int_value, integer()} | {float_value, float()} | {bool_value, boolean()}. -file("src/telega/telemetry.gleam", 273). -spec value_to_dynamic(value()) -> gleam@dynamic:dynamic_(). value_to_dynamic(Value) -> case Value of {string_value, String} -> gleam_stdlib:identity(String); {int_value, Int} -> gleam_stdlib:identity(Int); {float_value, Float} -> gleam_stdlib:identity(Float); {bool_value, Bool} -> gleam_stdlib:identity(Bool) end. -file("src/telega/telemetry.gleam", 199). ?DOC( " Emit a telemetry event.\n" "\n" " ```gleam\n" " telemetry.execute([\"telega\", \"update\", \"start\"], [#(\"system_time\", now)], [\n" " #(\"update_type\", telemetry.StringValue(\"text\")),\n" " ])\n" " ```\n" ). -spec execute( list(binary()), list({binary(), integer()}), list({binary(), value()}) ) -> nil. execute(Event, Measurements, Metadata) -> _ = telemetry:execute( gleam@list:map(Event, fun erlang:binary_to_atom/1), begin _pipe = Measurements, _pipe@1 = gleam@list:map( _pipe, fun(Pair) -> {erlang:binary_to_atom(erlang:element(1, Pair)), erlang:element(2, Pair)} end ), maps:from_list(_pipe@1) end, begin _pipe@2 = Metadata, _pipe@3 = gleam@list:map( _pipe@2, fun(Pair@1) -> {erlang:binary_to_atom(erlang:element(1, Pair@1)), value_to_dynamic(erlang:element(2, Pair@1))} end ), maps:from_list(_pipe@3) end ), nil. -file("src/telega/telemetry.gleam", 282). -spec dynamic_to_value(gleam@dynamic:dynamic_()) -> value(). dynamic_to_value(Value) -> Decoder = gleam@dynamic@decode:one_of( gleam@dynamic@decode:map( {decoder, fun gleam@dynamic@decode:decode_string/1}, fun(Field@0) -> {string_value, Field@0} end ), [gleam@dynamic@decode:map( {decoder, fun gleam@dynamic@decode:decode_bool/1}, fun(Field@0) -> {bool_value, Field@0} end ), gleam@dynamic@decode:map( {decoder, fun gleam@dynamic@decode:decode_int/1}, fun(Field@0) -> {int_value, Field@0} end ), gleam@dynamic@decode:map( {decoder, fun gleam@dynamic@decode:decode_float/1}, fun(Field@0) -> {float_value, Field@0} end )] ), case gleam@dynamic@decode:run(Value, Decoder) of {ok, Value@1} -> Value@1; {error, _} -> {string_value, gleam@string:inspect(Value)} end. -file("src/telega/telemetry.gleam", 224). ?DOC( " Attach a handler to several events. The `id` must be unique.\n" "\n" " The handler runs synchronously in the process that emitted the event —\n" " keep it fast and never call the Telegram API from it. A handler that\n" " crashes is detached by telemetry.\n" ). -spec attach_many( binary(), list(list(binary())), fun((list(binary()), list({binary(), integer()}), list({binary(), value()})) -> nil) ) -> nil. attach_many(Id, Events, Handler) -> _ = telemetry:attach_many( Id, gleam@list:map( Events, fun(Event) -> gleam@list:map(Event, fun erlang:binary_to_atom/1) end ), fun(Event@1, Measurements, Metadata, _) -> Handler( gleam@list:map(Event@1, fun erlang:atom_to_binary/1), begin _pipe = Measurements, _pipe@1 = maps:to_list(_pipe), gleam@list:map( _pipe@1, fun(Pair) -> {erlang:atom_to_binary(erlang:element(1, Pair)), erlang:element(2, Pair)} end ) end, begin _pipe@2 = Metadata, _pipe@3 = maps:to_list(_pipe@2), gleam@list:map( _pipe@3, fun(Pair@1) -> {erlang:atom_to_binary(erlang:element(1, Pair@1)), dynamic_to_value(erlang:element(2, Pair@1))} end ) end ) end, nil ), nil. -file("src/telega/telemetry.gleam", 252). ?DOC(" Detach a previously attached handler by its id.\n"). -spec detach(binary()) -> nil. detach(Id) -> _ = telemetry:detach(Id), nil. -file("src/telega/telemetry.gleam", 259). ?DOC(" Current monotonic time in native units. Use for measuring durations.\n"). -spec monotonic_time() -> integer(). monotonic_time() -> erlang:monotonic_time(). -file("src/telega/telemetry.gleam", 263). ?DOC(" Current system time in native units.\n"). -spec system_time() -> integer(). system_time() -> erlang:system_time(). -file("src/telega/telemetry.gleam", 269). ?DOC(" Convert a native time unit value (e.g. a `duration` measurement) to milliseconds.\n"). -spec native_to_millisecond(integer()) -> integer(). native_to_millisecond(Time) -> erlang:convert_time_unit( Time, erlang:binary_to_atom(<<"native"/utf8>>), erlang:binary_to_atom(<<"millisecond"/utf8>>) ). -file("src/telega/telemetry.gleam", 301). ?DOC( " Wrap a `Result`-returning function in a `start`/`stop`/`exception` span,\n" " following the Phoenix/Ecto span convention:\n" "\n" " - `event + [start]` with `system_time` before the function runs\n" " - `event + [stop]` with monotonic `duration` on `Ok`\n" " - `event + [exception]` with `duration` and inspected `error` metadata on `Error`\n" ). -spec span( list(binary()), list({binary(), value()}), fun(() -> {ok, KCL} | {error, KCM}) ) -> {ok, KCL} | {error, KCM}. span(Event, Metadata, Run) -> Started_at = erlang:monotonic_time(), execute( lists:append(Event, [<<"start"/utf8>>]), [{<<"system_time"/utf8>>, erlang:system_time()}], Metadata ), Result = Run(), Duration = erlang:monotonic_time() - Started_at, case Result of {ok, _} -> execute( lists:append(Event, [<<"stop"/utf8>>]), [{<<"duration"/utf8>>, Duration}], Metadata ); {error, Error} -> execute( lists:append(Event, [<<"exception"/utf8>>]), [{<<"duration"/utf8>>, Duration}], [{<<"error"/utf8>>, {string_value, gleam@string:inspect(Error)}} | Metadata] ) end, Result.