Skip to content

Commit bfd2cc9

Browse files
committed
Add support for traces_sampler config option
1 parent 58ce111 commit bfd2cc9

File tree

6 files changed

+466
-11
lines changed

6 files changed

+466
-11
lines changed

.dialyzer_ignore.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
[
22
{"test/support/example_plug_application.ex"},
3-
{"test/support/test_helpers.ex"}
3+
{"test/support/test_helpers.ex"},
4+
{"lib/sentry/opentelemetry/sampler.ex", :pattern_match, 1}
45
]

lib/sentry/config.ex

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
defmodule Sentry.Config do
22
@moduledoc false
33

4+
@typedoc """
5+
A function that determines the sample rate for transaction events.
6+
7+
The function receives a sampling context map and should return a boolean or a float between `0.0` and `1.0`.
8+
"""
9+
@type traces_sampler_function :: (map() -> boolean() | float()) | {module(), atom()}
10+
411
integrations_schema = [
512
max_expected_check_in_time: [
613
type: :integer,
@@ -158,6 +165,34 @@ defmodule Sentry.Config do
158165
for guides on how to set it up.
159166
"""
160167
],
168+
traces_sampler: [
169+
type: {:custom, __MODULE__, :__validate_traces_sampler__, []},
170+
default: nil,
171+
type_doc: "`t:traces_sampler_function/0` or `nil`",
172+
doc: """
173+
A function that determines the sample rate for transaction events. This function
174+
receives a sampling context map and should return a boolean or a float between `0.0` and `1.0`.
175+
176+
The sampling context contains:
177+
- `:parent_sampled` - boolean indicating if the parent trace was sampled (nil if no parent)
178+
- `:transaction_context` - map with transaction information (name, op, etc.)
179+
180+
If both `:traces_sampler` and `:traces_sample_rate` are configured, `:traces_sampler` takes precedence.
181+
182+
Example:
183+
```elixir
184+
traces_sampler: fn sampling_context ->
185+
case sampling_context[:transaction_context][:op] do
186+
"http.server" -> 0.1 # Sample 10% of HTTP requests
187+
"db.query" -> 0.01 # Sample 1% of database queries
188+
_ -> false # Don't sample other operations
189+
end
190+
end
191+
```
192+
193+
This value is also used to determine if tracing is enabled: if it's not `nil`, tracing is enabled.
194+
"""
195+
],
161196
included_environments: [
162197
type: {:or, [{:in, [:all]}, {:list, {:or, [:atom, :string]}}]},
163198
deprecated: "Use :dsn to control whether to send events to Sentry.",
@@ -625,6 +660,9 @@ defmodule Sentry.Config do
625660
@spec traces_sample_rate() :: nil | float()
626661
def traces_sample_rate, do: fetch!(:traces_sample_rate)
627662

663+
@spec traces_sampler() :: traces_sampler_function() | nil
664+
def traces_sampler, do: get(:traces_sampler)
665+
628666
@spec hackney_opts() :: keyword()
629667
def hackney_opts, do: fetch!(:hackney_opts)
630668

@@ -663,7 +701,7 @@ defmodule Sentry.Config do
663701
def integrations, do: fetch!(:integrations)
664702

665703
@spec tracing?() :: boolean()
666-
def tracing?, do: not is_nil(fetch!(:traces_sample_rate))
704+
def tracing?, do: not is_nil(fetch!(:traces_sample_rate)) or not is_nil(get(:traces_sampler))
667705

668706
@spec put_config(atom(), term()) :: :ok
669707
def put_config(key, value) when is_atom(key) do
@@ -773,6 +811,26 @@ defmodule Sentry.Config do
773811
end
774812
end
775813

814+
def __validate_traces_sampler__(nil), do: {:ok, nil}
815+
816+
def __validate_traces_sampler__(fun) when is_function(fun, 1) do
817+
{:ok, fun}
818+
end
819+
820+
def __validate_traces_sampler__({module, function})
821+
when is_atom(module) and is_atom(function) do
822+
if function_exported?(module, function, 1) do
823+
{:ok, {module, function}}
824+
else
825+
{:error, "function #{module}.#{function}/1 is not exported"}
826+
end
827+
end
828+
829+
def __validate_traces_sampler__(other) do
830+
{:error,
831+
"expected :traces_sampler to be nil, a function with arity 1, or a {module, function} tuple, got: #{inspect(other)}"}
832+
end
833+
776834
def __validate_json_library__(nil) do
777835
{:error, "nil is not a valid value for the :json_library option"}
778836
end

lib/sentry/opentelemetry/sampler.ex

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ if Code.ensure_loaded?(:otel_sampler) do
44

55
alias OpenTelemetry.{Span, Tracer}
66
alias Sentry.ClientReport
7+
alias SamplingContext
78

89
@behaviour :otel_sampler
910

@@ -24,27 +25,47 @@ if Code.ensure_loaded?(:otel_sampler) do
2425
@impl true
2526
def should_sample(
2627
ctx,
27-
_trace_id,
28+
trace_id,
2829
_links,
2930
span_name,
30-
_span_kind,
31-
_attributes,
31+
span_kind,
32+
attributes,
3233
config
3334
) do
3435
result =
3536
if span_name in config[:drop] do
3637
{:drop, [], []}
3738
else
38-
sample_rate = Sentry.Config.traces_sample_rate()
39+
traces_sampler = Sentry.Config.traces_sampler()
40+
traces_sample_rate = Sentry.Config.traces_sample_rate()
3941

4042
case get_trace_sampling_decision(ctx) do
4143
{:inherit, trace_sampled, tracestate} ->
42-
decision = if trace_sampled, do: :record_and_sample, else: :drop
43-
44-
{decision, [], tracestate}
44+
if traces_sampler do
45+
sampling_context =
46+
build_sampling_context(
47+
trace_sampled,
48+
span_name,
49+
span_kind,
50+
attributes,
51+
trace_id
52+
)
53+
54+
make_sampler_decision(traces_sampler, sampling_context, tracestate)
55+
else
56+
decision = if trace_sampled, do: :record_and_sample, else: :drop
57+
{decision, [], tracestate}
58+
end
4559

4660
:no_trace ->
47-
make_sampling_decision(sample_rate)
61+
if traces_sampler do
62+
sampling_context =
63+
build_sampling_context(nil, span_name, span_kind, attributes, trace_id)
64+
65+
make_sampler_decision(traces_sampler, sampling_context, [])
66+
else
67+
make_sampling_decision(traces_sample_rate)
68+
end
4869
end
4970
end
5071

@@ -121,6 +142,72 @@ if Code.ensure_loaded?(:otel_sampler) do
121142
end
122143
end
123144

145+
defp build_sampling_context(parent_sampled, span_name, _span_kind, attributes, trace_id) do
146+
transaction_context = %{
147+
name: span_name,
148+
op: span_name,
149+
trace_id: trace_id,
150+
attributes: attributes
151+
}
152+
153+
sampling_context = %SamplingContext{
154+
transaction_context: transaction_context,
155+
parent_sampled: parent_sampled
156+
}
157+
158+
if attributes && map_size(attributes) > 0 do
159+
Map.merge(sampling_context, attributes)
160+
else
161+
sampling_context
162+
end
163+
end
164+
165+
defp make_sampler_decision(traces_sampler, sampling_context, _existing_tracestate) do
166+
try do
167+
result = call_traces_sampler(traces_sampler, sampling_context)
168+
sample_rate = normalize_sampler_result(result)
169+
170+
cond do
171+
sample_rate == 0.0 ->
172+
tracestate = build_tracestate(0.0, 1.0, false)
173+
{:drop, [], tracestate}
174+
175+
sample_rate == 1.0 ->
176+
tracestate = build_tracestate(1.0, 0.0, true)
177+
{:record_and_sample, [], tracestate}
178+
179+
is_float(sample_rate) and sample_rate > 0.0 and sample_rate < 1.0 ->
180+
random_value = :rand.uniform()
181+
sampled = random_value < sample_rate
182+
tracestate = build_tracestate(sample_rate, random_value, sampled)
183+
decision = if sampled, do: :record_and_sample, else: :drop
184+
{decision, [], tracestate}
185+
186+
true ->
187+
tracestate = build_tracestate(0.0, 1.0, false)
188+
{:drop, [], tracestate}
189+
end
190+
rescue
191+
error ->
192+
require Logger
193+
Logger.warning("traces_sampler function failed: #{inspect(error)}")
194+
tracestate = build_tracestate(0.0, 1.0, false)
195+
{:drop, [], tracestate}
196+
end
197+
end
198+
199+
defp call_traces_sampler(fun, sampling_context) when is_function(fun, 1) do
200+
fun.(sampling_context)
201+
end
202+
203+
defp call_traces_sampler({module, function}, sampling_context) do
204+
apply(module, function, [sampling_context])
205+
end
206+
207+
defp normalize_sampler_result(true), do: 1.0
208+
defp normalize_sampler_result(false), do: 0.0
209+
defp normalize_sampler_result(rate) when is_float(rate), do: rate
210+
124211
defp record_discarded_transaction() do
125212
ClientReport.Sender.record_discarded_events(:sample_rate, "transaction")
126213
end

lib/sentry/sampling_context.ex

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
defmodule SamplingContext do
2+
@moduledoc """
3+
The struct for the **sampling_context** that is passed to `traces_sampler`.
4+
5+
This is set up via `Sentry.OpenTelemetry.Sampler`.
6+
7+
See also <https://develop.sentry.dev/sdk/telemetry/traces/#sampling-context>.
8+
"""
9+
10+
@moduledoc since: "11.0.0"
11+
12+
@typedoc """
13+
The sampling context struct that contains information needed for sampling decisions.
14+
15+
This matches the structure used in the Python SDK's create_sampling_context function.
16+
"""
17+
@type t :: %__MODULE__{
18+
transaction_context: %{
19+
name: String.t() | nil,
20+
op: String.t(),
21+
trace_id: String.t(),
22+
attributes: map()
23+
},
24+
parent_sampled: boolean() | nil
25+
}
26+
27+
@enforce_keys [:transaction_context, :parent_sampled]
28+
defstruct [:transaction_context, :parent_sampled]
29+
30+
@behaviour Access
31+
32+
@impl Access
33+
def fetch(struct, key) do
34+
case Map.fetch(struct, key) do
35+
{:ok, value} -> {:ok, value}
36+
:error -> :error
37+
end
38+
end
39+
40+
@impl Access
41+
def get_and_update(struct, key, function) do
42+
current_value = Map.get(struct, key)
43+
44+
case function.(current_value) do
45+
{get_value, update_value} ->
46+
{get_value, Map.put(struct, key, update_value)}
47+
48+
:pop ->
49+
{current_value, Map.delete(struct, key)}
50+
end
51+
end
52+
53+
@impl Access
54+
def pop(struct, key) do
55+
{Map.get(struct, key), Map.delete(struct, key)}
56+
end
57+
end
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
defmodule Sentry.ConfigTracesSamplerTest do
2+
use ExUnit.Case, async: true
3+
4+
describe "traces_sampler configuration validation" do
5+
defmodule TestSampler do
6+
def sample(_context), do: 0.5
7+
end
8+
9+
test "accepts nil" do
10+
assert {:ok, nil} = Sentry.Config.__validate_traces_sampler__(nil)
11+
end
12+
13+
test "accepts function with arity 1" do
14+
fun = fn _context -> 0.5 end
15+
assert {:ok, ^fun} = Sentry.Config.__validate_traces_sampler__(fun)
16+
end
17+
18+
test "accepts MFA tuple with exported function" do
19+
assert {:ok, {TestSampler, :sample}} =
20+
Sentry.Config.__validate_traces_sampler__({TestSampler, :sample})
21+
end
22+
23+
test "rejects MFA tuple with non-exported function" do
24+
assert {:error, error_msg} =
25+
Sentry.Config.__validate_traces_sampler__({TestSampler, :non_existent})
26+
27+
assert error_msg =~ "function"
28+
assert error_msg =~ "is not exported"
29+
end
30+
31+
test "rejects function with wrong arity" do
32+
fun = fn -> 0.5 end
33+
assert {:error, _} = Sentry.Config.__validate_traces_sampler__(fun)
34+
end
35+
36+
test "rejects invalid types" do
37+
assert {:error, _} = Sentry.Config.__validate_traces_sampler__("invalid")
38+
assert {:error, _} = Sentry.Config.__validate_traces_sampler__(123)
39+
assert {:error, _} = Sentry.Config.__validate_traces_sampler__([])
40+
end
41+
end
42+
43+
describe "tracing? function" do
44+
setup do
45+
original_rate = Sentry.Config.traces_sample_rate()
46+
original_sampler = Sentry.Config.traces_sampler()
47+
48+
on_exit(fn ->
49+
Sentry.Config.put_config(:traces_sample_rate, original_rate)
50+
Sentry.Config.put_config(:traces_sampler, original_sampler)
51+
end)
52+
53+
:ok
54+
end
55+
56+
test "returns true when traces_sample_rate is set" do
57+
Sentry.Config.put_config(:traces_sample_rate, 0.5)
58+
Sentry.Config.put_config(:traces_sampler, nil)
59+
60+
assert Sentry.Config.tracing?()
61+
end
62+
63+
test "returns true when traces_sampler is set" do
64+
Sentry.Config.put_config(:traces_sample_rate, nil)
65+
Sentry.Config.put_config(:traces_sampler, fn _ -> 0.5 end)
66+
67+
assert Sentry.Config.tracing?()
68+
end
69+
70+
test "returns true when both are set" do
71+
Sentry.Config.put_config(:traces_sample_rate, 0.5)
72+
Sentry.Config.put_config(:traces_sampler, fn _ -> 0.5 end)
73+
74+
assert Sentry.Config.tracing?()
75+
end
76+
77+
test "returns false when neither is set" do
78+
Sentry.Config.put_config(:traces_sample_rate, nil)
79+
Sentry.Config.put_config(:traces_sampler, nil)
80+
81+
refute Sentry.Config.tracing?()
82+
end
83+
end
84+
end

0 commit comments

Comments
 (0)