Skip to content

Commit 5a841bb

Browse files
committed
Better span sampler
This updates OpenTelemetry.Sampler so that it uses `traces_sample_rate` for sampling spans before they get sent to the span processor. This way we're not processing spans when they are dropped during sampling, which was previously the case as the Client was responsible for sampling just before attemping to send a transaction.
1 parent bd2421a commit 5a841bb

File tree

6 files changed

+401
-20
lines changed

6 files changed

+401
-20
lines changed

lib/sentry/client.ex

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ defmodule Sentry.Client do
117117

118118
result_type = Keyword.get_lazy(opts, :result, &Config.send_result/0)
119119
client = Keyword.get_lazy(opts, :client, &Config.client/0)
120-
sample_rate = Keyword.get_lazy(opts, :sample_rate, &Config.traces_sample_rate/0)
121120
before_send = Keyword.get_lazy(opts, :before_send, &Config.before_send/0)
122121
after_send_event = Keyword.get_lazy(opts, :after_send_event, &Config.after_send_event/0)
123122

@@ -126,8 +125,7 @@ defmodule Sentry.Client do
126125
Application.get_env(:sentry, :request_retries, Transport.default_retries())
127126
end)
128127

129-
with :ok <- sample_event(sample_rate),
130-
{:ok, %Transaction{} = transaction} <- maybe_call_before_send(transaction, before_send) do
128+
with {:ok, %Transaction{} = transaction} <- maybe_call_before_send(transaction, before_send) do
131129
send_result = encode_and_send(transaction, result_type, client, request_retries)
132130
_ignored = maybe_call_after_send(transaction, send_result, after_send_event)
133131
send_result

lib/sentry/opentelemetry/sampler.ex

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,24 @@ if Code.ensure_loaded?(:otel_sampler) do
44

55
@behaviour :otel_sampler
66

7+
@sentry_sample_rate_key "sentry-sample_rate"
8+
@sentry_sample_rand_key "sentry-sample_rand"
9+
@sentry_sampled_key "sentry-sampled"
10+
11+
@impl true
712
def setup(config) do
813
config
914
end
1015

16+
@impl true
1117
def description(_) do
1218
"SentrySampler"
1319
end
1420

21+
@impl true
1522
def should_sample(
16-
_ctx,
17-
_trace_id,
23+
ctx,
24+
trace_id,
1825
_links,
1926
span_name,
2027
_span_kind,
@@ -24,8 +31,81 @@ if Code.ensure_loaded?(:otel_sampler) do
2431
if span_name in config[:drop] do
2532
{:drop, [], []}
2633
else
27-
{:record_and_sample, [], []}
34+
sample_rate = Sentry.Config.traces_sample_rate()
35+
36+
case get_parent_sampling_decision(ctx, trace_id) do
37+
{:inherit, parent_sampled, tracestate} ->
38+
decision = if parent_sampled, do: :record_and_sample, else: :drop
39+
{decision, [], tracestate}
40+
41+
:no_parent ->
42+
make_sampling_decision(sample_rate)
43+
end
2844
end
2945
end
46+
47+
defp get_parent_sampling_decision(ctx, trace_id) do
48+
try do
49+
case :otel_tracer.current_span_ctx(ctx) do
50+
:undefined ->
51+
:no_parent
52+
53+
span_ctx ->
54+
parent_trace_id = :otel_span.trace_id(span_ctx)
55+
56+
if parent_trace_id == trace_id do
57+
tracestate = :otel_span.tracestate(span_ctx)
58+
parent_sampled = get_tracestate_value(tracestate, @sentry_sampled_key)
59+
60+
case parent_sampled do
61+
"true" -> {:inherit, true, tracestate}
62+
"false" -> {:inherit, false, tracestate}
63+
_ -> :no_parent
64+
end
65+
else
66+
:no_parent
67+
end
68+
end
69+
rescue
70+
_ -> :no_parent
71+
end
72+
end
73+
74+
defp make_sampling_decision(sample_rate) do
75+
cond do
76+
sample_rate == 0.0 ->
77+
tracestate = build_tracestate(sample_rate, 1.0, false)
78+
{:drop, [], tracestate}
79+
80+
sample_rate == 1.0 ->
81+
tracestate = build_tracestate(sample_rate, 0.0, true)
82+
{:record_and_sample, [], tracestate}
83+
84+
true ->
85+
random_value = :rand.uniform()
86+
sampled = random_value < sample_rate
87+
88+
tracestate = build_tracestate(sample_rate, random_value, sampled)
89+
decision = if sampled, do: :record_and_sample, else: :drop
90+
{decision, [], tracestate}
91+
end
92+
end
93+
94+
defp build_tracestate(sample_rate, random_value, sampled) do
95+
[
96+
{@sentry_sample_rate_key, Float.to_string(sample_rate)},
97+
{@sentry_sample_rand_key, Float.to_string(random_value)},
98+
{@sentry_sampled_key, to_string(sampled)}
99+
]
100+
end
101+
102+
defp get_tracestate_value(tracestate, key) when is_list(tracestate) do
103+
case List.keyfind(tracestate, key, 0) do
104+
{^key, value} -> value
105+
nil -> nil
106+
end
107+
end
108+
109+
defp get_tracestate_value(_tracestate, _key), do: nil
30110
end
31111
end

lib/sentry/opentelemetry/span_processor.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ if Code.ensure_loaded?(OpenTelemetry) do
3939
:ignored ->
4040
true
4141

42+
:unsampled ->
43+
# Transaction was not sampled, this is expected behavior
44+
true
45+
46+
:excluded ->
47+
# Transaction was excluded (e.g., by before_send callback)
48+
true
49+
4250
{:error, error} ->
4351
Logger.warning("Failed to send transaction to Sentry: #{inspect(error)}")
4452
{:error, :invalid_span}

test/sentry/opentelemetry/sampler_test.exs

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,103 @@ defmodule Sentry.Opentelemetry.SamplerTest do
33

44
alias Sentry.OpenTelemetry.Sampler
55

6-
test "drops spans with the given name" do
7-
assert {:drop, [], []} =
8-
Sampler.should_sample(nil, nil, nil, "Elixir.Oban.Stager process", nil, nil,
9-
drop: ["Elixir.Oban.Stager process"]
10-
)
6+
setup do
7+
original_rate = Sentry.Config.traces_sample_rate()
8+
9+
on_exit(fn ->
10+
Sentry.Config.put_config(:traces_sample_rate, original_rate)
11+
end)
12+
13+
:ok
14+
end
15+
16+
describe "span name dropping" do
17+
test "drops spans with the given name" do
18+
assert {:drop, [], []} =
19+
Sampler.should_sample(nil, nil, nil, "Elixir.Oban.Stager process", nil, nil,
20+
drop: ["Elixir.Oban.Stager process"]
21+
)
22+
end
23+
24+
test "records and samples spans not in drop list" do
25+
Sentry.Config.put_config(:traces_sample_rate, 1.0)
26+
27+
assert {:record_and_sample, [], tracestate} =
28+
Sampler.should_sample(nil, 123, nil, "Elixir.Oban.Worker process", nil, nil,
29+
drop: []
30+
)
31+
32+
assert is_list(tracestate)
33+
assert {"sentry-sample_rate", "1.0"} in tracestate
34+
assert {"sentry-sampled", "true"} in tracestate
35+
end
1136
end
1237

13-
test "records and samples spans with the given name" do
14-
assert {:record_and_sample, [], []} =
15-
Sampler.should_sample(nil, nil, nil, "Elixir.Oban.Worker process", nil, nil,
16-
drop: []
17-
)
38+
describe "sampling based on traces_sample_rate" do
39+
test "always drops when sample rate is 0.0" do
40+
Sentry.Config.put_config(:traces_sample_rate, 0.0)
41+
42+
assert {:drop, [], tracestate} =
43+
Sampler.should_sample(nil, 123, nil, "test span", nil, nil, drop: [])
44+
45+
assert {"sentry-sample_rate", "0.0"} in tracestate
46+
assert {"sentry-sampled", "false"} in tracestate
47+
end
48+
49+
test "always samples when sample rate is 1.0" do
50+
Sentry.Config.put_config(:traces_sample_rate, 1.0)
51+
52+
assert {:record_and_sample, [], tracestate} =
53+
Sampler.should_sample(nil, 123, nil, "test span", nil, nil, drop: [])
54+
55+
assert {"sentry-sample_rate", "1.0"} in tracestate
56+
assert {"sentry-sampled", "true"} in tracestate
57+
end
58+
59+
test "different trace_ids produce different sampling decisions" do
60+
Sentry.Config.put_config(:traces_sample_rate, 0.5)
61+
62+
trace_ids = Enum.to_list(1..100)
63+
64+
results =
65+
Enum.map(trace_ids, fn trace_id ->
66+
{decision, [], _tracestate} =
67+
Sampler.should_sample(nil, trace_id, nil, "test span", nil, nil, drop: [])
68+
69+
decision == :record_and_sample
70+
end)
71+
72+
sampled_count = Enum.count(results, & &1)
73+
74+
assert sampled_count > 30 and sampled_count < 70
75+
end
76+
end
77+
78+
describe "parent span inheritance" do
79+
test "inherits sampling decision from parent span with same trace_id" do
80+
Sentry.Config.put_config(:traces_sample_rate, 1.0)
81+
82+
assert {:record_and_sample, [], _tracestate} =
83+
Sampler.should_sample(nil, 123, nil, "test span", nil, nil, drop: [])
84+
end
85+
end
86+
87+
describe "tracestate management" do
88+
test "builds tracestate with correct format" do
89+
Sentry.Config.put_config(:traces_sample_rate, 0.75)
90+
91+
{_decision, [], tracestate} =
92+
Sampler.should_sample(nil, 123, nil, "test span", nil, nil, drop: [])
93+
94+
assert List.keyfind(tracestate, "sentry-sample_rate", 0)
95+
assert List.keyfind(tracestate, "sentry-sample_rand", 0)
96+
assert List.keyfind(tracestate, "sentry-sampled", 0)
97+
98+
{"sentry-sample_rate", rate_str} = List.keyfind(tracestate, "sentry-sample_rate", 0)
99+
assert rate_str == "0.75"
100+
101+
{"sentry-sampled", sampled_str} = List.keyfind(tracestate, "sentry-sampled", 0)
102+
assert sampled_str in ["true", "false"]
103+
end
18104
end
19105
end

0 commit comments

Comments
 (0)