-module(pharos_ffi). -export([ %% Telemetry poller wrapper start_poller/1, list_measurements/1, %% Telemetry attach/detach attach_many/4, detach/1, %% Memory unit conversion convert_memory/2, %% Alert diagnostic collect_diagnostic/0, %% Custom telemetry measurement emitters emit_cluster_nodes/0, emit_host_memory/0, emit_host_disk/0, %% Cross-typed-name plumbing for the event bus manager_from_name/1, %% Supervisor shutdown shutdown_supervisor/1 ]). %% Politely stop a supervisor (and therefore its tree). Idempotent: a %% second call against a dead pid is a no-op so `pharos:stop/1` is safe to %% call multiple times. shutdown_supervisor(Pid) when is_pid(Pid) -> case erlang:is_process_alive(Pid) of false -> nil; true -> try _ = supervisor:stop(Pid), nil catch _:_ -> nil end end; shutdown_supervisor(_Other) -> nil. %% =========================================================================== %% Alert diagnostic %% =========================================================================== %% Capture a brief VM snapshot for the alert diagnostic field. %% Returns a binary (Gleam String) describing memory usage and process count. collect_diagnostic() -> Memory = erlang:memory(), TotalBytes = proplists:get_value(total, Memory, 0), TotalMb = TotalBytes / 1_048_576, Procs = erlang:system_info(process_count), iolist_to_binary( io_lib:format("memory=~.2fMb processes=~w", [TotalMb, Procs]) ). %% =========================================================================== %% Telemetry poller %% =========================================================================== %% Translate Gleam-encoded poller options into the keyword list shape %% telemetry_poller expects, then start the poller. %% %% Gleam's variant encoding (e.g. `{period, Ms}`, `{builtin, memory}`) does %% not match telemetry_poller's expected term shapes (e.g. bare atom %% `memory`, `{process_info, [{name, _}, ...]}`), so we translate at the %% FFI boundary. start_poller(GleamOptions) -> ErlangOptions = lists:map(fun translate_option/1, GleamOptions), case telemetry_poller:start_link(ErlangOptions) of {ok, Pid} -> {ok, Pid}; ignore -> {error, poller_ignored}; {error, Reason} -> {error, {poller_start_failed, format_reason(Reason)}} end. translate_option({period, Ms}) -> {period, Ms}; translate_option({measurements, Specs}) -> {measurements, lists:map(fun translate_measurement/1, Specs)}. translate_measurement({builtin, Name}) -> %% Built-in measurements (`memory`, `total_run_queue_lengths`, %% `system_counts`, `persistent_term`) are bare atoms in %% telemetry_poller's API. Name; translate_measurement({process_info_spec, Name, Event, Keys}) -> {process_info, [{name, Name}, {event, Event}, {keys, Keys}]}; translate_measurement({custom, Module, Function, Args}) -> {Module, Function, Args}. %% telemetry_poller normalises measurements into internal %% {telemetry_poller_builtin, Name, []} tuples when storing them. %% This shim converts those back to the bare atoms the Gleam side may want. list_measurements(Poller) -> lists:map(fun restore_measurement/1, telemetry_poller:list_measurements(Poller)). restore_measurement({telemetry_poller_builtin, memory, _}) -> memory; restore_measurement({telemetry_poller_builtin, total_run_queue_lengths, _}) -> total_run_queue_lengths; restore_measurement({telemetry_poller_builtin, system_counts, _}) -> system_counts; restore_measurement({telemetry_poller_builtin, persistent_term, _}) -> persistent_term; restore_measurement(Other) -> Other. %% =========================================================================== %% Telemetry attach / detach %% =========================================================================== %% Wraps a 2-arity Gleam handler in the 4-arity Erlang fun that %% telemetry:attach_many/4 expects, packing the four arguments into a %% TelemetryEvent record that Gleam can pattern-match on. attach_many(HandlerId, Events, GleamHandler, Config) -> Handler = fun(EventName, Measurements, Metadata, Cfg) -> Event = {telemetry_event, EventName, Measurements, Metadata}, GleamHandler(Event, Cfg) end, case telemetry:attach_many(HandlerId, Events, Handler, Config) of ok -> {ok, nil}; {error, already_exists} -> {error, handler_already_attached}; {error, Reason} -> {error, {attach_failed, format_reason(Reason)}} end. detach(HandlerId) -> case telemetry:detach(HandlerId) of ok -> {ok, nil}; {error, not_found} -> {error, handler_not_found}; {error, Reason} -> {error, {detach_failed, format_reason(Reason)}} end. %% =========================================================================== %% Memory unit conversion %% =========================================================================== convert_memory(Measurements, Unit) -> Factor = case Unit of kb -> 1024; mb -> 1_048_576; gb -> 1_073_741_824 end, maps:map(fun(_Key, Bytes) -> Bytes / Factor end, Measurements). %% =========================================================================== %% Custom telemetry measurement emitters. %% %% Each function is invoked by telemetry_poller every polling cycle. They %% emit telemetry events whose decoded shape lives in pharos/measurement.gleam. %% =========================================================================== %% Emit `[pharos, cluster, nodes]`: this node's name plus connected nodes. emit_cluster_nodes() -> Self = erlang:atom_to_binary(erlang:node(), utf8), Nodes = [erlang:atom_to_binary(N, utf8) || N <- erlang:nodes()], telemetry:execute( [pharos, cluster, nodes], #{count => length(Nodes)}, #{self => Self, nodes => Nodes} ). %% Host-memory probe. Reads OS memory totals from `/proc/meminfo` (Linux) and %% emits `[pharos, host, memory]` with real byte counts and no `status` key %% (an absent status decodes as `Implemented` on the Gleam side). On platforms %% without a readable `/proc/meminfo`, degrades to the placeholder zeros + %% `status: unimplemented` so the event contract holds everywhere. emit_host_memory() -> case read_meminfo() of {ok, Total, Available} -> Used = max(Total - Available, 0), telemetry:execute( [pharos, host, memory], #{ total_bytes => Total, used_bytes => Used, available_bytes => Available }, #{} ); error -> telemetry:execute( [pharos, host, memory], #{total_bytes => 0, used_bytes => 0, available_bytes => 0}, #{status => unimplemented} ) end. %% Scaffolded host-disk probe. Same shape as `emit_host_memory/0`. emit_host_disk() -> telemetry:execute( [pharos, host, disk], #{total_bytes => 0, used_bytes => 0, available_bytes => 0}, #{status => unimplemented} ). %% =========================================================================== %% Name-to-Manager cast. %% %% At the Erlang level a `Name(AlertEvent)` is just a registered atom and an %% eparch `Manager(AlertEvent)` is just a pid-or-name reference. gen_event %% accepts atoms wherever it accepts pids, so this is a zero-cost identity %% function that crosses the Gleam type boundary. %% =========================================================================== manager_from_name(Name) -> Name. %% =========================================================================== %% Internal %% =========================================================================== format_reason(Reason) -> iolist_to_binary(io_lib:format("~p", [Reason])). %% Read `MemTotal` and `MemAvailable` from `/proc/meminfo`, returning their %% values in bytes. `/proc/meminfo` reports sizes in kB (1024-byte units), so %% each value is scaled by 1024. Returns `error` if the file is unreadable or %% either field is absent (e.g. on non-Linux hosts, or kernels predating %% `MemAvailable`). read_meminfo() -> case file:read_file("/proc/meminfo") of {ok, Bin} -> Lines = binary:split(Bin, <<"\n">>, [global]), Map = lists:foldl(fun parse_meminfo_line/2, #{}, Lines), case {maps:find(<<"MemTotal">>, Map), maps:find(<<"MemAvailable">>, Map)} of {{ok, TotalKb}, {ok, AvailKb}} -> {ok, TotalKb * 1024, AvailKb * 1024}; _ -> error end; {error, _Reason} -> error end. %% Fold one `/proc/meminfo` line (e.g. `<<"MemTotal: 16384256 kB">>`) into %% the accumulator map as `Key => Kb`. Lines that don't parse are skipped. parse_meminfo_line(Line, Acc) -> case binary:split(Line, <<":">>) of [Key, Rest] -> case parse_kb(Rest) of {ok, Kb} -> maps:put(Key, Kb, Acc); error -> Acc end; _Other -> Acc end. %% Parse the leading integer (in kB) from a `/proc/meminfo` value such as %% `<<" 16384256 kB">>`, ignoring surrounding whitespace and the unit suffix. parse_kb(Rest) -> case string:to_integer(string:trim(Rest)) of {Kb, _Tail} when is_integer(Kb) -> {ok, Kb}; _Other -> error end.