Custom Elixir Loggers
Integrating Elixir Logging with Discord: A Custom Logger Implementation
In the world of software development, efficient logging is crucial for monitoring applications, debugging issues, and ensuring system reliability. Elixir, a dynamic, functional language designed for building scalable and maintainable applications, offers a powerful logging facility through its Logger module. This post will guide you through creating a custom Elixir logger backend to send logs directly to Discord, a popular communication platform. This approach leverages Elixir’s extensibility and Discord’s API to streamline log monitoring and analysis.
Understanding Logger Backends in Elixir
Elixir’s Logger is a built-in module that provides a mechanism for emitting logs. The Logger supports multiple backends, which are components that handle log messages. By default, Elixir logs to the console, but you can create custom backends to route logs to various destinations, such as files, external services, or, in our case, Discord.
Custom backends must implement the :gen_event behaviour, which involves defining a series of callback functions that the Logger will invoke during its operation. These callbacks include init/1, handle_event/2, handle_call/2, handle_info/2, among others, allowing developers to customize how log messages are processed and dispatched.
Building a Discord Logger Backend
Our custom logge will send log messages to a specified Discord channel. This process requires interfacing with the Discord API, for which we'll use the Tesla HTTP client library to simplify HTTP requests.
First, we define the MyApp.DiscordLogger module and set up Tesla middleware for interacting with Discord's API:
defmodule MyApp.DiscordLogger do
require Logger
use Tesla
plug Tesla.Middleware.BaseUrl, "https://discord.com/api/v10"
plug Tesla.Middleware.JSON
@behaviour :gen_event
The @behaviour :gen_event
directive indicates that our logger will adhere to the gen_event behaviour, necessitating the implementation of specific callbacks.
Implementing Callback Functions
init/1
initializes the backend with any given configuration.handle_call/2
allows for runtime configuration changes.handle_event/2
processes log messages, determining whether to forward them to Discord based on the log level and node.handle_info/2
andhandle_event(:flush, state)
are required for the:gen_event
behaviour but are not used in this specific implementation.
Filtering Log Levels
The is_level_okay/2
function checks whether a log message meets the minimum log level requirement before sending it to Discord, ensuring that only relevant logs are forwarded.
def handle_event({level, _gl, {Logger, msg, ts, md}}, %{} = state) do
if is_level_okay(level, state.level) do
...
end
Sending Logs to Discord
'log_to_discord/4' is the core function where log messages are formatted and sent to Discord. It constructs the request, including formatting the message and setting the appropriate headers for authentication. The function then makes a POST request to Discord's API endpoint for sending messages to a channel.
def log_to_discord(channel_id, level, msg, ts, md) do
formatted_msg = format_message(level, msg, ts, md)
url = "/channels/#{channel_id}/messages"
body = %{"content" => formatted_msg}
headers = [
{"authorization", "Bot #{Application.get_env(:logger, :discord) |> Keyword.get(:bot_token)}"}
]
post(url, body, headers: headers)
|> case do
{:ok, %{status: status}} when status in 200..299 -> :ok
{:error, reason} -> Logger.error("Error sending log to Discord: #{reason}")
_any -> Logger.error("Error sending log to Discord")
end
end
Full Example
defmodule MyApp.DiscordLogger do
require Logger
use Tesla
plug Tesla.Middleware.BaseUrl, "https://discord.com/api/v10"
plug Tesla.Middleware.JSON
@behaviour :gen_event
def init({__MODULE__, name}) do
{:ok, configure(name, [])}
end
def handle_call({:configure, opts}, %{name: name} = state) do
{:ok, :ok, configure(name, opts, state)}
end
def handle_event({_level, gl, {Logger, _, _, _}}, state) when node(gl) != node() do
{:ok, state}
end
def handle_event({level, _gl, {Logger, msg, ts, md}}, %{} = state) do
if is_level_okay(level, state.level) do
log_to_discord(state.channel_id, level, msg, ts, md)
end
{:ok, state}
end
def handle_event(:flush, state) do
{:ok, state}
end
def handle_info(_, state) do
{:ok, state}
end
defp is_level_okay(lvl, min_level) do
is_nil(min_level) or Logger.compare_levels(lvl, min_level) != :lt
end
def log_to_discord(channel_id, level, msg, ts, md) do
formatted_msg = format_message(level, msg, ts, md)
url = "/channels/#{channel_id}/messages"
body = %{"content" => formatted_msg}
headers = [
{"authorization",
"Bot #{Application.get_env(:logger, :discord) |> Keyword.get(:bot_token)}"}
]
post(url, body, headers: headers)
|> case do
{:ok, %{status: status}} when status in 200..299 -> :ok
{:error, reason} -> Logger.error("Error sending log to Discord: #{reason}")
_any -> Logger.error("Error sending log to Discord")
end
end
def format_message(level, msg, _ts, md) do
timestamp = DateTime.utc_now()
source = md[:application]
msg = IO.iodata_to_binary(msg) |> String.slice(0..1900)
"[#{timestamp}] #{source} [#{level}] `#{msg}`"
end
defp configure(name, opts) do
state = %{name: name, format: nil, level: nil, metadata: nil, metadata_filter: nil}
configure(name, opts, state)
end
defp configure(name, opts, state) do
env = Application.get_env(:logger, name, [])
opts = Keyword.merge(env, opts)
Application.put_env(:logger, name, opts)
new_state = %{
channel_id: Keyword.get(opts, :channel_id, nil),
level: Keyword.get(opts, :level)
}
Map.merge(state, new_state)
end
end
Example Usage
To use MyApp.DiscordLogger, add it as a backend to your Elixir application's Logger configuration, specifying the Discord channel ID and bot token:
config :logger,
backends: [{MyApp.DiscordLogger, :discord_logger}]
config :logger, :discord_logger,
channel_id: "YOUR_DISCORD_CHANNEL_ID",
level: :error,
bot_token: "YOUR_BOT_TOKEN"
This configuration enables your Elixir application to send log messages directly to your Discord channel, facilitating real-time monitoring and alerting.
Conclusion
Creating a custom Elixir logger backend for Discord demonstrates the flexibility of Elixir’s logging system and the ease of integrating Elixir applications with external platforms. By routing logs to Discord, teams can leverage Discord's features for collaborative debugging and monitoring, streamlining their development workflow. This approach exemplifies how Elixir's extensibility can be utilized to enhance application observability and operational efficiency.
For more details on implementing custom logger backends in Elixir, refer to the official Logger documentation.