Skip to content

Commit aaf359e

Browse files
authored
Allow event sample rates (#613)
* Allow for custom sample rate per event * Update README * Wording update * Nils were passing through I misunderstood `Keyword.get`, as it will let nil values pass through, when I wanted it to fallback to the default. To fix this I am filtering out the nil opts values before calling with them. * Bump some wait times, for flakes
1 parent b2bc396 commit aaf359e

File tree

6 files changed

+170
-40
lines changed

6 files changed

+170
-40
lines changed

README.md

+44-2
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,50 @@ The `insights_sample_rate` option accepts a whole percentage value between 0
359359
and 100, where 0 means no events will be sent and 100 means all events will be
360360
sent. The default is no sampling (100%).
361361

362-
Events are sampled by hashing the request_id if available in the event payload,
363-
otherwise random sampling is used.
362+
Events are sampled by hashing the `request_id` if available in the event payload,
363+
otherwise random sampling is used. This deterministic approach ensures that
364+
related events from the same request are consistently sampled together.
365+
366+
The sample rate is applied to all events sent to Honeybadger Insights, including
367+
automatic instrumentation events. You can also set the sample rate per event
368+
by adding the `sample_rate` key to the event metadata map:
369+
370+
```elixir
371+
Honeybadger.event("user_created", %{
372+
user_id: user.id,
373+
_hb: %{sample_rate: 100}
374+
})
375+
```
376+
377+
The event sample rate can also be set within the `event_context/1` function.
378+
This can be handy if you want to set an overall sample rate for a process or
379+
ensure that specific instrumented events get sent:
380+
381+
```elixir
382+
# Set a higher sampling rate for this entire process
383+
Honeybadger.event_context(%{_hb: %{sample_rate: 100}})
384+
385+
# Now all events from this process, including automatic instrumentation,
386+
# will use the 100% sample rate
387+
Ecto.Repo.insert!(%MyApp.User{}) # This instrumented event will be sent
388+
```
389+
390+
Remember that context is process-bound and applies to all events sent from the
391+
same process after the `event_context/1` call, until it's changed or the process
392+
terminates.
393+
394+
When setting sample rates below the global setting, be aware that this affects
395+
how events with the same `request_id` are sampled. Since sampling is
396+
deterministic based on the `request_id` hash, all events sharing the same
397+
`request_id` will either all be sampled or all be skipped together. This
398+
ensures consistency across related events.
399+
400+
With that in mind, it's recommended to default to the global sample rate and
401+
use per-event sampling for specific cases where you want to ensure events are
402+
sent regardless of the global setting, or you are setting the sample rate in
403+
the context where all events with the same `request_id` will also share the
404+
same sampling rate.
405+
364406

365407
## Breadcrumbs
366408

lib/honeybadger.ex

+13-13
Original file line numberDiff line numberDiff line change
@@ -363,9 +363,10 @@ defmodule Honeybadger do
363363
def event(event_data) do
364364
event_context()
365365
|> Map.merge(event_data)
366-
|> maybe_sample()
367-
|> put_ts()
366+
|> Map.put_new(:ts, DateTime.utc_now(:millisecond) |> DateTime.to_iso8601())
368367
|> event_filter()
368+
|> maybe_sample()
369+
|> maybe_strip_metadata()
369370
|> send_event()
370371
end
371372

@@ -379,8 +380,6 @@ defmodule Honeybadger do
379380
end
380381
end
381382

382-
defp event_filter(nil), do: nil
383-
384383
defp event_filter(map) do
385384
if get_env(:event_filter) do
386385
get_env(:event_filter).filter_event(map)
@@ -389,18 +388,19 @@ defmodule Honeybadger do
389388
end
390389
end
391390

392-
defp put_ts(nil), do: nil
391+
defp maybe_strip_metadata(nil), do: nil
392+
defp maybe_strip_metadata(data), do: Map.drop(data, [:_hb])
393393

394-
defp put_ts(data) do
395-
Map.put_new(data, :ts, DateTime.utc_now(:millisecond) |> DateTime.to_iso8601())
396-
end
394+
defp maybe_sample(nil), do: nil
397395

398396
defp maybe_sample(data) do
399-
if EventsSampler.sample?(data[:request_id]) do
400-
data
401-
else
402-
nil
403-
end
397+
sampled? =
398+
EventsSampler.sample?(
399+
hash_value: data[:request_id],
400+
sample_rate: get_in(data, [:_hb, :sample_rate])
401+
)
402+
403+
if sampled?, do: data, else: nil
404404
end
405405

406406
@doc """

lib/honeybadger/events_sampler.ex

+40-11
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,8 @@ defmodule Honeybadger.EventsSampler do
1010
@fully_sampled_rate 100
1111

1212
def start_link(opts \\ []) do
13-
if Honeybadger.get_env(:insights_sample_rate) == @fully_sampled_rate do
14-
:ignore
15-
else
16-
{name, opts} = Keyword.pop(opts, :name, __MODULE__)
17-
GenServer.start_link(__MODULE__, opts, name: name)
18-
end
13+
{name, opts} = Keyword.pop(opts, :name, __MODULE__)
14+
GenServer.start_link(__MODULE__, opts, name: name)
1915
end
2016

2117
@impl true
@@ -34,17 +30,45 @@ defmodule Honeybadger.EventsSampler do
3430
{:ok, state}
3531
end
3632

37-
def sample?(hash_value \\ nil, server \\ __MODULE__) do
38-
if Honeybadger.get_env(:insights_sample_rate) == @fully_sampled_rate do
33+
@doc """
34+
Determines if an event should be sampled
35+
36+
## Options
37+
* `:sample_rate` - Override the default sample rate from the server state
38+
* `:hash_value` - The hash value to use for sampling. If not provided, random sampling is used.
39+
* `:server` - Specify the GenServer to use (default: `__MODULE__`)
40+
41+
## Examples
42+
iex> Sampler.sample?()
43+
true
44+
45+
iex> Sampler.sample?(sample_rate: 1)
46+
false
47+
48+
iex> Sampler.sample?(hash_value: "abc-123")
49+
false
50+
"""
51+
@spec sample?(Keyword.t()) :: boolean()
52+
def sample?(opts \\ []) do
53+
{server, opts} = Keyword.pop(opts, :server, __MODULE__)
54+
# Remove nil values from options
55+
opts = Keyword.filter(opts, fn {_k, v} -> not is_nil(v) end)
56+
57+
if sampling_at_full_rate?(opts) do
3958
true
4059
else
41-
GenServer.call(server, {:sample?, hash_value})
60+
GenServer.call(server, {:sample?, opts})
4261
end
4362
end
4463

4564
@impl true
46-
def handle_call({:sample?, hash_value}, _from, state) do
47-
decision = do_sample?(hash_value, state.sample_rate)
65+
def handle_call({:sample?, opts}, _from, state) do
66+
decision =
67+
do_sample?(
68+
Keyword.get(opts, :hash_value),
69+
Keyword.get(opts, :sample_rate, state.sample_rate)
70+
)
71+
4872
# Increment the count of sampled or ignored events
4973
count_key = if decision, do: :sample_count, else: :ignore_count
5074
state = update_in(state, [count_key], &(&1 + 1))
@@ -64,6 +88,11 @@ defmodule Honeybadger.EventsSampler do
6488
{:noreply, %{state | sample_count: 0, ignore_count: 0}}
6589
end
6690

91+
defp sampling_at_full_rate?(opts) when is_list(opts) do
92+
sample_rate = Keyword.get(opts, :sample_rate, Honeybadger.get_env(:insights_sample_rate))
93+
sample_rate == @fully_sampled_rate
94+
end
95+
6796
# Use random sampling when no hash value is provided
6897
defp do_sample?(nil, sample_rate) do
6998
:rand.uniform() * @fully_sampled_rate < sample_rate

test/honeybadger/events_sampler_test.exs

+35-13
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ defmodule Honeybadger.EventsSamplerTest do
1212
EventsSampler.start_link(config ++ [name: name])
1313
end
1414

15-
test "start_link returns :ignore if rate is 100" do
16-
assert :ignore == start_sampler()
15+
test "returns true immediately if default sample rate is 100" do
16+
assert EventsSampler.sample?(hash_value: :foo)
17+
assert EventsSampler.sample?()
1718
end
1819

19-
test "returns true immediately if sample rate is 100" do
20-
assert EventsSampler.sample?(:foo, nil)
21-
assert EventsSampler.sample?(nil, nil)
20+
test "returns true immediately if passed in sample rate is 100" do
21+
with_config([insights_sample_rate: 0], fn ->
22+
assert EventsSampler.sample?(sample_rate: 100)
23+
end)
2224
end
2325

2426
test "samples for hashed values" do
@@ -27,28 +29,48 @@ defmodule Honeybadger.EventsSamplerTest do
2729

2830
log =
2931
capture_log(fn ->
30-
EventsSampler.sample?("trace-1", sampler)
31-
EventsSampler.sample?("trace-2", sampler)
32-
Process.sleep(500)
32+
EventsSampler.sample?(hash_value: "trace-1", server: sampler)
33+
EventsSampler.sample?(hash_value: "trace-2", server: sampler)
34+
Process.sleep(1000)
3335
end)
3436

3537
assert log =~ ~r/\[Honeybadger\] Sampled \d events \(of 2 total events\)/
3638
end)
3739
end
3840

39-
test "samples for nil hash values" do
41+
test "samples for un-hashed values" do
4042
with_config([insights_sample_rate: 50], fn ->
4143
{:ok, sampler} = start_sampler(sampled_log_interval: 100)
4244

4345
log =
4446
capture_log(fn ->
45-
EventsSampler.sample?(nil, sampler)
46-
EventsSampler.sample?(nil, sampler)
47-
EventsSampler.sample?(nil, sampler)
48-
Process.sleep(500)
47+
EventsSampler.sample?(server: sampler)
48+
EventsSampler.sample?(server: sampler)
49+
EventsSampler.sample?(server: sampler)
50+
Process.sleep(1000)
4951
end)
5052

5153
assert log =~ ~r/\[Honeybadger\] Sampled \d events \(of 3 total events\)/
5254
end)
5355
end
56+
57+
test "handles nil sample_rate" do
58+
with_config([insights_sample_rate: 0], fn ->
59+
{:ok, sampler} = start_sampler()
60+
refute EventsSampler.sample?(sample_rate: nil, server: sampler)
61+
refute EventsSampler.sample?(hash_value: "asdf", sample_rate: nil, server: sampler)
62+
end)
63+
end
64+
65+
test "respects custom sample rate in opts" do
66+
with_config([insights_sample_rate: 50], fn ->
67+
{:ok, sampler} = start_sampler()
68+
69+
# Force sampling to occur with 100% sample rate
70+
assert EventsSampler.sample?(hash_value: "trace-1", sample_rate: 100, server: sampler)
71+
72+
# Force sampling to not occur with 0% sample rate
73+
refute EventsSampler.sample?(hash_value: "trace-1", sample_rate: 0, server: sampler)
74+
end)
75+
end
5476
end

test/honeybadger/events_worker_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ defmodule Honeybadger.EventsWorkerTest do
241241
change_behavior.(:ok)
242242

243243
# Should send both batches after throttle period
244-
assert_receive {:events_sent, ^first_batch}, 350
244+
assert_receive {:events_sent, ^first_batch}, 500
245245
assert_receive {:events_sent, ^second_batch}, 50
246246

247247
GenServer.stop(pid)

test/honeybadger_test.exs

+37
Original file line numberDiff line numberDiff line change
@@ -572,5 +572,42 @@ defmodule HoneybadgerTest do
572572
assert data["key"] == "value"
573573
assert data["user_id"] == 456
574574
end
575+
576+
test "samples with custom rate" do
577+
restart_with_config(
578+
exclude_envs: [],
579+
events_worker_enabled: false,
580+
insights_sample_rate: 100
581+
)
582+
583+
Honeybadger.event(%{event_type: "test_event", key: "value", _hb: %{sample_rate: 0}})
584+
585+
refute_receive {:api_request, _data}
586+
end
587+
588+
test "samples with custom overriding global lower rate" do
589+
restart_with_config(
590+
exclude_envs: [],
591+
events_worker_enabled: false,
592+
insights_sample_rate: 0
593+
)
594+
595+
Honeybadger.event(%{event_type: "test_event", key: "value", _hb: %{sample_rate: 100}})
596+
597+
assert_receive {:api_request, _data}
598+
end
599+
600+
test "samples with custom rate in context" do
601+
restart_with_config(
602+
exclude_envs: [],
603+
events_worker_enabled: false,
604+
insights_sample_rate: 100
605+
)
606+
607+
Honeybadger.event_context(%{_hb: %{sample_rate: 0}})
608+
Honeybadger.event(%{event_type: "test_event", key: "value"})
609+
610+
refute_receive {:api_request, _data}
611+
end
575612
end
576613
end

0 commit comments

Comments
 (0)