Repository: honeybadger-io/honeybadger-elixir
Pull request #613 (2025-04-25 19:20:10)

Allow event sample rates
This allows for the sample rate to be set within the payload of the event, in a new metadata map:

```elixir
Honeybadger.event("user_created", %{
  user_id: user.id,
  _hb: %{sample_rate: 100}
})
```

There are a few changes that I made to enable this:

* The `Honeybadger.EventsSampler` is now started even if the global sample rate is 100, as I don't know if a user will then send a different sample rate with the event.
* The evaluation was reordered, so a filter could add the sample rate to the payload
* I am stripping the metadata map before sending the event


Patch:
diff --git a/README.md b/README.md
index e6e0b4a..cfe83db 100644
--- a/README.md
+++ b/README.md
@@ -359,8 +359,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.
+Events are sampled by hashing the `request_id` if available in the event payload,
+otherwise random sampling is used. This deterministic approach ensures that
+related events from the same request are consistently sampled together.
+
+The sample rate is applied to all events sent to Honeybadger Insights, including
+automatic instrumentation events. You can also set the sample rate per event
+by adding the `sample_rate` key to the event metadata map:
+
+```elixir
+Honeybadger.event("user_created", %{
+  user_id: user.id,
+  _hb: %{sample_rate: 100}
+})
+```
+
+The event sample rate can also be set within the `event_context/1` function.
+This can be handy if you want to set an overall sample rate for a process or
+ensure that specific instrumented events get sent:
+
+```elixir
+# Set a higher sampling rate for this entire process
+Honeybadger.event_context(%{_hb: %{sample_rate: 100}})
+
+# Now all events from this process, including automatic instrumentation,
+# will use the 100% sample rate
+Ecto.Repo.insert!(%MyApp.User{}) # This instrumented event will be sent
+```
+
+Remember that context is process-bound and applies to all events sent from the
+same process after the `event_context/1` call, until it's changed or the process
+terminates.
+
+When setting sample rates below the global setting, be aware that this affects
+how events with the same `request_id` are sampled. Since sampling is
+deterministic based on the `request_id` hash, all events sharing the same
+`request_id` will either all be sampled or all be skipped together. This
+ensures consistency across related events.
+
+With that in mind, it's recommended to default to the global sample rate and
+use per-event sampling for specific cases where you want to ensure events are
+sent regardless of the global setting, or you are setting the sample rate in
+the context where all events with the same `request_id` will also share the
+same sampling rate.
+
 
 ## Breadcrumbs
 
diff --git a/lib/honeybadger.ex b/lib/honeybadger.ex
index 88b0f18..ea4630a 100644
--- a/lib/honeybadger.ex
+++ b/lib/honeybadger.ex
@@ -363,9 +363,10 @@ defmodule Honeybadger do
   def event(event_data) do
     event_context()
     |> Map.merge(event_data)
-    |> maybe_sample()
-    |> put_ts()
+    |> Map.put_new(:ts, DateTime.utc_now(:millisecond) |> DateTime.to_iso8601())
     |> event_filter()
+    |> maybe_sample()
+    |> maybe_strip_metadata()
     |> send_event()
   end
 
@@ -379,8 +380,6 @@ 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)
@@ -389,18 +388,19 @@ defmodule Honeybadger do
     end
   end
 
-  defp put_ts(nil), do: nil
+  defp maybe_strip_metadata(nil), do: nil
+  defp maybe_strip_metadata(data), do: Map.drop(data, [:_hb])
 
-  defp put_ts(data) do
-    Map.put_new(data, :ts, DateTime.utc_now(:millisecond) |> DateTime.to_iso8601())
-  end
+  defp maybe_sample(nil), do: nil
 
   defp maybe_sample(data) do
-    if EventsSampler.sample?(data[:request_id]) do
-      data
-    else
-      nil
-    end
+    sampled? =
+      EventsSampler.sample?(
+        hash_value: data[:request_id],
+        sample_rate: get_in(data, [:_hb, :sample_rate])
+      )
+
+    if sampled?, do: data, else: nil
   end
 
   @doc """
diff --git a/lib/honeybadger/events_sampler.ex b/lib/honeybadger/events_sampler.ex
index a4de611..fde0634 100644
--- a/lib/honeybadger/events_sampler.ex
+++ b/lib/honeybadger/events_sampler.ex
@@ -10,12 +10,8 @@ defmodule Honeybadger.EventsSampler do
   @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
+    {name, opts} = Keyword.pop(opts, :name, __MODULE__)
+    GenServer.start_link(__MODULE__, opts, name: name)
   end
 
   @impl true
@@ -34,17 +30,45 @@ defmodule Honeybadger.EventsSampler do
     {:ok, state}
   end
 
-  def sample?(hash_value \\ nil, server \\ __MODULE__) do
-    if Honeybadger.get_env(:insights_sample_rate) == @fully_sampled_rate do
+  @doc """
+  Determines if an event should be sampled
+
+  ## Options
+    * `:sample_rate` - Override the default sample rate from the server state
+    * `:hash_value` - The hash value to use for sampling. If not provided, random sampling is used.
+    * `:server` - Specify the GenServer to use (default: `__MODULE__`)
+
+  ## Examples
+      iex> Sampler.sample?()
+      true
+
+      iex> Sampler.sample?(sample_rate: 1)
+      false
+
+      iex> Sampler.sample?(hash_value: "abc-123")
+      false
+  """
+  @spec sample?(Keyword.t()) :: boolean()
+  def sample?(opts \\ []) do
+    {server, opts} = Keyword.pop(opts, :server, __MODULE__)
+    # Remove nil values from options
+    opts = Keyword.filter(opts, fn {_k, v} -> not is_nil(v) end)
+
+    if sampling_at_full_rate?(opts) do
       true
     else
-      GenServer.call(server, {:sample?, hash_value})
+      GenServer.call(server, {:sample?, opts})
     end
   end
 
   @impl true
-  def handle_call({:sample?, hash_value}, _from, state) do
-    decision = do_sample?(hash_value, state.sample_rate)
+  def handle_call({:sample?, opts}, _from, state) do
+    decision =
+      do_sample?(
+        Keyword.get(opts, :hash_value),
+        Keyword.get(opts, :sample_rate, 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))
@@ -64,6 +88,11 @@ defmodule Honeybadger.EventsSampler do
     {:noreply, %{state | sample_count: 0, ignore_count: 0}}
   end
 
+  defp sampling_at_full_rate?(opts) when is_list(opts) do
+    sample_rate = Keyword.get(opts, :sample_rate, Honeybadger.get_env(:insights_sample_rate))
+    sample_rate == @fully_sampled_rate
+  end
+
   # Use random sampling when no hash value is provided
   defp do_sample?(nil, sample_rate) do
     :rand.uniform() * @fully_sampled_rate < sample_rate


Test patch:
diff --git a/test/honeybadger/events_sampler_test.exs b/test/honeybadger/events_sampler_test.exs
index bc0491c..17c0226 100644
--- a/test/honeybadger/events_sampler_test.exs
+++ b/test/honeybadger/events_sampler_test.exs
@@ -12,13 +12,15 @@ defmodule Honeybadger.EventsSamplerTest do
     EventsSampler.start_link(config ++ [name: name])
   end
 
-  test "start_link returns :ignore if rate is 100" do
-    assert :ignore == start_sampler()
+  test "returns true immediately if default sample rate is 100" do
+    assert EventsSampler.sample?(hash_value: :foo)
+    assert EventsSampler.sample?()
   end
 
-  test "returns true immediately if sample rate is 100" do
-    assert EventsSampler.sample?(:foo, nil)
-    assert EventsSampler.sample?(nil, nil)
+  test "returns true immediately if passed in sample rate is 100" do
+    with_config([insights_sample_rate: 0], fn ->
+      assert EventsSampler.sample?(sample_rate: 100)
+    end)
   end
 
   test "samples for hashed values" do
@@ -27,28 +29,48 @@ defmodule Honeybadger.EventsSamplerTest do
 
       log =
         capture_log(fn ->
-          EventsSampler.sample?("trace-1", sampler)
-          EventsSampler.sample?("trace-2", sampler)
-          Process.sleep(500)
+          EventsSampler.sample?(hash_value: "trace-1", server: sampler)
+          EventsSampler.sample?(hash_value: "trace-2", server: sampler)
+          Process.sleep(1000)
         end)
 
       assert log =~ ~r/\[Honeybadger\] Sampled \d events \(of 2 total events\)/
     end)
   end
 
-  test "samples for nil hash values" do
+  test "samples for un-hashed 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)
+          EventsSampler.sample?(server: sampler)
+          EventsSampler.sample?(server: sampler)
+          EventsSampler.sample?(server: sampler)
+          Process.sleep(1000)
         end)
 
       assert log =~ ~r/\[Honeybadger\] Sampled \d events \(of 3 total events\)/
     end)
   end
+
+  test "handles nil sample_rate" do
+    with_config([insights_sample_rate: 0], fn ->
+      {:ok, sampler} = start_sampler()
+      refute EventsSampler.sample?(sample_rate: nil, server: sampler)
+      refute EventsSampler.sample?(hash_value: "asdf", sample_rate: nil, server: sampler)
+    end)
+  end
+
+  test "respects custom sample rate in opts" do
+    with_config([insights_sample_rate: 50], fn ->
+      {:ok, sampler} = start_sampler()
+
+      # Force sampling to occur with 100% sample rate
+      assert EventsSampler.sample?(hash_value: "trace-1", sample_rate: 100, server: sampler)
+
+      # Force sampling to not occur with 0% sample rate
+      refute EventsSampler.sample?(hash_value: "trace-1", sample_rate: 0, server: sampler)
+    end)
+  end
 end
diff --git a/test/honeybadger/events_worker_test.exs b/test/honeybadger/events_worker_test.exs
index 2d75dc3..eeaea80 100644
--- a/test/honeybadger/events_worker_test.exs
+++ b/test/honeybadger/events_worker_test.exs
@@ -241,7 +241,7 @@ defmodule Honeybadger.EventsWorkerTest do
       change_behavior.(:ok)
 
       # Should send both batches after throttle period
-      assert_receive {:events_sent, ^first_batch}, 350
+      assert_receive {:events_sent, ^first_batch}, 500
       assert_receive {:events_sent, ^second_batch}, 50
 
       GenServer.stop(pid)
diff --git a/test/honeybadger_test.exs b/test/honeybadger_test.exs
index ac98056..933e86f 100644
--- a/test/honeybadger_test.exs
+++ b/test/honeybadger_test.exs
@@ -572,5 +572,42 @@ defmodule HoneybadgerTest do
       assert data["key"] == "value"
       assert data["user_id"] == 456
     end
+
+    test "samples with custom rate" do
+      restart_with_config(
+        exclude_envs: [],
+        events_worker_enabled: false,
+        insights_sample_rate: 100
+      )
+
+      Honeybadger.event(%{event_type: "test_event", key: "value", _hb: %{sample_rate: 0}})
+
+      refute_receive {:api_request, _data}
+    end
+
+    test "samples with custom overriding global lower rate" do
+      restart_with_config(
+        exclude_envs: [],
+        events_worker_enabled: false,
+        insights_sample_rate: 0
+      )
+
+      Honeybadger.event(%{event_type: "test_event", key: "value", _hb: %{sample_rate: 100}})
+
+      assert_receive {:api_request, _data}
+    end
+
+    test "samples with custom rate in context" do
+      restart_with_config(
+        exclude_envs: [],
+        events_worker_enabled: false,
+        insights_sample_rate: 100
+      )
+
+      Honeybadger.event_context(%{_hb: %{sample_rate: 0}})
+      Honeybadger.event(%{event_type: "test_event", key: "value"})
+
+      refute_receive {:api_request, _data}
+    end
   end
 end