Repository: honeybadger-io/honeybadger-elixir
Pull request #608 (2025-04-22 22:21:04)

Add Events sample rate
This adds a `Honeybadger.EventsSampler` module to supply sampling logic, keep track of sampled counts for logging, and is a good start for adaptive sampling ;)


Patch:
diff --git a/README.md b/README.md
index 8252248..e6e0b4a 100644
--- a/README.md
+++ b/README.md
@@ -343,6 +343,25 @@ When explicit passing is needed:
 - Supervised processes
 - Processes started through other OTP abstractions
 
+### Event Sampling
+
+You can enable event sampling to reduce the number of events sent to
+Honeybadger. This is especially useful if you are hitting your daily event
+quota limit:
+
+```elixir
+config :honeybadger,
+  # Sample 50% of events
+  insights_sample_rate: 50
+```
+
+The `insights_sample_rate` option accepts a whole percentage value between 0
+and 100, where 0 means no events will be sent and 100 means all events will be
+sent. The default is no sampling (100%).
+
+Events are sampled by hashing the request_id if available in the event payload,
+otherwise random sampling is used.
+
 ## Breadcrumbs
 
 Breadcrumbs allow you to record events along a processes execution path. If
diff --git a/lib/honeybadger.ex b/lib/honeybadger.ex
index dd0935b..88b0f18 100644
--- a/lib/honeybadger.ex
+++ b/lib/honeybadger.ex
@@ -171,7 +171,7 @@ defmodule Honeybadger do
 
   require Logger
 
-  alias Honeybadger.{Client, Notice, EventsWorker}
+  alias Honeybadger.{Client, Notice, EventsWorker, EventsSampler}
   alias Honeybadger.Breadcrumbs.{Collector, Breadcrumb}
 
   @type notify_options ::
@@ -221,7 +221,7 @@ defmodule Honeybadger do
       |> Enum.each(& &1.attach())
     end
 
-    children = [{Client, [config]}, EventsWorker]
+    children = [{Client, [config]}, EventsWorker, EventsSampler]
 
     Supervisor.start_link(children, strategy: :one_for_one)
   end
@@ -363,7 +363,8 @@ defmodule Honeybadger do
   def event(event_data) do
     event_context()
     |> Map.merge(event_data)
-    |> Map.put_new(:ts, DateTime.utc_now(:millisecond) |> DateTime.to_iso8601())
+    |> maybe_sample()
+    |> put_ts()
     |> event_filter()
     |> send_event()
   end
@@ -378,6 +379,8 @@ defmodule Honeybadger do
     end
   end
 
+  defp event_filter(nil), do: nil
+
   defp event_filter(map) do
     if get_env(:event_filter) do
       get_env(:event_filter).filter_event(map)
@@ -386,6 +389,20 @@ defmodule Honeybadger do
     end
   end
 
+  defp put_ts(nil), do: nil
+
+  defp put_ts(data) do
+    Map.put_new(data, :ts, DateTime.utc_now(:millisecond) |> DateTime.to_iso8601())
+  end
+
+  defp maybe_sample(data) do
+    if EventsSampler.sample?(data[:request_id]) do
+      data
+    else
+      nil
+    end
+  end
+
   @doc """
   Stores a breadcrumb item.
 
diff --git a/lib/honeybadger/events_sampler.ex b/lib/honeybadger/events_sampler.ex
new file mode 100644
index 0000000..a4de611
--- /dev/null
+++ b/lib/honeybadger/events_sampler.ex
@@ -0,0 +1,80 @@
+defmodule Honeybadger.EventsSampler do
+  @moduledoc false
+
+  use GenServer
+  require Logger
+
+  # Every 5 minutes, we log the number of sampled events
+  @sampled_log_interval 5 * 60 * 1000
+  @hash_max 1_000_000
+  @fully_sampled_rate 100
+
+  def start_link(opts \\ []) do
+    if Honeybadger.get_env(:insights_sample_rate) == @fully_sampled_rate do
+      :ignore
+    else
+      {name, opts} = Keyword.pop(opts, :name, __MODULE__)
+      GenServer.start_link(__MODULE__, opts, name: name)
+    end
+  end
+
+  @impl true
+  def init(opts) do
+    state =
+      %{
+        sample_rate: Honeybadger.get_env(:insights_sample_rate),
+        sampled_log_interval: @sampled_log_interval,
+        sample_count: 0,
+        ignore_count: 0
+      }
+      |> Map.merge(Map.new(opts))
+
+    schedule_report(state.sampled_log_interval)
+
+    {:ok, state}
+  end
+
+  def sample?(hash_value \\ nil, server \\ __MODULE__) do
+    if Honeybadger.get_env(:insights_sample_rate) == @fully_sampled_rate do
+      true
+    else
+      GenServer.call(server, {:sample?, hash_value})
+    end
+  end
+
+  @impl true
+  def handle_call({:sample?, hash_value}, _from, state) do
+    decision = do_sample?(hash_value, state.sample_rate)
+    # Increment the count of sampled or ignored events
+    count_key = if decision, do: :sample_count, else: :ignore_count
+    state = update_in(state, [count_key], &(&1 + 1))
+    {:reply, decision, state}
+  end
+
+  @impl true
+  def handle_info(:report, %{sample_count: sample_count, ignore_count: ignore_count} = state) do
+    if sample_count > 0 do
+      Logger.debug(
+        "[Honeybadger] Sampled #{sample_count} events (of #{sample_count + ignore_count} total events)"
+      )
+    end
+
+    schedule_report(state.sampled_log_interval)
+
+    {:noreply, %{state | sample_count: 0, ignore_count: 0}}
+  end
+
+  # Use random sampling when no hash value is provided
+  defp do_sample?(nil, sample_rate) do
+    :rand.uniform() * @fully_sampled_rate < sample_rate
+  end
+
+  # Use hash sampling when a hash value is provided
+  defp do_sample?(hash_value, sample_rate) when is_binary(hash_value) or is_atom(hash_value) do
+    :erlang.phash2(hash_value, @hash_max) / @hash_max * @fully_sampled_rate < sample_rate
+  end
+
+  defp schedule_report(interval) do
+    Process.send_after(self(), :report, interval)
+  end
+end
diff --git a/mix.exs b/mix.exs
index 0e402ce..a85000a 100644
--- a/mix.exs
+++ b/mix.exs
@@ -84,6 +84,7 @@ defmodule Honeybadger.Mixfile do
 
         # Insights
         insights_enabled: false,
+        insights_sample_rate: 100.0,
         insights_config: %{},
 
         # Events


Test patch:
diff --git a/test/honeybadger/events_sampler_test.exs b/test/honeybadger/events_sampler_test.exs
new file mode 100644
index 0000000..bc0491c
--- /dev/null
+++ b/test/honeybadger/events_sampler_test.exs
@@ -0,0 +1,54 @@
+defmodule Honeybadger.EventsSamplerTest do
+  use Honeybadger.Case, async: true
+  require Logger
+
+  alias Honeybadger.EventsSampler
+
+  defp start_sampler(config \\ []) do
+    name =
+      "test_events_sampler_#{System.unique_integer([:positive])}"
+      |> String.to_atom()
+
+    EventsSampler.start_link(config ++ [name: name])
+  end
+
+  test "start_link returns :ignore if rate is 100" do
+    assert :ignore == start_sampler()
+  end
+
+  test "returns true immediately if sample rate is 100" do
+    assert EventsSampler.sample?(:foo, nil)
+    assert EventsSampler.sample?(nil, nil)
+  end
+
+  test "samples for hashed values" do
+    with_config([insights_sample_rate: 50], fn ->
+      {:ok, sampler} = start_sampler(sampled_log_interval: 100)
+
+      log =
+        capture_log(fn ->
+          EventsSampler.sample?("trace-1", sampler)
+          EventsSampler.sample?("trace-2", sampler)
+          Process.sleep(500)
+        end)
+
+      assert log =~ ~r/\[Honeybadger\] Sampled \d events \(of 2 total events\)/
+    end)
+  end
+
+  test "samples for nil hash values" do
+    with_config([insights_sample_rate: 50], fn ->
+      {:ok, sampler} = start_sampler(sampled_log_interval: 100)
+
+      log =
+        capture_log(fn ->
+          EventsSampler.sample?(nil, sampler)
+          EventsSampler.sample?(nil, sampler)
+          EventsSampler.sample?(nil, sampler)
+          Process.sleep(500)
+        end)
+
+      assert log =~ ~r/\[Honeybadger\] Sampled \d events \(of 3 total events\)/
+    end)
+  end
+end