defmodule LoggerTelegramBackend do @moduledoc """ A logger backend for [Telegram](https://telegram.org/). ## Usage In your `c:Application.start/2` callback, add the `LoggerTelegramBackend`: @impl true def start(_type, _args) do {:ok, _} = LoggerTelegramBackend.attach() # ... end Add the following to your configuration: config :logger, LoggerTelegramBackend, chat_id: "your_chat_id", token: "yout_bot_token" To create a Telegram bot, see the next section. ### Creating a Telegram bot To create a Telegram bot, follow the instructions [here](https://core.telegram.org/bots/features#creating-a-new-bot) and get the `token` for the bot. Then send a message to the bot and get your `chat_id`: ```bash TOKEN="..." curl https://api.telegram.org/bot$TOKEN/getUpdates ``` ## Configuration You can configure LoggerTelegramBackend through the application environment. Configure the following options under the `LoggerTelegramBackend` key of the `:logger` application. For example, you can do this in `config/runtime.exs`: # config/runtime.exs config :logger, LoggerTelegramBackend, chat_id: System.fetch_env!("TELEGRAM_CHAT_ID"), token: System.fetch_env!("TELEGRAM_TOKEN"), level: :warning, # ... The basic configuration options are: - `:level` (`t:Logger.level/0`) - the level to be logged by this backend. Note that messages are first filtered by the general `:level` configuration for the `:logger` application. Defaults to `nil` (all levels are logged). - `:metadata` (list of `t:atom/0` or `:all`) - the metadata to be included in the message. `:all` will get all metadata. Defaults to`[:line, :function, :module, :application, :file]`. - `:metadata_filter` (`t:keyword/0`, may also include `t:atom/0`) - the key-value pairs or keys that is must be present in the metadata for a message to be sent. Defaults to `[]`. See the [*Filtering Messages* section](#filtering-messages) below. To customize the behaviour of the HTTP client used by LoggerTelegramBackend, you can use these options: - `:client` (`t:module/0`) - A module that implements the `LoggerTelegramBackend.HTTPClient` behaviour. Defaults to `LoggerTelegramBackend.HTTPClient.Finch` (requires `:finch`). - `:client_pool_opts` (`t:keyword/0`) - Options to configure the HTTP client pool. See `Finch.start_link/1`. Defaults to `[]`. - `:client_request_opts` (`t:keyword/0`) - Options passed to the `c:LoggerTelegramBackend.HTTPClient.request/5` callback. See `Finch.request/3`. Defaults to `[]`. ## Filtering messages If you would like to prevent LoggerTelegramBackend from sending certain messages, you can use the `:metadata_filter` configuration option. It must be configured to be a list of key-value pairs or keys. ### Examples - `metadata_filter: [application: :core]` - The metadata must contain `application: :core` - `metadata_filter: [:user]` - The metadata must contain the key `:user`, but it can be set to any value - `metadata_filter: [{:application, :core}, :user]` - The metadata must contain `application: :core` **AND** must include the key `:user` ## Using a proxy An HTTP proxy can be configured via the `:client_pool_opts` option: config :logger, LoggerTelegramBackend, # ... client_pool_opts: [conn_opts: [proxy: {:http, "127.0.0.1", 8888, []}]] See the [Pool Configuration Options](https://hexdocs.pm/finch/Finch.html#start_link/1-pool-configuration-options) for further information. """ @doc """ Adds the LoggerTelegramBackend backend. ## Options * `:flush` - when `true`, guarantees all messages currently sent to `Logger` are processed before the backend is added ## Example iex> LoggerTelegramBackend.attach() {:ok, _pid} """ @doc since: "3.0.0" @spec attach(keyword) :: Supervisor.on_start_child() def attach(opts \\ []) case Version.match?(System.version(), ">= 1.15.0") do true -> def attach(opts), do: LoggerBackends.add(__MODULE__, opts) false -> def attach(opts), do: Logger.add_backend(__MODULE__, opts) end @doc """ Removes the LoggerTelegramBackend backend. ## Options * `:flush` - when `true`, guarantees all messages currently sent to `Logger` are processed before the backend is removed ## Example iex> LoggerTelegramBackend.detach() :ok """ @doc since: "3.0.0" @spec detach(keyword) :: :ok | {:error, term} def detach(opts \\ []) case Version.match?(System.version(), ">= 1.15.0") do true -> def detach(opts), do: LoggerBackends.remove(__MODULE__, opts) false -> def detach(opts), do: Logger.remove_backend(__MODULE__, opts) end @doc """ Applies runtime configuration. See the module doc for more information. ## Example iex> LoggerTelegramBackend.configure(level: :error) :ok """ @doc since: "3.0.0" @spec configure(keyword) :: term case Version.match?(System.version(), ">= 1.15.0") do true -> def configure(opts), do: LoggerBackends.configure(__MODULE__, opts) false -> def configure(opts), do: Logger.configure_backend(__MODULE__, opts) end @behaviour :gen_event alias LoggerTelegramBackend.Formatter alias LoggerTelegramBackend.Sender @default_metadata [:line, :function, :module, :application, :file] @impl :gen_event def init(__MODULE__) do config = Application.get_env(:logger, __MODULE__, []) validate_config(config) end @impl :gen_event def handle_call({:configure, config}, state) do config = Keyword.merge(Application.get_env(:logger, __MODULE__), config) case validate_config(config) do {:ok, new_state} -> :ok = Application.put_env(:logger, __MODULE__, config) {:ok, :ok, new_state} {:error, _reason} = error -> {:ok, error, state} end end @impl :gen_event def handle_event({_level, gl, _event}, state) when node(gl) != node(), do: {:ok, state} def handle_event({level, _gl, {Logger, message, timestamp, metadata}}, state) do if meet_level?(level, state.level) and metadata_matches?(metadata, state.metadata_filter) do log_event(level, message, timestamp, metadata, state) end {:ok, state} end def handle_event(_event, state), do: {:ok, state} @impl :gen_event def handle_info(_message, state), do: {:ok, state} defp meet_level?(_lvl, nil), do: true defp meet_level?(:warn, min), do: meet_level?(:warning, min) defp meet_level?(lvl, min), do: Logger.compare_levels(lvl, min) != :lt defp metadata_matches?(_metadata, []), do: true defp metadata_matches?(metadata, [{key, value} | rest]) do case Keyword.fetch(metadata, key) do {:ok, ^value} -> metadata_matches?(metadata, rest) _ -> false end end defp metadata_matches?(metadata, [key | rest]) do Keyword.has_key?(metadata, key) and metadata_matches?(metadata, rest) end defp validate_config(config) do cond do is_nil(config[:token]) -> {:error, {:missing_config, :token}} is_nil(config[:chat_id]) -> {:error, {:missing_config, :chat_id}} true -> {:ok, initialize(config)} end end defp initialize(config) do %{ level: config[:level], metadata: config[:metadata] || @default_metadata, metadata_filter: config[:metadata_filter] || [], sender_opts: Keyword.take(config, [:token, :chat_id, :client_request_opts]) } end defp log_event(level, message, _ts, metadata, state) do metadata = take_metadata(metadata, state.metadata) message = Formatter.format_event(message, level, metadata) with {:error, reason} <- Sender.send_message(message, state.sender_opts) do IO.puts(:stderr, "#{__MODULE__} failed to send message: #{inspect(reason)}") end end defp take_metadata(metadata, :all), do: metadata defp take_metadata(metadata, keys) do for key <- Enum.reverse(keys), reduce: [] do acc -> case Keyword.fetch(metadata, key) do {:ok, val} -> [{key, val} | acc] :error -> acc end end end end