Current section
Files
Jump to
Current section
Files
lib/error_tracker_notifier.ex
defmodule ErrorTrackerNotifier do
@moduledoc """
Attaches to ErrorTracker telemetry events and sends notifications for new errors.
Includes throttling to limit notification frequency for repeated errors.
## Configuration
Configure in your config.exs:
```elixir
# For email notifications
config :error_tracker_notifier,
notification_type: :email,
from_email: "support@example.com",
to_email: "support@example.com",
mailer: MyApp.Mailer,
throttle_seconds: 10 # Optional: Throttle time between notifications (default: 10 seconds)
# For Discord webhook notifications
config :error_tracker_notifier,
notification_type: :discord,
webhook_url: "https://discord.com/api/webhooks/your-webhook-url",
throttle_seconds: 30 # Optional: Throttle time between notifications
# For both email and Discord notifications
config :error_tracker_notifier,
notification_type: [:email, :discord], # Can be a single atom or a list
from_email: "support@example.com",
to_email: "support@example.com",
mailer: MyApp.Mailer,
webhook_url: "https://discord.com/api/webhooks/your-webhook-url",
throttle_seconds: 60 # Optional: Throttle time between notifications
```
## Setup
Add to your application.ex:
```elixir
def start(_type, _args) do
children = [
# ...other children
{ErrorTrackerNotifier, []}
]
# Start the application supervisor
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
```
"""
use GenServer
require Logger
alias ErrorTrackerNotifier.Email
alias ErrorTrackerNotifier.Discord
# Cleanup old entries every 5 minutes
@cleanup_interval :timer.minutes(5)
# Client API
@doc """
Starts the ErrorTrackerNotifier process.
If the process is already started, returns {:error, {:already_started, pid}}.
This is the expected behavior when started by a supervisor.
"""
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Attach to ErrorTracker telemetry events.
Automatically called when the GenServer starts.
You can also call this function manually if you need to reattach telemetry events
or if you're starting the process in a different way.
"""
def setup do
case Process.whereis(__MODULE__) do
nil ->
Logger.warning("Cannot setup telemetry - ErrorTrackerNotifier process not found")
{:error, :process_not_found}
_pid ->
GenServer.call(__MODULE__, :setup)
end
end
@doc """
Setup telemetry handlers directly without requiring the GenServer to be running.
This can be used in applications where you want to handle telemetry events but
don't want to start the GenServer process.
Returns :ok on success, or :error on failure.
"""
def setup_telemetry do
do_setup()
end
@doc """
Send a notification for a new error occurrence.
"""
def send_occurrence_notification(occurrence, header_txt) do
GenServer.call(__MODULE__, {:notify, occurrence, header_txt})
end
def get_app_name do
app_atom = app_atom()
Application.get_env(app_atom, :app_name, Atom.to_string(app_atom))
rescue
_ -> "Application"
end
def get_config(key, default) do
# Get config directly under :error_tracker_notifier
value = Application.get_env(:error_tracker_notifier, key)
# Check for legacy config and show warning
if is_nil(value) do
app = app_atom()
if app != :error_tracker_notifier do
legacy_config = Application.get_env(app, :error_tracker_notifier)
if legacy_config && Keyword.has_key?(legacy_config, key) do
Logger.error("""
[ERROR] Found configuration under #{inspect(app)}:error_tracker_notifier instead of :error_tracker_notifier
The configuration format has changed. Please update your config files:
Old format (no longer supported):
config :#{app}, :error_tracker_notifier, key: value
New format (required):
config :error_tracker_notifier, key: value
All configuration must be moved to the new format.
""")
end
end
end
# Return the value or default
value || default
end
# GenServer callbacks
@impl true
def init(_opts) do
# Check if we have valid configuration to determine whether to start
if has_valid_config?() do
# Initialize state with an empty map for tracking error occurrences
state = %{
errors: %{},
setup_complete: false
}
# Schedule periodic cleanup
schedule_cleanup()
# Setup telemetry handlers
{:ok, state, {:continue, :setup}}
else
# No valid configuration found, shut down gracefully
Logger.info("ErrorTrackerNotifier shutting down: No configuration found")
:ignore
end
end
@impl true
def handle_continue(:setup, state) do
case do_setup() do
:ok -> {:noreply, %{state | setup_complete: true}}
_ -> {:noreply, state}
end
end
@impl true
def handle_call(:setup, _from, state) do
case do_setup() do
:ok -> {:reply, :ok, %{state | setup_complete: true}}
error -> {:reply, error, state}
end
end
@impl true
def handle_call({:notify, occurrence, header_txt}, _from, state) do
{result, new_state} = handle_notification(occurrence, header_txt, state)
{:reply, result, new_state}
end
# Handle all info messages
@impl true
def handle_info(message, state) do
case message do
# Handle cleanup timer
:cleanup ->
# Schedule next cleanup first to ensure it always runs
schedule_cleanup()
# Remove error records older than 1 hour
cleaned_state = cleanup_old_records(state)
{:noreply, cleaned_state}
# Handle telemetry events
{:telemetry_event, event_name, measurements, metadata, _config} ->
try do
process_telemetry_event(event_name, measurements, metadata, state)
rescue
error ->
Logger.error("Error processing telemetry event: #{inspect(error)}")
{:noreply, state}
end
# Handle unexpected messages
unexpected ->
Logger.debug("Unexpected message received: #{inspect(unexpected)}")
{:noreply, state}
end
end
# Public telemetry event handler for Telemetry to call
@doc false
def handle_telemetry_event(event_name, measurements, metadata, config) do
# Forward telemetry events to the GenServer process
case Process.whereis(__MODULE__) do
nil ->
Logger.warning("Cannot forward telemetry event - ErrorTrackerNotifier process not found")
pid ->
send(pid, {:telemetry_event, event_name, measurements, metadata, config})
end
end
# Process telemetry events within the GenServer
defp process_telemetry_event(event_name, measurements, metadata, state) do
case event_name do
[:error_tracker, :error, :new] ->
error_id = metadata.error.id
reason = truncate_reason(metadata.occurrence.reason)
Logger.debug("ErrorTrackerNotifier event: new error #{inspect(error_id)}")
{_, new_state} = handle_notification(metadata.occurrence, "New Error! (#{reason})", state)
{:noreply, new_state}
[:error_tracker, :occurrence, :new] ->
occurrence_id = metadata.occurrence.id
error_id = metadata.occurrence.error_id
reason = truncate_reason(metadata.occurrence.reason)
Logger.debug(
"ErrorTrackerNotifier event: new occurrence #{inspect(occurrence_id)} " <>
"for error #{inspect(error_id)}"
)
{_, new_state} =
handle_notification(metadata.occurrence, "Error: #{reason}", state)
{:noreply, new_state}
_ ->
Logger.debug(
"Unhandled ErrorTracker event: #{inspect(event_name)}, " <>
"measurements: #{inspect(measurements)}, metadata: #{inspect(metadata)}"
)
{:noreply, state}
end
end
# Clean up error records older than 1 hour
defp cleanup_old_records(state) do
now = System.system_time(:second)
one_day_ago = (now - :timer.hours(1)) |> div(1000)
cleaned_errors =
Enum.filter(state.errors, fn {_error_id, %{last_time: last_time}} ->
last_time >= one_day_ago
end)
|> Map.new()
# Return updated state with cleaned errors
%{state | errors: cleaned_errors}
end
# Private implementation
defp do_setup do
# Check if ErrorTracker is loaded
unless Code.ensure_loaded?(ErrorTracker) do
Logger.warning(
"ErrorTracker module not found. Make sure error_tracker dependency is installed."
)
:error
else
# Listen for both new errors and new occurrences
events = [
[:error_tracker, :error, :new],
[:error_tracker, :occurrence, :new]
]
# Detach existing handlers to avoid duplicates on reloads
:telemetry.detach("error-tracker-notifications")
# Attach our handler to the events
:ok =
:telemetry.attach_many(
"error-tracker-notifications",
events,
&__MODULE__.handle_telemetry_event/4,
nil
)
notification_types = get_notification_types()
types_str = notification_types |> Enum.map(&to_string/1) |> Enum.join(" and ")
Logger.debug(
"ErrorTracker #{types_str} notifications set up for new errors and occurrences"
)
:ok
end
rescue
e ->
Logger.error("Failed to setup ErrorTracker notifications: #{inspect(e)}")
:error
end
defp handle_notification(occurrence, header_txt, state) do
error_id = occurrence.error_id
now = System.system_time(:second)
throttle_seconds = get_config(:throttle_seconds, 10)
# Check current error state
error_state = Map.get(state.errors, error_id, %{count: 0, last_time: 0})
time_since_last = now - error_state.last_time
cond do
# First occurrence or outside throttle window
error_state.last_time == 0 || time_since_last >= throttle_seconds ->
# Send notification with count from previous batch (if any)
count_to_report = error_state.count
# Send the notification
result = send_notifications(occurrence, header_txt, count_to_report)
# Reset counter and update timestamp
updated_errors =
Map.put(state.errors, error_id, %{
# Start with 0 for the current occurrence (it's already been reported)
count: 0,
last_time: now
})
{result, %{state | errors: updated_errors}}
# Within throttle window
true ->
# Increment count but don't send notification
updated_error = %{
count: error_state.count + 1,
last_time: error_state.last_time
}
updated_errors = Map.put(state.errors, error_id, updated_error)
Logger.debug(
"Throttled notification for error #{error_id}. Count: #{updated_error.count}"
)
{[{:throttled, error_id, updated_error.count}], %{state | errors: updated_errors}}
end
end
defp send_notifications(occurrence, header_txt, count) do
notification_types = get_notification_types()
# Format the header with count information when applicable
header_with_count = format_header_with_count(header_txt, count)
Enum.map(notification_types, fn type ->
case type do
:email ->
Email.send_occurrence_notification(occurrence, header_with_count, config_app_name())
:discord ->
Discord.send_occurrence_notification(occurrence, header_with_count, config_app_name())
:test ->
# Special case for tests - don't actually send notifications
Logger.debug("Test notification for error #{occurrence.error_id}")
{:ok, :test_notification_sent}
_ ->
Logger.error("Unknown notification type: #{type}")
{:error, :unknown_notification_type}
end
end)
end
# Get notification types from config, supporting both atom and list formats
defp get_notification_types do
# Get notification_type from config (can be an atom or a list)
notification_type = get_config(:notification_type, :email)
# Ensure we always return a list, even if a single atom was provided
List.wrap(notification_type) |> Enum.uniq()
end
# Determine the application atom to use for config lookup
defp app_atom do
# Try to detect the application name dynamically
case :application.get_application() do
{:ok, app} ->
app
# Fallback to default app name
_ ->
:error_tracker_notifier
end
end
# Determine which application to look for in the config
# This function is kept for backward compatibility but is no longer used
# in the main code path
defp config_app_name do
# Default application name to look for in config
config_app = Application.get_env(:error_tracker_notifier, :config_app_name)
config_app || app_atom()
end
# Format header text with count information when applicable
defp format_header_with_count(header_txt, count) do
occurrence_text =
case count do
0 ->
""
1 ->
""
_ ->
" (#{count} occurrences)"
end
"#{header_txt}#{occurrence_text}"
end
# Schedule the next cleanup operation
defp schedule_cleanup do
Process.send_after(self(), :cleanup, @cleanup_interval)
end
# Truncate reason to max 80 characters
defp truncate_reason(reason) when is_binary(reason) do
if String.length(reason) > 80 do
String.slice(reason, 0, 77) <> "..."
else
reason
end
end
defp truncate_reason(reason) do
# If reason isn't a string, convert it to string
reason |> inspect() |> truncate_reason()
end
# Get app runtime mode - either :normal or :test
defp runtime_mode do
# Check application config for runtime mode, defaulting to :normal
Application.get_env(:error_tracker_notifier, :runtime_mode, :normal)
end
# Check if we have valid configuration based on notification types
defp has_valid_config?() do
# Check runtime mode - :test mode always starts
case runtime_mode() do
:test ->
true
:normal ->
# Get notification type from the config
notification_type = Application.get_env(:error_tracker_notifier, :notification_type)
# Check for legacy config and show warning
app = app_atom()
if app != :error_tracker_notifier do
legacy_config = Application.get_env(app, :error_tracker_notifier)
if legacy_config && Keyword.has_key?(legacy_config, :notification_type) do
Logger.error("""
[ERROR] Found notification configuration under #{inspect(app)}:error_tracker_notifier instead of :error_tracker_notifier
The configuration format has changed. Please update your config files:
Old format (no longer supported):
config :#{app}, :error_tracker_notifier,
notification_type: :email,
# other settings...
New format (required):
config :error_tracker_notifier,
notification_type: :email,
# other settings...
""")
end
end
# If no notification type is set, return false
if is_nil(notification_type) do
false
else
# Convert to list if it's a single atom
notification_types = List.wrap(notification_type)
# Check each notification type for required config
Enum.any?(notification_types, fn type ->
case type do
:email ->
has_email_config_direct?()
:discord ->
has_discord_config_direct?()
:test ->
# Test type doesn't need additional config
true
_ ->
false
end
end)
end
end
end
# These functions are kept for backward compatibility during refactoring
# but are no longer used in the main code path
# Check if we have the minimum required email configuration (direct config)
defp has_email_config_direct?() do
from_email = Application.get_env(:error_tracker_notifier, :from_email)
to_email = Application.get_env(:error_tracker_notifier, :to_email)
mailer = Application.get_env(:error_tracker_notifier, :mailer)
# All three are required for email configuration
not is_nil(from_email) and not is_nil(to_email) and not is_nil(mailer)
end
# Check if we have the minimum required Discord configuration (direct config)
defp has_discord_config_direct?() do
webhook_url = Application.get_env(:error_tracker_notifier, :webhook_url)
# Webhook URL is required for Discord configuration
not is_nil(webhook_url)
end
end