Skip to content

Commit 58ce111

Browse files
committed
Better span sampler (#903)
* 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. * Enhance sampling logic to record discarded transactions * Clarify trace-level sampling decision-making Previously it incorrectly referred to "parent" where in reality we're dealing with spans from the same trace and respect sampling decisions that were already made for a given trace. This is not the same thing as parent/child spans that we're handling in the SpanProcessor as this type of relationship is established post-sampling. * Remove unnecessary sleep calls in sampler tests * Fix flaky test * Clean up make_sampling_decision * Simplify client reports for dropped transactions * Update traces_sample_rate default value to be nil * Account for `nil` sample_rate when sampling * Use put_test_config * Update traces_sample_rate spec to allow nil value
1 parent 55cacc1 commit 58ce111

File tree

9 files changed

+611
-40
lines changed

9 files changed

+611
-40
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/client_report/sender.ex

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,21 @@ defmodule Sentry.ClientReport.Sender do
1717
GenServer.start_link(__MODULE__, nil, name: Keyword.get(opts, :name, __MODULE__))
1818
end
1919

20+
def record_discarded_events(reason, info, genserver \\ __MODULE__)
21+
22+
@spec record_discarded_events(atom(), String.t(), GenServer.server()) :: :ok
23+
def record_discarded_events(reason, data_category, genserver)
24+
when is_binary(data_category) do
25+
GenServer.cast(genserver, {:record_discarded_events, reason, data_category})
26+
end
27+
2028
@spec record_discarded_events(atom(), [item], GenServer.server()) :: :ok
2129
when item:
2230
Sentry.Attachment.t()
2331
| Sentry.CheckIn.t()
2432
| ClientReport.t()
2533
| Sentry.Event.t()
26-
| Sentry.Transaction.t()
27-
def record_discarded_events(reason, event_items, genserver \\ __MODULE__)
34+
def record_discarded_events(reason, event_items, genserver)
2835
when is_list(event_items) do
2936
# We silently ignore events whose reasons aren't valid because we have to add it to the allowlist in Snuba
3037
# https://develop.sentry.dev/sdk/client-reports/

lib/sentry/config.ex

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,13 @@ defmodule Sentry.Config do
145145
],
146146
traces_sample_rate: [
147147
type: {:custom, __MODULE__, :__validate_traces_sample_rate__, []},
148-
default: 0.0,
148+
default: nil,
149149
doc: """
150150
The sample rate for transaction events. A value between `0.0` and `1.0` (inclusive).
151151
A value of `0.0` means no transactions will be sampled, while `1.0` means all transactions
152-
will be sampled. This value is also used to determine if tracing is enabled: if it's
153-
greater than `0`, tracing is enabled.
152+
will be sampled.
153+
154+
This value is also used to determine if tracing is enabled: if it's not `nil`, tracing is enabled.
154155
155156
Tracing requires OpenTelemetry packages to work. See [the
156157
OpenTelemetry setup documentation](https://opentelemetry.io/docs/languages/erlang/getting-started/)
@@ -621,7 +622,7 @@ defmodule Sentry.Config do
621622
@spec sample_rate() :: float()
622623
def sample_rate, do: fetch!(:sample_rate)
623624

624-
@spec traces_sample_rate() :: float()
625+
@spec traces_sample_rate() :: nil | float()
625626
def traces_sample_rate, do: fetch!(:traces_sample_rate)
626627

627628
@spec hackney_opts() :: keyword()
@@ -662,7 +663,7 @@ defmodule Sentry.Config do
662663
def integrations, do: fetch!(:integrations)
663664

664665
@spec tracing?() :: boolean()
665-
def tracing?, do: fetch!(:traces_sample_rate) > 0.0
666+
def tracing?, do: not is_nil(fetch!(:traces_sample_rate))
666667

667668
@spec put_config(atom(), term()) :: :ok
668669
def put_config(key, value) when is_atom(key) do
@@ -763,12 +764,12 @@ defmodule Sentry.Config do
763764
end
764765
end
765766

766-
def __validate_traces_sample_rate__(float) do
767-
if is_float(float) and float >= 0.0 and float <= 1.0 do
768-
{:ok, float}
767+
def __validate_traces_sample_rate__(value) do
768+
if is_nil(value) or (is_float(value) and value >= 0.0 and value <= 1.0) do
769+
{:ok, value}
769770
else
770771
{:error,
771-
"expected :traces_sample_rate to be a float between 0.0 and 1.0 (included), got: #{inspect(float)}"}
772+
"expected :traces_sample_rate to be nil or a value between 0.0 and 1.0 (included), got: #{inspect(value)}"}
772773
end
773774
end
774775

lib/sentry/opentelemetry/sampler.ex

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

5+
alias OpenTelemetry.{Span, Tracer}
6+
alias Sentry.ClientReport
7+
58
@behaviour :otel_sampler
69

10+
@sentry_sample_rate_key "sentry-sample_rate"
11+
@sentry_sample_rand_key "sentry-sample_rand"
12+
@sentry_sampled_key "sentry-sampled"
13+
14+
@impl true
715
def setup(config) do
816
config
917
end
1018

19+
@impl true
1120
def description(_) do
1221
"SentrySampler"
1322
end
1423

24+
@impl true
1525
def should_sample(
16-
_ctx,
26+
ctx,
1727
_trace_id,
1828
_links,
1929
span_name,
2030
_span_kind,
2131
_attributes,
2232
config
2333
) do
24-
if span_name in config[:drop] do
25-
{:drop, [], []}
26-
else
27-
{:record_and_sample, [], []}
34+
result =
35+
if span_name in config[:drop] do
36+
{:drop, [], []}
37+
else
38+
sample_rate = Sentry.Config.traces_sample_rate()
39+
40+
case get_trace_sampling_decision(ctx) do
41+
{:inherit, trace_sampled, tracestate} ->
42+
decision = if trace_sampled, do: :record_and_sample, else: :drop
43+
44+
{decision, [], tracestate}
45+
46+
:no_trace ->
47+
make_sampling_decision(sample_rate)
48+
end
49+
end
50+
51+
case result do
52+
{:drop, _, _} ->
53+
record_discarded_transaction()
54+
result
55+
56+
_ ->
57+
result
2858
end
2959
end
60+
61+
defp get_trace_sampling_decision(ctx) do
62+
case Tracer.current_span_ctx(ctx) do
63+
:undefined ->
64+
:no_trace
65+
66+
span_ctx ->
67+
tracestate = Span.tracestate(span_ctx)
68+
trace_sampled = get_tracestate_value(tracestate, @sentry_sampled_key)
69+
70+
case trace_sampled do
71+
"true" ->
72+
{:inherit, true, tracestate}
73+
74+
"false" ->
75+
{:inherit, false, tracestate}
76+
77+
nil ->
78+
:no_trace
79+
end
80+
end
81+
end
82+
83+
defp make_sampling_decision(sample_rate) do
84+
cond do
85+
is_nil(sample_rate) ->
86+
{:drop, [], []}
87+
88+
sample_rate == 0.0 ->
89+
tracestate = build_tracestate(sample_rate, 1.0, false)
90+
{:drop, [], tracestate}
91+
92+
sample_rate == 1.0 ->
93+
tracestate = build_tracestate(sample_rate, 0.0, true)
94+
{:record_and_sample, [], tracestate}
95+
96+
true ->
97+
random_value = :rand.uniform()
98+
sampled = random_value < sample_rate
99+
tracestate = build_tracestate(sample_rate, random_value, sampled)
100+
decision = if sampled, do: :record_and_sample, else: :drop
101+
{decision, [], tracestate}
102+
end
103+
end
104+
105+
defp build_tracestate(sample_rate, random_value, sampled) do
106+
[
107+
{@sentry_sample_rate_key, Float.to_string(sample_rate)},
108+
{@sentry_sample_rand_key, Float.to_string(random_value)},
109+
{@sentry_sampled_key, to_string(sampled)}
110+
]
111+
end
112+
113+
defp get_tracestate_value({:tracestate, tracestate}, key) do
114+
get_tracestate_value(tracestate, key)
115+
end
116+
117+
defp get_tracestate_value(tracestate, key) when is_list(tracestate) do
118+
case List.keyfind(tracestate, key, 0) do
119+
{^key, value} -> value
120+
nil -> nil
121+
end
122+
end
123+
124+
defp record_discarded_transaction() do
125+
ClientReport.Sender.record_discarded_events(:sample_rate, "transaction")
126+
end
30127
end
31128
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/config_test.exs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,12 @@ defmodule Sentry.ConfigTest do
154154
end
155155

156156
test ":traces_sample_rate" do
157+
assert Config.validate!([])[:traces_sample_rate] == nil
158+
159+
assert Config.validate!(traces_sample_rate: nil)[:traces_sample_rate] == nil
160+
assert Config.validate!(traces_sample_rate: 0.0)[:traces_sample_rate] == 0.0
161+
assert Config.validate!(traces_sample_rate: 0.5)[:traces_sample_rate] == 0.5
157162
assert Config.validate!(traces_sample_rate: 1.0)[:traces_sample_rate] == 1.0
158-
assert Config.validate!([])[:traces_sample_rate] == 0.0
159163

160164
assert_raise ArgumentError, ~r/invalid value for :traces_sample_rate option/, fn ->
161165
Config.validate!(traces_sample_rate: 2.0)

0 commit comments

Comments
 (0)