Skip to content

Commit 9d05023

Browse files
committed
feat(oban): add :skip_retries_callback option
The callback receives the worker module and job struct, allowing for per-worker or per-job decision logic.
1 parent 9ab7fa9 commit 9d05023

4 files changed

Lines changed: 180 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_retries_callback: [
82+
type: {:custom, __MODULE__, :__validate_skip_retries_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_retries_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_retries_callback__(nil), do: {:ok, nil}
1088+
1089+
def __validate_skip_retries_callback__(fun) when is_function(fun, 2) do
1090+
{:ok, fun}
1091+
end
1092+
1093+
def __validate_skip_retries_callback__(other) do
1094+
{:error,
1095+
"expected :skip_retries_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: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,39 @@ 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_retries_callback) do
43+
callback when is_function(callback, 2) ->
44+
not call_skip_retries_callback(callback, job)
45+
46+
_ ->
47+
true
48+
end
49+
end
50+
51+
defp call_skip_retries_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("skip_retries_callback failed: #{inspect(error)}")
63+
false
64+
end
65+
end
66+
4167
defp report(job, kind, reason, stacktrace, config) do
4268
stacktrace =
4369
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_retries_callback configuration validation" do
64+
test "accepts nil" do
65+
assert :ok = put_test_config(integrations: [oban: [skip_retries_callback: nil]])
66+
assert Sentry.Config.integrations()[:oban][:skip_retries_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_retries_callback: fun]])
72+
assert Sentry.Config.integrations()[:oban][:skip_retries_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_retries_callback to be/, fn ->
79+
put_test_config(integrations: [oban: [skip_retries_callback: fun]])
80+
end
81+
end
82+
83+
test "rejects invalid types" do
84+
assert_raise ArgumentError, ~r/expected :skip_retries_callback to be/, fn ->
85+
put_test_config(integrations: [oban: [skip_retries_callback: "invalid"]])
86+
end
87+
88+
assert_raise ArgumentError, ~r/expected :skip_retries_callback to be/, fn ->
89+
put_test_config(integrations: [oban: [skip_retries_callback: 123]])
90+
end
91+
92+
assert_raise ArgumentError, ~r/expected :skip_retries_callback to be/, fn ->
93+
put_test_config(integrations: [oban: [skip_retries_callback: []]])
94+
end
95+
end
96+
end
6297
end

test/sentry/integrations/oban/error_reporter_test.exs

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

215303
## Helpers

0 commit comments

Comments
 (0)