Skip to content

Commit 499140c

Browse files
authored
Support for traces_sampler option (#910)
* Add support for `traces_sampler` config option * Update docs * Remove unnecessary attribute merging * Call traces_sampler only for root spans * Reuse sampling decision logic * Add test for handling invalid traces_sampler return values * Add tests for SamplingContext access functions
1 parent 58ce111 commit 499140c

File tree

7 files changed

+681
-9
lines changed

7 files changed

+681
-9
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 struct 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 span 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: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ if Code.ensure_loaded?(:otel_sampler) do
44

55
alias OpenTelemetry.{Span, Tracer}
66
alias Sentry.ClientReport
7+
alias SamplingContext
8+
9+
require Logger
710

811
@behaviour :otel_sampler
912

@@ -24,27 +27,34 @@ if Code.ensure_loaded?(:otel_sampler) do
2427
@impl true
2528
def should_sample(
2629
ctx,
27-
_trace_id,
30+
trace_id,
2831
_links,
2932
span_name,
30-
_span_kind,
31-
_attributes,
33+
span_kind,
34+
attributes,
3235
config
3336
) do
3437
result =
3538
if span_name in config[:drop] do
3639
{:drop, [], []}
3740
else
38-
sample_rate = Sentry.Config.traces_sample_rate()
41+
traces_sampler = Sentry.Config.traces_sampler()
42+
traces_sample_rate = Sentry.Config.traces_sample_rate()
3943

4044
case get_trace_sampling_decision(ctx) do
4145
{:inherit, trace_sampled, tracestate} ->
4246
decision = if trace_sampled, do: :record_and_sample, else: :drop
43-
4447
{decision, [], tracestate}
4548

4649
:no_trace ->
47-
make_sampling_decision(sample_rate)
50+
if traces_sampler do
51+
sampling_context =
52+
build_sampling_context(nil, span_name, span_kind, attributes, trace_id)
53+
54+
make_sampler_decision(traces_sampler, sampling_context)
55+
else
56+
make_sampling_decision(traces_sample_rate)
57+
end
4858
end
4959
end
5060

@@ -121,6 +131,56 @@ if Code.ensure_loaded?(:otel_sampler) do
121131
end
122132
end
123133

134+
defp build_sampling_context(parent_sampled, span_name, _span_kind, attributes, trace_id) do
135+
transaction_context = %{
136+
name: span_name,
137+
op: span_name,
138+
trace_id: trace_id,
139+
attributes: attributes
140+
}
141+
142+
sampling_context = %SamplingContext{
143+
transaction_context: transaction_context,
144+
parent_sampled: parent_sampled
145+
}
146+
147+
sampling_context
148+
end
149+
150+
defp make_sampler_decision(traces_sampler, sampling_context) do
151+
try do
152+
result = call_traces_sampler(traces_sampler, sampling_context)
153+
sample_rate = normalize_sampler_result(result)
154+
155+
if is_float(sample_rate) and sample_rate >= 0.0 and sample_rate <= 1.0 do
156+
make_sampling_decision(sample_rate)
157+
else
158+
Logger.warning(
159+
"traces_sampler function returned an invalid sample rate: #{inspect(sample_rate)}"
160+
)
161+
162+
make_sampling_decision(0.0)
163+
end
164+
rescue
165+
error ->
166+
Logger.warning("traces_sampler function failed: #{inspect(error)}")
167+
168+
make_sampling_decision(0.0)
169+
end
170+
end
171+
172+
defp call_traces_sampler(fun, sampling_context) when is_function(fun, 1) do
173+
fun.(sampling_context)
174+
end
175+
176+
defp call_traces_sampler({module, function}, sampling_context) do
177+
apply(module, function, [sampling_context])
178+
end
179+
180+
defp normalize_sampler_result(true), do: 1.0
181+
defp normalize_sampler_result(false), do: 0.0
182+
defp normalize_sampler_result(rate), do: rate
183+
124184
defp record_discarded_transaction() do
125185
ClientReport.Sender.record_discarded_events(:sample_rate, "transaction")
126186
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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
defmodule Sentry.ConfigTracesSamplerTest do
2+
use ExUnit.Case, async: true
3+
4+
import Sentry.TestHelpers
5+
6+
describe "traces_sampler configuration validation" do
7+
defmodule TestSampler do
8+
def sample(_context), do: 0.5
9+
end
10+
11+
test "accepts nil" do
12+
assert :ok = put_test_config(traces_sampler: nil)
13+
assert Sentry.Config.traces_sampler() == nil
14+
end
15+
16+
test "accepts function with arity 1" do
17+
fun = fn _context -> 0.5 end
18+
assert :ok = put_test_config(traces_sampler: fun)
19+
assert Sentry.Config.traces_sampler() == fun
20+
end
21+
22+
test "accepts MFA tuple with exported function" do
23+
assert :ok = put_test_config(traces_sampler: {TestSampler, :sample})
24+
assert Sentry.Config.traces_sampler() == {TestSampler, :sample}
25+
end
26+
27+
test "rejects MFA tuple with non-exported function" do
28+
assert_raise ArgumentError, ~r/function.*is not exported/, fn ->
29+
put_test_config(traces_sampler: {TestSampler, :non_existent})
30+
end
31+
end
32+
33+
test "rejects function with wrong arity" do
34+
fun = fn -> 0.5 end
35+
36+
assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn ->
37+
put_test_config(traces_sampler: fun)
38+
end
39+
end
40+
41+
test "rejects invalid types" do
42+
assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn ->
43+
put_test_config(traces_sampler: "invalid")
44+
end
45+
46+
assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn ->
47+
put_test_config(traces_sampler: 123)
48+
end
49+
50+
assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn ->
51+
put_test_config(traces_sampler: [])
52+
end
53+
end
54+
end
55+
56+
describe "tracing? function" do
57+
test "returns true when traces_sample_rate is set" do
58+
put_test_config(traces_sample_rate: 0.5, traces_sampler: nil)
59+
60+
assert Sentry.Config.tracing?()
61+
end
62+
63+
test "returns true when traces_sampler is set" do
64+
put_test_config(traces_sample_rate: nil, traces_sampler: fn _ -> 0.5 end)
65+
66+
assert Sentry.Config.tracing?()
67+
end
68+
69+
test "returns true when both are set" do
70+
put_test_config(traces_sample_rate: 0.5, traces_sampler: fn _ -> 0.5 end)
71+
72+
assert Sentry.Config.tracing?()
73+
end
74+
75+
test "returns false when neither is set" do
76+
put_test_config(traces_sample_rate: nil, traces_sampler: nil)
77+
78+
refute Sentry.Config.tracing?()
79+
end
80+
end
81+
end

0 commit comments

Comments
 (0)