From 96cef485cbbfd09e44dd33287e29c5333c21eb5b Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 21 Sep 2026 11:13:09 +0000 Subject: [PATCH 1/4] fix(telemetry): drop the item when a log or metric callback crashes --- lib/sentry/metric.ex | 19 ----- lib/sentry/telemetry/scheduler.ex | 33 +++++---- test/sentry/metrics_integration_test.exs | 27 +++++++ test/sentry/metrics_test.exs | 55 +++++++------- test/sentry/telemetry/scheduler_test.exs | 91 +++++++++++++----------- 5 files changed, 120 insertions(+), 105 deletions(-) diff --git a/lib/sentry/metric.ex b/lib/sentry/metric.ex index fbead07a..f826a54f 100644 --- a/lib/sentry/metric.ex +++ b/lib/sentry/metric.ex @@ -10,7 +10,6 @@ defmodule Sentry.Metric do @moduledoc since: "13.0.0" alias Sentry.Config - alias Sentry.LoggerUtils @type metric_type :: :counter | :gauge | :distribution @@ -108,24 +107,6 @@ defmodule Sentry.Metric do |> maybe_put(:span_id, metric.span_id) end - @doc false - @spec call_before_send_callback(t(), function() | {module(), atom()}) :: t() | nil - def call_before_send_callback(metric, function) when is_function(function, 1) do - function.(metric) - rescue - error -> - LoggerUtils.warning("before_send_metric callback failed: #{inspect(error)}") - metric - end - - def call_before_send_callback(metric, {mod, fun}) do - apply(mod, fun, [metric]) - rescue - error -> - LoggerUtils.warning("before_send_metric callback failed: #{inspect(error)}") - metric - end - defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) diff --git a/lib/sentry/telemetry/scheduler.ex b/lib/sentry/telemetry/scheduler.ex index b57a45c0..78b2c096 100644 --- a/lib/sentry/telemetry/scheduler.ex +++ b/lib/sentry/telemetry/scheduler.ex @@ -36,6 +36,7 @@ defmodule Sentry.Telemetry.Scheduler do alias Sentry.Telemetry.{Buffer, Category} alias Sentry.{ + Callback, CheckIn, ClientError, ClientReport, @@ -380,22 +381,8 @@ defmodule Sentry.Telemetry.Scheduler do end end - defp call_before_send_log(log_event, function) when is_function(function, 1) do - function.(log_event) - rescue - error -> - LoggerUtils.warning("before_send_log callback failed: #{inspect(error)}") - - log_event - end - - defp call_before_send_log(log_event, {mod, fun}) do - apply(mod, fun, [log_event]) - rescue - error -> - LoggerUtils.warning("before_send_log callback failed: #{inspect(error)}") - - log_event + defp call_before_send_log(log_event, callback) do + Callback.run(:before_send_log, invocation(log_event, callback), nil) end defp apply_before_send_metric_callbacks(metrics) do @@ -403,7 +390,7 @@ defmodule Sentry.Telemetry.Scheduler do if callback do for metric <- metrics, - %Metric{} = modified_metric <- [Metric.call_before_send_callback(metric, callback)] do + %Metric{} = modified_metric <- [call_before_send_metric(metric, callback)] do modified_metric end else @@ -411,6 +398,18 @@ defmodule Sentry.Telemetry.Scheduler do end end + defp call_before_send_metric(metric, callback) do + Callback.run(:before_send_metric, invocation(metric, callback), nil) + end + + defp invocation(item, function) when is_function(function, 1) do + fn -> function.(item) end + end + + defp invocation(item, {mod, fun}) do + fn -> apply(mod, fun, [item]) end + end + defp advance_cycle(%Scheduler{} = state) do cycle_length = length(state.priority_cycle) new_position = rem(state.cycle_position + 1, cycle_length) diff --git a/test/sentry/metrics_integration_test.exs b/test/sentry/metrics_integration_test.exs index 9479b8a0..86e02caa 100644 --- a/test/sentry/metrics_integration_test.exs +++ b/test/sentry/metrics_integration_test.exs @@ -3,6 +3,7 @@ defmodule Sentry.MetricsIntegrationTest do require OpenTelemetry.Tracer, as: Tracer + import ExUnit.CaptureLog import Sentry.TestHelpers import Sentry.Test.Assertions @@ -84,6 +85,20 @@ defmodule Sentry.MetricsIntegrationTest do end end + describe "before_send_metric callback that crashes" do + test "drops the metric when the callback raises", ctx do + assert_metric_dropped(ctx, fn _metric -> raise "boom" end) + end + + test "drops the metric when the callback throws", ctx do + assert_metric_dropped(ctx, fn _metric -> throw(:boom) end) + end + + test "drops the metric when the callback exits", ctx do + assert_metric_dropped(ctx, fn _metric -> exit(:boom) end) + end + end + describe "metric envelope format" do test "metrics include all required fields", ctx do Metrics.count("test.counter", 42, unit: "request", attributes: %{method: "GET"}) @@ -147,4 +162,16 @@ defmodule Sentry.MetricsIntegrationTest do assert metric["span_id"] == transaction["contexts"]["trace"]["span_id"] end end + + defp assert_metric_dropped(ctx, crashing_callback) do + put_test_config(before_send_metric: crashing_callback) + + capture_log(fn -> + Metrics.count("crash.me", 1) + :ok = TelemetryProcessor.flush(ctx.processor) + end) + + assert [] == collect_sentry_metric_items(ctx.ref, 1, timeout: 200) + assert [] == Sentry.Test.pop_sentry_metrics() + end end diff --git a/test/sentry/metrics_test.exs b/test/sentry/metrics_test.exs index 29b52241..2e8a7272 100644 --- a/test/sentry/metrics_test.exs +++ b/test/sentry/metrics_test.exs @@ -1,6 +1,7 @@ defmodule Sentry.MetricsTest do use Sentry.Case, async: true + import ExUnit.CaptureLog import Sentry.TestHelpers alias Sentry.{Metric, Metrics, TelemetryProcessor} @@ -192,44 +193,32 @@ defmodule Sentry.MetricsTest do end describe "before_send_metric error handling" do - test "returns original metric when callback raises" do - callback = fn _metric -> - raise "callback error" - end + test "logs the failure when the callback raises" do + log = capture_callback_failure(fn _metric -> raise "callback error" end) - put_test_config(before_send_metric: callback) + assert log =~ "callback error" + end - import ExUnit.CaptureLog + test "logs the failure when the callback throws" do + log = capture_callback_failure(fn _metric -> throw(:callback_thrown) end) - log = - capture_log([metadata: [:domain]], fn -> - assert :ok = Metrics.count("test.counter", 42) - TelemetryProcessor.flush() - end) + assert log =~ ":callback_thrown" + end - assert log =~ "before_send_metric callback failed" - assert log =~ "callback error" - assert log =~ ~r/domain=(\w+\.)*sentry/ + test "logs the failure when the callback exits" do + log = capture_callback_failure(fn _metric -> exit(:callback_exited) end) + + assert log =~ ":callback_exited" end - test "returns original metric when a {module, function} callback raises" do + test "logs the failure when a {module, function} callback raises" do defmodule RaisingCallback do def before_send_metric(_metric), do: raise("MFA callback error") end - put_test_config(before_send_metric: {RaisingCallback, :before_send_metric}) - - import ExUnit.CaptureLog + log = capture_callback_failure({RaisingCallback, :before_send_metric}) - log = - capture_log([metadata: [:domain]], fn -> - assert :ok = Metrics.count("test.counter", 42) - TelemetryProcessor.flush() - end) - - assert log =~ "before_send_metric callback failed" assert log =~ "MFA callback error" - assert log =~ ~r/domain=(\w+\.)*sentry/ end test "drops metric when callback returns invalid type" do @@ -246,6 +235,20 @@ defmodule Sentry.MetricsTest do TelemetryProcessor.flush() assert_receive :callback_called end + + defp capture_callback_failure(callback) do + put_test_config(before_send_metric: callback) + + log = + capture_log([metadata: [:domain]], fn -> + assert :ok = Metrics.count("test.counter", 42) + TelemetryProcessor.flush() + end) + + assert log =~ ~r/domain=(\w+\.)*sentry \[error\]\s+:before_send_metric callback failed/ + + log + end end describe "edge case values" do diff --git a/test/sentry/telemetry/scheduler_test.exs b/test/sentry/telemetry/scheduler_test.exs index 17d52aee..704d8399 100644 --- a/test/sentry/telemetry/scheduler_test.exs +++ b/test/sentry/telemetry/scheduler_test.exs @@ -272,51 +272,22 @@ defmodule Sentry.Telemetry.SchedulerTest do end describe "before_send_log callback error protection" do - test "callback that raises still allows events to be processed" do - buffers = start_test_buffers(batch_size: 1) - test_pid = self() - - # A broken callback is not a failed send, so :log_level must not demote it. - put_test_config( - log_level: :debug, - before_send_log: fn _log_event -> - raise "boom" - end - ) - - {:ok, pid} = - Scheduler.start_link( - buffers: buffers, - on_envelope: fn envelope -> send(test_pid, {:envelope, envelope}) end, - name: :"test_scheduler_raise_#{System.unique_integer([:positive])}" - ) - - log = - capture_log([metadata: [:domain]], fn -> - Buffer.add(buffers.log, make_log_event("test")) - Scheduler.signal(pid) - - assert_receive {:envelope, envelope}, 500 - # Event passes through unmodified when callback raises - assert [%Sentry.LogBatch{log_events: [%LogEvent{body: "test"}]}] = envelope.items - end) + test "drops the log event when the callback raises" do + assert_log_event_dropped(fn _log_event -> raise "boom" end) + end - assert log =~ ~r/domain=(\w+\.)*sentry \[warning\]\s+before_send_log callback failed/ + test "drops the log event when the callback throws" do + assert_log_event_dropped(fn _log_event -> throw(:boom) end) + end - GenServer.stop(pid) - stop_buffers(buffers) + test "drops the log event when the callback exits" do + assert_log_event_dropped(fn _log_event -> exit(:boom) end) end - test "callback that raises does not crash the Scheduler" do + test "keeps processing log events after callbacks of every crash kind" do buffers = start_test_buffers(batch_size: 1) test_pid = self() - put_test_config( - before_send_log: fn _log_event -> - raise "boom" - end - ) - {:ok, pid} = Scheduler.start_link( buffers: buffers, @@ -325,15 +296,20 @@ defmodule Sentry.Telemetry.SchedulerTest do ) capture_log(fn -> - Buffer.add(buffers.log, make_log_event("first")) - Scheduler.signal(pid) - assert_receive {:envelope, _}, 500 + for crashing_callback <- [ + fn _log_event -> raise "boom" end, + fn _log_event -> throw(:boom) end, + fn _log_event -> exit(:boom) end + ] do + put_test_config(before_send_log: crashing_callback) + Buffer.add(buffers.log, make_log_event("crashing")) + Scheduler.signal(pid) + refute_receive {:envelope, _}, 200 + end end) - # Scheduler is still alive and functional assert Process.alive?(pid) - # Can still process new events put_test_config(before_send_log: fn log_event -> log_event end) Buffer.add(buffers.log, make_log_event("second")) @@ -633,6 +609,35 @@ defmodule Sentry.Telemetry.SchedulerTest do end end + defp assert_log_event_dropped(crashing_callback) do + buffers = start_test_buffers(batch_size: 1) + test_pid = self() + + put_test_config(log_level: :debug, before_send_log: crashing_callback) + + {:ok, pid} = + Scheduler.start_link( + buffers: buffers, + on_envelope: fn envelope -> send(test_pid, {:envelope, envelope}) end, + name: :"test_scheduler_crash_#{System.unique_integer([:positive])}" + ) + + log = + capture_log([metadata: [:domain]], fn -> + Buffer.add(buffers.log, make_log_event("test")) + Scheduler.signal(pid) + + refute_receive {:envelope, _envelope}, 500 + end) + + assert log =~ ~r/domain=(\w+\.)*sentry \[error\]\s+:before_send_log callback failed/ + + assert Process.alive?(pid) + + GenServer.stop(pid) + stop_buffers(buffers) + end + defp receive_envelopes_until_empty(acc \\ []) do receive do {:envelope, envelope} -> From 15c15e54b983ced03d007e4f538a4993dac352e5 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 21 Sep 2026 11:13:10 +0000 Subject: [PATCH 2/4] docs(sentry): cover the log and metric callbacks in crashing callbacks --- lib/sentry.ex | 15 +++++++++------ lib/sentry/config.ex | 6 ++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/sentry.ex b/lib/sentry.ex index 1a7b3e2b..5f150852 100644 --- a/lib/sentry.ex +++ b/lib/sentry.ex @@ -155,12 +155,11 @@ defmodule Sentry do ## Crashing Callbacks - If a `:before_send`, `:after_send_event`, or `:filter` callback raises, throws, or exits, - Sentry catches the failure and logs it at the `:error` level instead of letting it reach - the code that was reporting the event. The log carries the `:sentry` logger domain, so the - SDK never reports its own callback failure as an event. Other configurable callbacks, such - as `:before_send_log` and `:before_send_metric`, handle their own failures and are not - covered by this section. + If a `:before_send`, `:after_send_event`, `:filter`, `:before_send_log`, or + `:before_send_metric` callback raises, throws, or exits, Sentry catches the failure and logs + it at the `:error` level instead of letting it reach the code that was reporting the event. + The log carries the `:sentry` logger domain, so the SDK never reports its own callback + failure as an event. The item being handled is then dropped: @@ -171,6 +170,10 @@ defmodule Sentry do `c:Sentry.EventFilter.exclude_exception?/2` crashes, is treated like one that excluded the exception. `capture_exception/2` returns `:excluded`. + * A `:before_send_log` or `:before_send_metric` callback that crashes is treated like one + that returned `nil`. The log event or metric is not sent, and the rest of the batch it + belongs to is unaffected. + An `:after_send_event` callback runs once the event has already been sent and its return value is ignored, so a crash there changes nothing the caller sees: the send result is still the one the transport produced. diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index 6c1df271..88201b23 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -973,6 +973,9 @@ defmodule Sentry.Config do well as filtering out the log event altogether. If the callback returns `nil` or `false`, the log event is not reported. If it returns a (potentially-updated) `Sentry.LogEvent`, then the updated log event is used instead. + If the callback crashes, the failure is logged at the `:error` level and the log event is + not reported. See the [*Crashing Callbacks*](#module-crashing-callbacks) section below for + more information. *Available since v12.0.0*. """ ], @@ -984,6 +987,9 @@ defmodule Sentry.Config do well as filtering out the metric altogether. If the callback returns `nil` or `false`, the metric is not reported. If it returns a (potentially-updated) `Sentry.Metric`, then the updated metric is used instead. + If the callback crashes, the failure is logged at the `:error` level and the metric is not + reported. See the [*Crashing Callbacks*](#module-crashing-callbacks) section below for more + information. *Available since v13.0.0*. """ ] From e2f33a01c9be3f6158c45c46a3f4e33b5024814f Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 21 Sep 2026 11:20:01 +0000 Subject: [PATCH 3/4] feat(client-reports): count a callback crash as a callback_error outcome --- lib/sentry.ex | 15 ++++++--- lib/sentry/callback.ex | 14 ++++++-- lib/sentry/client.ex | 18 ++++++---- lib/sentry/client_report.ex | 1 + lib/sentry/telemetry/scheduler.ex | 15 +++++++-- test/sentry/metrics_integration_test.exs | 34 +++++++++++++++++-- test/sentry/telemetry/scheduler_test.exs | 43 ++++++++++++++++++++++++ test/sentry_test.exs | 23 +++++++++++++ 8 files changed, 145 insertions(+), 18 deletions(-) diff --git a/lib/sentry.ex b/lib/sentry.ex index 5f150852..1a757f36 100644 --- a/lib/sentry.ex +++ b/lib/sentry.ex @@ -304,11 +304,16 @@ defmodule Sentry do {send_opts, create_event_opts} = Options.split_send_event_options(options) exclude? = - Callback.run( - :filter, - fn -> filter_module.exclude_exception?(exception, event_source) end, - true - ) + case Callback.run(:filter, fn -> + filter_module.exclude_exception?(exception, event_source) + end) do + {:ok, exclude?} -> + exclude? + + :failed -> + ClientReport.Sender.record_discarded_events(:callback_error, "error") + true + end if exclude? do :excluded diff --git a/lib/sentry/callback.ex b/lib/sentry/callback.ex index 9ce2b47f..c1180aaf 100644 --- a/lib/sentry/callback.ex +++ b/lib/sentry/callback.ex @@ -6,8 +6,16 @@ defmodule Sentry.Callback do @type spec() :: (... -> term()) | {module(), atom()} @spec run(atom(), (-> result), result) :: result when result: var - def run(name, fun, fallback) when is_atom(name) and is_function(fun, 0) do - fun.() + def run(name, fun, fallback) do + case run(name, fun) do + {:ok, result} -> result + :failed -> fallback + end + end + + @spec run(atom(), (-> result)) :: {:ok, result} | :failed when result: var + def run(name, fun) when is_atom(name) and is_function(fun, 0) do + {:ok, fun.()} catch kind, reason -> LoggerUtils.error( @@ -15,7 +23,7 @@ defmodule Sentry.Callback do Exception.format(kind, reason, __STACKTRACE__) ) - fallback + :failed end @spec to_fun(atom(), spec(), [term()]) :: (-> term()) diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index ef07bdab..0a0029ca 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -191,16 +191,22 @@ defmodule Sentry.Client do end defp maybe_call_before_send(event, callback) do - if result = call_before_send(event, callback) do - {:ok, result} - else - :excluded + case Callback.run(:before_send, before_send_invocation(event, callback)) do + {:ok, false} -> + :excluded + + {:ok, result} -> + {:ok, result} + + :failed -> + ClientReport.Sender.record_discarded_events(:callback_error, [event]) + :excluded end end - defp call_before_send(event, callback) do + defp before_send_invocation(event, callback) do invocation = Callback.to_fun(:before_send, callback, [event]) - Callback.run(:before_send, fn -> invocation.() || false end, false) + fn -> invocation.() || false end end defp maybe_call_after_send(_event_or_transaction, _result, nil) do diff --git a/lib/sentry/client_report.ex b/lib/sentry/client_report.ex index 2649f37b..d51133de 100644 --- a/lib/sentry/client_report.ex +++ b/lib/sentry/client_report.ex @@ -23,6 +23,7 @@ defmodule Sentry.ClientReport do :network_error, :sample_rate, :before_send, + :callback_error, :event_processor, :insufficient_data, :backpressure, diff --git a/lib/sentry/telemetry/scheduler.ex b/lib/sentry/telemetry/scheduler.ex index 78b2c096..91902cef 100644 --- a/lib/sentry/telemetry/scheduler.ex +++ b/lib/sentry/telemetry/scheduler.ex @@ -382,7 +382,7 @@ defmodule Sentry.Telemetry.Scheduler do end defp call_before_send_log(log_event, callback) do - Callback.run(:before_send_log, invocation(log_event, callback), nil) + run_callback(:before_send_log, log_event, callback) end defp apply_before_send_metric_callbacks(metrics) do @@ -399,7 +399,18 @@ defmodule Sentry.Telemetry.Scheduler do end defp call_before_send_metric(metric, callback) do - Callback.run(:before_send_metric, invocation(metric, callback), nil) + run_callback(:before_send_metric, metric, callback) + end + + defp run_callback(name, item, callback) do + case Callback.run(name, invocation(item, callback)) do + {:ok, result} -> + result + + :failed -> + ClientReport.Sender.record_discarded_events(:callback_error, [item]) + nil + end end defp invocation(item, function) when is_function(function, 1) do diff --git a/test/sentry/metrics_integration_test.exs b/test/sentry/metrics_integration_test.exs index 86e02caa..6e75109e 100644 --- a/test/sentry/metrics_integration_test.exs +++ b/test/sentry/metrics_integration_test.exs @@ -11,13 +11,23 @@ defmodule Sentry.MetricsIntegrationTest do alias Sentry.Telemetry.Buffer setup do - %{bypass: bypass, telemetry_processor: processor_name, ref: ref} = + %{ + bypass: bypass, + telemetry_processor: processor_name, + ref: ref, + client_report_sender: client_report_sender + } = Sentry.Test.setup_sentry( collect_envelopes: true, telemetry_processor: [buffer_configs: %{metric: %{batch_size: 1}}] ) - %{processor: processor_name, ref: ref, bypass: bypass} + %{ + processor: processor_name, + ref: ref, + bypass: bypass, + client_report_sender: client_report_sender + } end describe "metric batching" do @@ -97,6 +107,26 @@ defmodule Sentry.MetricsIntegrationTest do test "drops the metric when the callback exits", ctx do assert_metric_dropped(ctx, fn _metric -> exit(:boom) end) end + + test "records a callback_error outcome for the dropped metric", ctx do + assert_metric_dropped(ctx, fn _metric -> raise "boom" end) + + assert %{ + {:callback_error, "trace_metric"} => 1, + {:callback_error, "trace_metric_byte"} => bytes + } = :sys.get_state(ctx.client_report_sender) + + assert bytes > 0 + end + + test "records no outcome for a metric the callback filters out", ctx do + put_test_config(before_send_metric: fn _metric -> nil end) + + Metrics.count("drop.me", 1) + :ok = TelemetryProcessor.flush(ctx.processor) + + assert :sys.get_state(ctx.client_report_sender) == %{} + end end describe "metric envelope format" do diff --git a/test/sentry/telemetry/scheduler_test.exs b/test/sentry/telemetry/scheduler_test.exs index 704d8399..1ac572dc 100644 --- a/test/sentry/telemetry/scheduler_test.exs +++ b/test/sentry/telemetry/scheduler_test.exs @@ -322,6 +322,25 @@ defmodule Sentry.Telemetry.SchedulerTest do end end + describe "before_send_log client report outcomes" do + test "records a callback_error outcome for a log event dropped by a crashing callback", + %{client_report_sender: sender} do + run_log_event_through(fn _log_event -> raise "boom" end) + + assert %{{:callback_error, "log_item"} => 1, {:callback_error, "log_byte"} => bytes} = + :sys.get_state(sender) + + assert bytes > 0 + end + + test "records no outcome for a log event the callback filters out", + %{client_report_sender: sender} do + run_log_event_through(fn _log_event -> nil end) + + assert :sys.get_state(sender) == %{} + end + end + describe "transport queue capacity" do test "stops processing when transport queue is full" do buffers = start_test_buffers(batch_size: 1) @@ -638,6 +657,30 @@ defmodule Sentry.Telemetry.SchedulerTest do stop_buffers(buffers) end + defp run_log_event_through(callback) do + buffers = start_test_buffers(batch_size: 1) + test_pid = self() + + put_test_config(before_send_log: callback) + + {:ok, pid} = + Scheduler.start_link( + buffers: buffers, + on_envelope: fn envelope -> send(test_pid, {:envelope, envelope}) end, + name: :"test_scheduler_outcome_#{System.unique_integer([:positive])}" + ) + + capture_log(fn -> + Buffer.add(buffers.log, make_log_event("outcome")) + Scheduler.signal(pid) + + refute_receive {:envelope, _envelope}, 500 + end) + + GenServer.stop(pid) + stop_buffers(buffers) + end + defp receive_envelopes_until_empty(acc \\ []) do receive do {:envelope, envelope} -> diff --git a/test/sentry_test.exs b/test/sentry_test.exs index 83279c7a..f7b52628 100644 --- a/test/sentry_test.exs +++ b/test/sentry_test.exs @@ -226,6 +226,17 @@ defmodule SentryTest do assert SentryTest.pop_sentry_reports() == [] end + test "records a callback_error outcome for the dropped event", + %{client_report_sender: sender} do + put_test_config(before_send: fn _event -> raise "before_send is broken" end) + + capture_log(fn -> + assert :excluded = Sentry.capture_message("outcome before_send", result: :sync) + end) + + assert :sys.get_state(sender) == %{{:callback_error, "error"} => 1} + end + test "does not report its own failure back to Sentry" do test_pid = self() ref = make_ref() @@ -315,6 +326,18 @@ defmodule SentryTest do assert log =~ ":filter callback failed" assert SentryTest.pop_sentry_reports() == [] end + + test "records a callback_error outcome for the dropped exception", + %{client_report_sender: sender} do + put_test_config(filter: RaisingFilter) + + capture_log(fn -> + assert :excluded = + Sentry.capture_exception(%RuntimeError{message: "oops"}, result: :sync) + end) + + assert :sys.get_state(sender) == %{{:callback_error, "error"} => 1} + end end describe "send_check_in/1" do From bba6658033b3eb78fac9fdae64c4e6ee26760cce Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 22 Sep 2026 07:15:05 +0000 Subject: [PATCH 4/4] refactor(callback): record discarded callback events from Sentry.Callback --- lib/sentry.ex | 16 ++++++---------- lib/sentry/callback.ex | 20 ++++++++++++++++---- lib/sentry/client.ex | 15 +++++---------- lib/sentry/telemetry/scheduler.ex | 19 +++---------------- 4 files changed, 30 insertions(+), 40 deletions(-) diff --git a/lib/sentry.ex b/lib/sentry.ex index 1a757f36..7824c5cf 100644 --- a/lib/sentry.ex +++ b/lib/sentry.ex @@ -304,16 +304,12 @@ defmodule Sentry do {send_opts, create_event_opts} = Options.split_send_event_options(options) exclude? = - case Callback.run(:filter, fn -> - filter_module.exclude_exception?(exception, event_source) - end) do - {:ok, exclude?} -> - exclude? - - :failed -> - ClientReport.Sender.record_discarded_events(:callback_error, "error") - true - end + Callback.run( + :filter, + fn -> filter_module.exclude_exception?(exception, event_source) end, + true, + discard: {:callback_error, "error"} + ) if exclude? do :excluded diff --git a/lib/sentry/callback.ex b/lib/sentry/callback.ex index c1180aaf..5b264ef2 100644 --- a/lib/sentry/callback.ex +++ b/lib/sentry/callback.ex @@ -1,15 +1,20 @@ defmodule Sentry.Callback do @moduledoc false + alias Sentry.ClientReport alias Sentry.LoggerUtils @type spec() :: (... -> term()) | {module(), atom()} - @spec run(atom(), (-> result), result) :: result when result: var - def run(name, fun, fallback) do + @spec run(atom(), (-> result), result, keyword()) :: result when result: var + def run(name, fun, fallback, opts \\ []) when is_list(opts) do case run(name, fun) do - {:ok, result} -> result - :failed -> fallback + {:ok, result} -> + result + + :failed -> + record_discard(Keyword.get(opts, :discard)) + fallback end end @@ -41,4 +46,11 @@ defmodule Sentry.Callback do "got: #{inspect(other)}" end end + + defp record_discard(nil), do: :ok + + defp record_discard({reason, event_or_data_category}) do + _ = ClientReport.Sender.record_discarded_events(reason, event_or_data_category) + :ok + end end diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index 0a0029ca..d7a42022 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -191,16 +191,11 @@ defmodule Sentry.Client do end defp maybe_call_before_send(event, callback) do - case Callback.run(:before_send, before_send_invocation(event, callback)) do - {:ok, false} -> - :excluded - - {:ok, result} -> - {:ok, result} - - :failed -> - ClientReport.Sender.record_discarded_events(:callback_error, [event]) - :excluded + case Callback.run(:before_send, before_send_invocation(event, callback), false, + discard: {:callback_error, [event]} + ) do + false -> :excluded + result -> {:ok, result} end end diff --git a/lib/sentry/telemetry/scheduler.ex b/lib/sentry/telemetry/scheduler.ex index 91902cef..91027e69 100644 --- a/lib/sentry/telemetry/scheduler.ex +++ b/lib/sentry/telemetry/scheduler.ex @@ -403,22 +403,9 @@ defmodule Sentry.Telemetry.Scheduler do end defp run_callback(name, item, callback) do - case Callback.run(name, invocation(item, callback)) do - {:ok, result} -> - result - - :failed -> - ClientReport.Sender.record_discarded_events(:callback_error, [item]) - nil - end - end - - defp invocation(item, function) when is_function(function, 1) do - fn -> function.(item) end - end - - defp invocation(item, {mod, fun}) do - fn -> apply(mod, fun, [item]) end + Callback.run(name, Callback.to_fun(name, callback, [item]), nil, + discard: {:callback_error, [item]} + ) end defp advance_cycle(%Scheduler{} = state) do