Skip to content

Commit d01bcbd

Browse files
committed
WIP
1 parent 58ce111 commit d01bcbd

File tree

4 files changed

+407
-10
lines changed

4 files changed

+407
-10
lines changed

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())
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
@@ -24,27 +24,50 @@ if Code.ensure_loaded?(:otel_sampler) do
2424
@impl true
2525
def should_sample(
2626
ctx,
27-
_trace_id,
27+
trace_id,
2828
_links,
2929
span_name,
30-
_span_kind,
31-
_attributes,
30+
span_kind,
31+
attributes,
3232
config
3333
) do
3434
result =
3535
if span_name in config[:drop] do
3636
{:drop, [], []}
3737
else
38-
sample_rate = Sentry.Config.traces_sample_rate()
38+
traces_sampler = Sentry.Config.traces_sampler()
39+
traces_sample_rate = Sentry.Config.traces_sample_rate()
3940

4041
case get_trace_sampling_decision(ctx) do
4142
{:inherit, trace_sampled, tracestate} ->
42-
decision = if trace_sampled, do: :record_and_sample, else: :drop
43-
44-
{decision, [], tracestate}
43+
# If traces_sampler is configured, it can override parent decision
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+
# No traces_sampler, inherit parent decision
57+
decision = if trace_sampled, do: :record_and_sample, else: :drop
58+
{decision, [], tracestate}
59+
end
4560

4661
:no_trace ->
47-
make_sampling_decision(sample_rate)
62+
# No parent trace, use traces_sampler if available, otherwise traces_sample_rate
63+
if traces_sampler do
64+
sampling_context =
65+
build_sampling_context(nil, span_name, span_kind, attributes, trace_id)
66+
67+
make_sampler_decision(traces_sampler, sampling_context, [])
68+
else
69+
make_sampling_decision(traces_sample_rate)
70+
end
4871
end
4972
end
5073

@@ -121,6 +144,70 @@ if Code.ensure_loaded?(:otel_sampler) do
121144
end
122145
end
123146

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