Skip to content

Commit 7e7718f

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 7e7718f

File tree

6 files changed

+370
-25
lines changed

6 files changed

+370
-25
lines changed

lib/sentry/client.ex

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,12 @@ defmodule Sentry.Client do
110110
@spec send_transaction(Transaction.t(), keyword()) ::
111111
{:ok, transaction_id :: String.t()}
112112
| {:error, ClientError.t()}
113-
| :unsampled
114113
| :excluded
115114
def send_transaction(%Transaction{} = transaction, opts \\ []) do
116115
opts = NimbleOptions.validate!(opts, Options.send_transaction_schema())
117116

118117
result_type = Keyword.get_lazy(opts, :result, &Config.send_result/0)
119118
client = Keyword.get_lazy(opts, :client, &Config.client/0)
120-
sample_rate = Keyword.get_lazy(opts, :sample_rate, &Config.traces_sample_rate/0)
121119
before_send = Keyword.get_lazy(opts, :before_send, &Config.before_send/0)
122120
after_send_event = Keyword.get_lazy(opts, :after_send_event, &Config.after_send_event/0)
123121

@@ -126,16 +124,11 @@ defmodule Sentry.Client do
126124
Application.get_env(:sentry, :request_retries, Transport.default_retries())
127125
end)
128126

129-
with :ok <- sample_event(sample_rate),
130-
{:ok, %Transaction{} = transaction} <- maybe_call_before_send(transaction, before_send) do
127+
with {:ok, %Transaction{} = transaction} <- maybe_call_before_send(transaction, before_send) do
131128
send_result = encode_and_send(transaction, result_type, client, request_retries)
132129
_ignored = maybe_call_after_send(transaction, send_result, after_send_event)
133130
send_result
134131
else
135-
:unsampled ->
136-
ClientReport.Sender.record_discarded_events(:sample_rate, [transaction])
137-
:unsampled
138-
139132
:excluded ->
140133
:excluded
141134
end

lib/sentry/opentelemetry/sampler.ex

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,28 @@ if Code.ensure_loaded?(:otel_sampler) do
22
defmodule Sentry.OpenTelemetry.Sampler do
33
@moduledoc false
44

5+
alias OpenTelemetry.{Span, Tracer}
6+
57
@behaviour :otel_sampler
68

9+
@sentry_sample_rate_key "sentry-sample_rate"
10+
@sentry_sample_rand_key "sentry-sample_rand"
11+
@sentry_sampled_key "sentry-sampled"
12+
13+
@impl true
714
def setup(config) do
815
config
916
end
1017

18+
@impl true
1119
def description(_) do
1220
"SentrySampler"
1321
end
1422

23+
@impl true
1524
def should_sample(
16-
_ctx,
17-
_trace_id,
25+
ctx,
26+
trace_id,
1827
_links,
1928
span_name,
2029
_span_kind,
@@ -24,7 +33,78 @@ if Code.ensure_loaded?(:otel_sampler) do
2433
if span_name in config[:drop] do
2534
{:drop, [], []}
2635
else
27-
{:record_and_sample, [], []}
36+
sample_rate = Sentry.Config.traces_sample_rate()
37+
38+
case get_parent_sampling_decision(ctx, trace_id) do
39+
{:inherit, parent_sampled, tracestate} ->
40+
decision = if parent_sampled, do: :record_and_sample, else: :drop
41+
{decision, [], tracestate}
42+
43+
:no_parent ->
44+
make_sampling_decision(sample_rate)
45+
end
46+
end
47+
end
48+
49+
defp get_parent_sampling_decision(ctx, trace_id) do
50+
case Tracer.current_span_ctx(ctx) do
51+
:undefined ->
52+
:no_parent
53+
54+
span_ctx ->
55+
parent_trace_id = Span.trace_id(span_ctx)
56+
57+
if parent_trace_id == trace_id do
58+
tracestate = Span.tracestate(span_ctx)
59+
parent_sampled = get_tracestate_value(tracestate, @sentry_sampled_key)
60+
61+
case parent_sampled do
62+
"true" -> {:inherit, true, tracestate}
63+
"false" -> {:inherit, false, tracestate}
64+
nil -> :no_parent
65+
end
66+
else
67+
:no_parent
68+
end
69+
end
70+
end
71+
72+
defp make_sampling_decision(sample_rate) do
73+
cond do
74+
sample_rate == 0.0 ->
75+
tracestate = build_tracestate(sample_rate, 1.0, false)
76+
{:drop, [], tracestate}
77+
78+
sample_rate == 1.0 ->
79+
tracestate = build_tracestate(sample_rate, 0.0, true)
80+
{:record_and_sample, [], tracestate}
81+
82+
true ->
83+
random_value = :rand.uniform()
84+
sampled = random_value < sample_rate
85+
86+
tracestate = build_tracestate(sample_rate, random_value, sampled)
87+
decision = if sampled, do: :record_and_sample, else: :drop
88+
{decision, [], tracestate}
89+
end
90+
end
91+
92+
defp build_tracestate(sample_rate, random_value, sampled) do
93+
[
94+
{@sentry_sample_rate_key, Float.to_string(sample_rate)},
95+
{@sentry_sample_rand_key, Float.to_string(random_value)},
96+
{@sentry_sampled_key, to_string(sampled)}
97+
]
98+
end
99+
100+
defp get_tracestate_value({:tracestate, tracestate}, key) do
101+
get_tracestate_value(tracestate, key)
102+
end
103+
104+
defp get_tracestate_value(tracestate, key) when is_list(tracestate) do
105+
case List.keyfind(tracestate, key, 0) do
106+
{^key, value} -> value
107+
nil -> nil
28108
end
29109
end
30110
end

lib/sentry/opentelemetry/span_processor.ex

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

42+
:excluded ->
43+
true
44+
4245
{:error, error} ->
4346
Logger.warning("Failed to send transaction to Sentry: #{inspect(error)}")
4447
{: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)