Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[
{"test/support/example_plug_application.ex"},
{"test/support/test_helpers.ex"}
{"test/support/test_helpers.ex"},
{"lib/sentry/opentelemetry/sampler.ex", :pattern_match, 1}
]
60 changes: 59 additions & 1 deletion lib/sentry/config.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
defmodule Sentry.Config do
@moduledoc false

@typedoc """
A function that determines the sample rate for transaction events.

The function receives a sampling context map and should return a boolean or a float between `0.0` and `1.0`.
"""
@type traces_sampler_function :: (map() -> boolean() | float()) | {module(), atom()}

integrations_schema = [
max_expected_check_in_time: [
type: :integer,
Expand Down Expand Up @@ -158,6 +165,34 @@ defmodule Sentry.Config do
for guides on how to set it up.
"""
],
traces_sampler: [
type: {:custom, __MODULE__, :__validate_traces_sampler__, []},
default: nil,
type_doc: "`t:traces_sampler_function/0` or `nil`",
doc: """
A function that determines the sample rate for transaction events. This function
receives a sampling context struct and should return a boolean or a float between `0.0` and `1.0`.

The sampling context contains:
- `:parent_sampled` - boolean indicating if the parent trace span was sampled (nil if no parent)
- `:transaction_context` - map with transaction information (name, op, etc.)

If both `:traces_sampler` and `:traces_sample_rate` are configured, `:traces_sampler` takes precedence.

Example:
```elixir
traces_sampler: fn sampling_context ->
case sampling_context.transaction_context.op do
"http.server" -> 0.1 # Sample 10% of HTTP requests
"db.query" -> 0.01 # Sample 1% of database queries
_ -> false # Don't sample other operations
end
end
```

This value is also used to determine if tracing is enabled: if it's not `nil`, tracing is enabled.
"""
],
included_environments: [
type: {:or, [{:in, [:all]}, {:list, {:or, [:atom, :string]}}]},
deprecated: "Use :dsn to control whether to send events to Sentry.",
Expand Down Expand Up @@ -625,6 +660,9 @@ defmodule Sentry.Config do
@spec traces_sample_rate() :: nil | float()
def traces_sample_rate, do: fetch!(:traces_sample_rate)

@spec traces_sampler() :: traces_sampler_function() | nil
def traces_sampler, do: get(:traces_sampler)

@spec hackney_opts() :: keyword()
def hackney_opts, do: fetch!(:hackney_opts)

Expand Down Expand Up @@ -663,7 +701,7 @@ defmodule Sentry.Config do
def integrations, do: fetch!(:integrations)

@spec tracing?() :: boolean()
def tracing?, do: not is_nil(fetch!(:traces_sample_rate))
def tracing?, do: not is_nil(fetch!(:traces_sample_rate)) or not is_nil(get(:traces_sampler))

@spec put_config(atom(), term()) :: :ok
def put_config(key, value) when is_atom(key) do
Expand Down Expand Up @@ -773,6 +811,26 @@ defmodule Sentry.Config do
end
end

def __validate_traces_sampler__(nil), do: {:ok, nil}

def __validate_traces_sampler__(fun) when is_function(fun, 1) do
{:ok, fun}
end

def __validate_traces_sampler__({module, function})
when is_atom(module) and is_atom(function) do
if function_exported?(module, function, 1) do
{:ok, {module, function}}
else
{:error, "function #{module}.#{function}/1 is not exported"}
end
end

def __validate_traces_sampler__(other) do
{:error,
"expected :traces_sampler to be nil, a function with arity 1, or a {module, function} tuple, got: #{inspect(other)}"}
end

def __validate_json_library__(nil) do
{:error, "nil is not a valid value for the :json_library option"}
end
Expand Down
109 changes: 101 additions & 8 deletions lib/sentry/opentelemetry/sampler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ if Code.ensure_loaded?(:otel_sampler) do

alias OpenTelemetry.{Span, Tracer}
alias Sentry.ClientReport
alias SamplingContext

require Logger

@behaviour :otel_sampler

Expand All @@ -24,27 +27,47 @@ if Code.ensure_loaded?(:otel_sampler) do
@impl true
def should_sample(
ctx,
_trace_id,
trace_id,
_links,
span_name,
_span_kind,
_attributes,
span_kind,
attributes,
config
) do
result =
if span_name in config[:drop] do
{:drop, [], []}
else
sample_rate = Sentry.Config.traces_sample_rate()
traces_sampler = Sentry.Config.traces_sampler()
traces_sample_rate = Sentry.Config.traces_sample_rate()

case get_trace_sampling_decision(ctx) do
{:inherit, trace_sampled, tracestate} ->
decision = if trace_sampled, do: :record_and_sample, else: :drop

{decision, [], tracestate}
if traces_sampler do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the traces_sampler has to be invoked only for root spans, see
https://github.com/getsentry/sentry-python/blob/c21525e4252805561d83cd2d726020dd41aa074d/sentry_sdk/opentelemetry/sampler.py#L246

so in this case I think the :inherit case shouldn't exist but only the :no_trace case below should invoke the traces_sampler

sampling_context =
build_sampling_context(
trace_sampled,
span_name,
span_kind,
attributes,
trace_id
)

make_sampler_decision(traces_sampler, sampling_context, tracestate)
else
decision = if trace_sampled, do: :record_and_sample, else: :drop
{decision, [], tracestate}
end

:no_trace ->
make_sampling_decision(sample_rate)
if traces_sampler do
sampling_context =
build_sampling_context(nil, span_name, span_kind, attributes, trace_id)

make_sampler_decision(traces_sampler, sampling_context, [])
else
make_sampling_decision(traces_sample_rate)
end
end
end

Expand Down Expand Up @@ -121,6 +144,76 @@ if Code.ensure_loaded?(:otel_sampler) do
end
end

defp build_sampling_context(parent_sampled, span_name, _span_kind, attributes, trace_id) do
transaction_context = %{
name: span_name,
op: span_name,
trace_id: trace_id,
attributes: attributes
}

sampling_context = %SamplingContext{
transaction_context: transaction_context,
parent_sampled: parent_sampled
}

if attributes && map_size(attributes) > 0 do
Map.merge(sampling_context, attributes)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attributes is already part of transaction_context above, why this merge?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sl0thentr0py accidental left-over after a refactoring :)

else
sampling_context
end
end

defp make_sampler_decision(traces_sampler, sampling_context, _existing_tracestate) do
try do
result = call_traces_sampler(traces_sampler, sampling_context)
sample_rate = normalize_sampler_result(result)

cond do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you just use make_sampling_decision after getting the rate here? wanna avoid duplication here especially

sample_rate == 0.0 ->
tracestate = build_tracestate(0.0, 1.0, false)

{:drop, [], tracestate}

sample_rate == 1.0 ->
tracestate = build_tracestate(1.0, 0.0, true)

{:record_and_sample, [], tracestate}

is_float(sample_rate) and sample_rate > 0.0 and sample_rate < 1.0 ->
random_value = :rand.uniform()
sampled = random_value < sample_rate
tracestate = build_tracestate(sample_rate, random_value, sampled)
decision = if sampled, do: :record_and_sample, else: :drop

{decision, [], tracestate}

true ->
tracestate = build_tracestate(0.0, 1.0, false)

{:drop, [], tracestate}
end
rescue
error ->
Logger.warning("traces_sampler function failed: #{inspect(error)}")

tracestate = build_tracestate(0.0, 1.0, false)
{:drop, [], tracestate}
end
end

defp call_traces_sampler(fun, sampling_context) when is_function(fun, 1) do
fun.(sampling_context)
end

defp call_traces_sampler({module, function}, sampling_context) do
apply(module, function, [sampling_context])
end

defp normalize_sampler_result(true), do: 1.0
defp normalize_sampler_result(false), do: 0.0
defp normalize_sampler_result(rate) when is_float(rate), do: rate

defp record_discarded_transaction() do
ClientReport.Sender.record_discarded_events(:sample_rate, "transaction")
end
Expand Down
57 changes: 57 additions & 0 deletions lib/sentry/sampling_context.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
defmodule SamplingContext do
@moduledoc """
The struct for the **sampling_context** that is passed to `traces_sampler`.

This is set up via `Sentry.OpenTelemetry.Sampler`.

See also <https://develop.sentry.dev/sdk/telemetry/traces/#sampling-context>.
"""

@moduledoc since: "11.0.0"

@typedoc """
The sampling context struct that contains information needed for sampling decisions.

This matches the structure used in the Python SDK's create_sampling_context function.
"""
@type t :: %__MODULE__{
transaction_context: %{
name: String.t() | nil,
op: String.t(),
trace_id: String.t(),
attributes: map()
},
parent_sampled: boolean() | nil
}

@enforce_keys [:transaction_context, :parent_sampled]
defstruct [:transaction_context, :parent_sampled]

@behaviour Access

@impl Access
def fetch(struct, key) do
case Map.fetch(struct, key) do
{:ok, value} -> {:ok, value}
:error -> :error
end
end

@impl Access
def get_and_update(struct, key, function) do
current_value = Map.get(struct, key)

case function.(current_value) do
{get_value, update_value} ->
{get_value, Map.put(struct, key, update_value)}

:pop ->
{current_value, Map.delete(struct, key)}
end
end

@impl Access
def pop(struct, key) do
{Map.get(struct, key), Map.delete(struct, key)}
end
end
81 changes: 81 additions & 0 deletions test/sentry/config_traces_sampler_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
defmodule Sentry.ConfigTracesSamplerTest do
use ExUnit.Case, async: true

import Sentry.TestHelpers

describe "traces_sampler configuration validation" do
defmodule TestSampler do
def sample(_context), do: 0.5
end

test "accepts nil" do
assert :ok = put_test_config(traces_sampler: nil)
assert Sentry.Config.traces_sampler() == nil
end

test "accepts function with arity 1" do
fun = fn _context -> 0.5 end
assert :ok = put_test_config(traces_sampler: fun)
assert Sentry.Config.traces_sampler() == fun
end

test "accepts MFA tuple with exported function" do
assert :ok = put_test_config(traces_sampler: {TestSampler, :sample})
assert Sentry.Config.traces_sampler() == {TestSampler, :sample}
end

test "rejects MFA tuple with non-exported function" do
assert_raise ArgumentError, ~r/function.*is not exported/, fn ->
put_test_config(traces_sampler: {TestSampler, :non_existent})
end
end

test "rejects function with wrong arity" do
fun = fn -> 0.5 end

assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn ->
put_test_config(traces_sampler: fun)
end
end

test "rejects invalid types" do
assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn ->
put_test_config(traces_sampler: "invalid")
end

assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn ->
put_test_config(traces_sampler: 123)
end

assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn ->
put_test_config(traces_sampler: [])
end
end
end

describe "tracing? function" do
test "returns true when traces_sample_rate is set" do
put_test_config(traces_sample_rate: 0.5, traces_sampler: nil)

assert Sentry.Config.tracing?()
end

test "returns true when traces_sampler is set" do
put_test_config(traces_sample_rate: nil, traces_sampler: fn _ -> 0.5 end)

assert Sentry.Config.tracing?()
end

test "returns true when both are set" do
put_test_config(traces_sample_rate: 0.5, traces_sampler: fn _ -> 0.5 end)

assert Sentry.Config.tracing?()
end

test "returns false when neither is set" do
put_test_config(traces_sample_rate: nil, traces_sampler: nil)

refute Sentry.Config.tracing?()
end
end
end
Loading