Current section

Files

Jump to
telega src telega@telemetry.erl
Raw

src/telega@telemetry.erl

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