Packages

Reusable structured audit logging, DB change tracking, and crash reporting for Elixir/Phoenix apps

Current section

Files

Jump to
audit_trail lib audit_trail deduplicator.ex
Raw

lib/audit_trail/deduplicator.ex

defmodule AuditTrail.Deduplicator do
use GenServer
# require Logger
@table :audit_trail_dedup
def start_link(_opts), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
def handle_crash(level, crash_info) do
GenServer.cast(__MODULE__, {:crash, level, crash_info})
end
@impl true
def init(_) do
:ets.new(@table, [:named_table, :public, :set])
schedule_digest()
{:ok, %{}}
end
@impl true
def handle_cast({:crash, level, crash_info}, state) do
hash = hash_crash(crash_info)
now = System.monotonic_time(:millisecond)
window = AuditTrail.Config.dedup_window_ms()
case :ets.lookup(@table, hash) do
[] ->
:ets.insert(@table, {hash, 1, now, now, crash_info})
send_immediately(level, crash_info, 1)
[{^hash, _count, first_ts, _last_ts, _info}] when now - first_ts > window ->
# Window expired — reset and treat as new
:ets.insert(@table, {hash, 1, now, now, crash_info})
send_immediately(level, crash_info, 1)
[{^hash, count, first_ts, _last_ts, info}] ->
:ets.insert(@table, {hash, count + 1, first_ts, now, info})
end
prune_if_needed()
{:noreply, state}
end
@impl true
def handle_info(:digest, state) do
window = AuditTrail.Config.dedup_window_ms()
now = System.monotonic_time(:millisecond)
:ets.tab2list(@table)
|> Enum.each(fn {hash, count, first_ts, last_ts, crash_info} ->
if count > 1 and now - first_ts <= window do
send_digest(crash_info, count, first_ts, last_ts)
end
if now - first_ts > window do
:ets.delete(@table, hash)
end
end)
schedule_digest()
{:noreply, state}
end
defp send_immediately(level, crash_info, count) do
recipients = route_crash(crash_info)
AuditTrail.Notifier.send_crash(crash_info, recipients, level, count)
end
defp send_digest(crash_info, count, first_ts, last_ts) do
recipients = route_crash(crash_info)
AuditTrail.Notifier.send_digest(crash_info, recipients, count, first_ts, last_ts)
end
defp route_crash(crash_info) do
module = Map.get(crash_info, :module, "")
exception = Map.get(crash_info, :exception)
source = Map.get(crash_info, :source)
routing = AuditTrail.Config.crash_routing()
matched =
Enum.flat_map(routing, fn
%{match: {:module, pattern}, to: to} when is_struct(pattern, Regex) ->
if Regex.match?(pattern, to_string(module)), do: List.wrap(to), else: []
%{match: {:module, mod_atom}, to: to} when is_atom(mod_atom) ->
if to_string(module) =~ to_string(mod_atom), do: List.wrap(to), else: []
%{match: {:exception, exc_mod}, to: to} ->
if exception == exc_mod, do: List.wrap(to), else: []
%{match: {:source, src}, to: to} ->
if source == src, do: List.wrap(to), else: []
%{match: :default, to: to} ->
List.wrap(to)
_ ->
[]
end)
case matched do
[] -> fallback_recipients(routing)
list -> Enum.uniq(list)
end
end
defp fallback_recipients(routing) do
routing
|> Enum.find_value([], fn
%{match: :default, to: to} -> List.wrap(to)
_ -> nil
end)
end
defp hash_crash(crash_info) do
key =
"#{Map.get(crash_info, :exception)}|#{Map.get(crash_info, :message)}|#{Enum.take(Map.get(crash_info, :stacktrace_lines, []), 3)}"
:crypto.hash(:md5, key) |> Base.encode16()
end
defp prune_if_needed do
max = AuditTrail.Config.dedup_max_entries()
if :ets.info(@table, :size) > max do
oldest = :ets.tab2list(@table) |> Enum.min_by(fn {_, _, first_ts, _, _} -> first_ts end)
:ets.delete(@table, elem(oldest, 0))
end
end
defp schedule_digest do
Process.send_after(self(), :digest, AuditTrail.Config.dedup_window_ms())
end
end