Skip to content

Commit 25deb1f

Browse files
committed
feat(oban): add :skip_error_report_callback option
The callback receives the worker module and job struct, allowing for per-worker or per-job decision logic on whether to skip error reporting.
1 parent 7a823c9 commit 25deb1f

4 files changed

Lines changed: 193 additions & 1 deletion

File tree

lib/sentry/config.ex

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,25 @@ defmodule Sentry.Config do
7878
with `oban_tags.` and with a value of `true`. *Available since 12.0.0*.
7979
"""
8080
],
81+
skip_error_report_callback: [
82+
type: {:custom, __MODULE__, :__validate_skip_error_report_callback__, []},
83+
default: nil,
84+
type_doc: "`(module(), Oban.Job.t() -> boolean())` or `nil`",
85+
doc: """
86+
A function that determines whether to skip reporting errors for Oban job retries.
87+
The function receives the worker module and the `Oban.Job` struct and should return
88+
`true` to skip reporting or `false` to report the error.
89+
90+
```elixir
91+
skip_error_report_callback: fn _worker, job ->
92+
job.attempt < job.max_attempts
93+
end
94+
```
95+
96+
This example skips reporting errors for all non-final retry attempts.
97+
*Available since 12.0.0*.
98+
"""
99+
],
81100
cron: [
82101
doc: """
83102
Configuration options for configuring [*crons*](https://docs.sentry.io/product/crons/)
@@ -1064,4 +1083,15 @@ defmodule Sentry.Config do
10641083
{:error,
10651084
"expected :oban_tags_to_sentry_tags to be nil, a function with arity 1, or a {module, function} tuple, got: #{inspect(other)}"}
10661085
end
1086+
1087+
def __validate_skip_error_report_callback__(nil), do: {:ok, nil}
1088+
1089+
def __validate_skip_error_report_callback__(fun) when is_function(fun, 2) do
1090+
{:ok, fun}
1091+
end
1092+
1093+
def __validate_skip_error_report_callback__(other) do
1094+
{:error,
1095+
"expected :skip_error_report_callback to be nil or a function with arity 2, got: #{inspect(other)}"}
1096+
end
10671097
end

lib/sentry/integrations/oban/error_reporter.ex

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,43 @@ defmodule Sentry.Integrations.Oban.ErrorReporter do
3131
%{job: job, kind: kind, reason: reason, stacktrace: stacktrace} = _metadata,
3232
config
3333
) do
34-
if report?(reason) do
34+
if report?(reason) and should_report?(job, config) do
3535
report(job, kind, reason, stacktrace, config)
3636
else
3737
:ok
3838
end
3939
end
4040

41+
defp should_report?(job, config) do
42+
case Keyword.get(config, :skip_error_report_callback) do
43+
callback when is_function(callback, 2) ->
44+
not call_skip_error_report_callback(callback, job)
45+
46+
_ ->
47+
true
48+
end
49+
end
50+
51+
defp call_skip_error_report_callback(callback, job) do
52+
worker =
53+
case apply(Oban.Worker, :from_string, [job.worker]) do
54+
{:ok, mod} -> mod
55+
:error -> nil
56+
end
57+
58+
try do
59+
callback.(worker, job) == true
60+
rescue
61+
error ->
62+
Logger.warning(
63+
"skip_error_report_callback failed for worker #{inspect(worker)} " <>
64+
"(job ID #{job.id}): #{inspect(error)}"
65+
)
66+
67+
false
68+
end
69+
end
70+
4171
defp report(job, kind, reason, stacktrace, config) do
4272
stacktrace =
4373
case {apply(Oban.Worker, :from_string, [job.worker]), stacktrace} do

test/sentry/config_oban_tags_to_sentry_tags_test.exs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,39 @@ defmodule Sentry.ConfigObanTagsToSentryTagsTest do
5959
end
6060
end
6161
end
62+
63+
describe "skip_error_report_callback configuration validation" do
64+
test "accepts nil" do
65+
assert :ok = put_test_config(integrations: [oban: [skip_error_report_callback: nil]])
66+
assert Sentry.Config.integrations()[:oban][:skip_error_report_callback] == nil
67+
end
68+
69+
test "accepts function with arity 2" do
70+
fun = fn _worker, _job -> true end
71+
assert :ok = put_test_config(integrations: [oban: [skip_error_report_callback: fun]])
72+
assert Sentry.Config.integrations()[:oban][:skip_error_report_callback] == fun
73+
end
74+
75+
test "rejects function with wrong arity" do
76+
fun = fn _job -> true end
77+
78+
assert_raise ArgumentError, ~r/expected :skip_error_report_callback to be/, fn ->
79+
put_test_config(integrations: [oban: [skip_error_report_callback: fun]])
80+
end
81+
end
82+
83+
test "rejects invalid types" do
84+
assert_raise ArgumentError, ~r/expected :skip_error_report_callback to be/, fn ->
85+
put_test_config(integrations: [oban: [skip_error_report_callback: "invalid"]])
86+
end
87+
88+
assert_raise ArgumentError, ~r/expected :skip_error_report_callback to be/, fn ->
89+
put_test_config(integrations: [oban: [skip_error_report_callback: 123]])
90+
end
91+
92+
assert_raise ArgumentError, ~r/expected :skip_error_report_callback to be/, fn ->
93+
put_test_config(integrations: [oban: [skip_error_report_callback: []]])
94+
end
95+
end
96+
end
6297
end

test/sentry/integrations/oban/error_reporter_test.exs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
defmodule Sentry.Integrations.Oban.ErrorReporterTest do
22
use ExUnit.Case, async: true
33

4+
import ExUnit.CaptureLog
5+
46
alias Sentry.Integrations.Oban.ErrorReporter
57

68
defmodule MyWorker do
@@ -210,6 +212,101 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do
210212
assert [event] = Sentry.Test.pop_sentry_reports()
211213
assert event.tags.custom_tag == "custom_value"
212214
end
215+
216+
test "skip_error_report_callback skips when callback returns true" do
217+
job =
218+
%{"id" => "123", "entity" => "user", "type" => "delete"}
219+
|> MyWorker.new()
220+
|> Ecto.Changeset.apply_action!(:validate)
221+
222+
reason = %RuntimeError{message: "oops"}
223+
224+
Sentry.Test.start_collecting()
225+
226+
job_attempt_1 = Map.merge(job, %{attempt: 1, max_attempts: 3})
227+
228+
# Callback returns true -> skip reporting
229+
assert :ok =
230+
ErrorReporter.handle_event(
231+
[:oban, :job, :exception],
232+
%{},
233+
%{job: job_attempt_1, kind: :error, reason: reason, stacktrace: []},
234+
skip_error_report_callback: fn _worker, job -> job.attempt < job.max_attempts end
235+
)
236+
237+
assert [] = Sentry.Test.pop_sentry_reports()
238+
239+
# Final attempt: callback returns false -> report
240+
job_attempt_3 = Map.merge(job, %{attempt: 3, max_attempts: 3})
241+
242+
assert :ok =
243+
ErrorReporter.handle_event(
244+
[:oban, :job, :exception],
245+
%{},
246+
%{job: job_attempt_3, kind: :error, reason: reason, stacktrace: []},
247+
skip_error_report_callback: fn _worker, job -> job.attempt < job.max_attempts end
248+
)
249+
250+
assert [event] = Sentry.Test.pop_sentry_reports()
251+
assert event.original_exception == %RuntimeError{message: "oops"}
252+
assert event.tags.oban_worker == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker"
253+
end
254+
255+
test "skip_error_report_callback receives worker module and job" do
256+
job =
257+
%{"id" => "123", "entity" => "user", "type" => "delete"}
258+
|> MyWorker.new()
259+
|> Ecto.Changeset.apply_action!(:validate)
260+
261+
reason = %RuntimeError{message: "oops"}
262+
test_pid = self()
263+
264+
Sentry.Test.start_collecting()
265+
266+
assert :ok =
267+
ErrorReporter.handle_event(
268+
[:oban, :job, :exception],
269+
%{},
270+
%{job: job, kind: :error, reason: reason, stacktrace: []},
271+
skip_error_report_callback: fn worker, received_job ->
272+
send(test_pid, {:callback_args, worker, received_job})
273+
false
274+
end
275+
)
276+
277+
assert_receive {:callback_args, worker, received_job}
278+
assert worker == MyWorker
279+
assert received_job == job
280+
end
281+
282+
test "skip_error_report_callback reports when callback returns false" do
283+
Sentry.Test.start_collecting()
284+
285+
emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [],
286+
skip_error_report_callback: fn _worker, _job -> false end
287+
)
288+
289+
assert [event] = Sentry.Test.pop_sentry_reports()
290+
assert event.original_exception == %RuntimeError{message: "oops"}
291+
end
292+
293+
test "skip_error_report_callback handles errors gracefully and defaults to reporting" do
294+
Sentry.Test.start_collecting()
295+
296+
log =
297+
capture_log(fn ->
298+
emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [],
299+
skip_error_report_callback: fn _worker, _job -> raise "callback error" end
300+
)
301+
end)
302+
303+
assert log =~ "skip_error_report_callback failed"
304+
assert log =~ "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker"
305+
assert log =~ "callback error"
306+
307+
assert [event] = Sentry.Test.pop_sentry_reports()
308+
assert event.original_exception == %RuntimeError{message: "oops"}
309+
end
213310
end
214311

215312
## Helpers

0 commit comments

Comments
 (0)