Skip to content

Commit 759ed98

Browse files
authored
Improve Oban error reporting (#872)
1 parent df0079f commit 759ed98

File tree

2 files changed

+113
-58
lines changed

2 files changed

+113
-58
lines changed

lib/sentry/integrations/oban/error_reporter.ex

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,39 +26,61 @@ defmodule Sentry.Integrations.Oban.ErrorReporter do
2626
def handle_event([:oban, :job, :exception], _measurements, %{job: job} = _metadata, :no_config) do
2727
%{reason: reason, stacktrace: stacktrace} = job.unsaved_error
2828

29+
if report?(reason) do
30+
report(job, reason, stacktrace)
31+
else
32+
:ok
33+
end
34+
end
35+
36+
defp report(job, reason, stacktrace) do
2937
stacktrace =
3038
case {apply(Oban.Worker, :from_string, [job.worker]), stacktrace} do
3139
{{:ok, atom_worker}, []} -> [{atom_worker, :process, 1, []}]
3240
_ -> stacktrace
3341
end
3442

35-
fingerprint_opts =
36-
if is_exception(reason) do
37-
[inspect(reason.__struct__), Exception.message(reason)]
38-
else
39-
[inspect(reason)]
40-
end
41-
4243
opts =
4344
[
4445
stacktrace: stacktrace,
4546
tags: %{oban_worker: job.worker, oban_queue: job.queue, oban_state: job.state},
46-
fingerprint: [job.worker] ++ fingerprint_opts,
47+
fingerprint: [job.worker, "{{ default }}"],
4748
extra:
4849
Map.take(job, [:args, :attempt, :id, :max_attempts, :meta, :queue, :tags, :worker]),
4950
integration_meta: %{oban: %{job: job}}
5051
]
5152

5253
_ =
53-
if is_exception(reason) do
54-
Sentry.capture_exception(reason, opts)
55-
else
56-
Sentry.capture_message(
57-
"Oban job #{job.worker} errored out: %s",
58-
opts ++ [interpolation_parameters: [inspect(reason)]]
59-
)
54+
case maybe_unwrap_exception(reason) do
55+
exception when is_exception(exception) ->
56+
Sentry.capture_exception(exception, opts)
57+
58+
_other ->
59+
Sentry.capture_message(
60+
"Oban job #{job.worker} errored out: %s",
61+
opts ++ [interpolation_parameters: [inspect(reason)]]
62+
)
6063
end
6164

6265
:ok
6366
end
67+
68+
# Oban.PerformError also wraps {:discard, _} and {:cancel, _} tuples, but those are
69+
# not *errors* and should not be reported to Sentry automatically.
70+
defp report?(%{reason: {type, _reason}} = error) when is_exception(error, Oban.PerformError) do
71+
type == :error
72+
end
73+
74+
defp report?(_error) do
75+
true
76+
end
77+
78+
defp maybe_unwrap_exception(%{reason: {:error, error}} = perform_error)
79+
when is_exception(perform_error, Oban.PerformError) and is_exception(error) do
80+
error
81+
end
82+
83+
defp maybe_unwrap_exception(reason) do
84+
reason
85+
end
6486
end

test/sentry/integrations/oban/error_reporter_test.exs

Lines changed: 76 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,44 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do
1010
def perform(%Oban.Job{}), do: :ok
1111
end
1212

13+
@worker_as_string "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker"
14+
1315
describe "handle_event/4" do
1416
test "reports the correct error to Sentry" do
15-
# Any worker is okay here, this is just an easier way to get a job struct.
16-
job =
17-
%{"id" => "123", "entity" => "user", "type" => "delete"}
18-
|> MyWorker.new()
19-
|> Ecto.Changeset.apply_action!(:validate)
20-
|> Map.replace!(:unsaved_error, %{
21-
reason: %RuntimeError{message: "oops"},
22-
kind: :error,
23-
stacktrace: []
24-
})
17+
Sentry.Test.start_collecting()
18+
19+
emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [])
20+
21+
assert [event] = Sentry.Test.pop_sentry_reports()
22+
assert event.original_exception == %RuntimeError{message: "oops"}
23+
assert [%{stacktrace: %{frames: [stacktrace]}} = exception] = event.exception
24+
25+
assert exception.type == "RuntimeError"
26+
assert exception.value == "oops"
27+
assert exception.mechanism.handled == true
28+
assert stacktrace.module == MyWorker
29+
30+
assert stacktrace.function ==
31+
"Sentry.Integrations.Oban.ErrorReporterTest.MyWorker.process/1"
32+
33+
assert event.tags.oban_queue == "default"
34+
assert event.tags.oban_state == "available"
35+
assert event.tags.oban_worker == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker"
36+
assert %{job: %Oban.Job{}} = event.integration_meta.oban
2537

38+
assert event.fingerprint == [@worker_as_string, "{{ default }}"]
39+
end
40+
41+
test "unwraps Oban.PerformErrors and reports the wrapped error" do
2642
Sentry.Test.start_collecting()
2743

28-
assert :ok =
29-
ErrorReporter.handle_event(
30-
[:oban, :job, :exception],
31-
%{},
32-
%{job: job},
33-
:no_config
34-
)
44+
emit_telemetry_for_failed_job(
45+
:error,
46+
%Oban.PerformError{
47+
reason: {:error, %RuntimeError{message: "oops"}}
48+
},
49+
[]
50+
)
3551

3652
assert [event] = Sentry.Test.pop_sentry_reports()
3753
assert event.original_exception == %RuntimeError{message: "oops"}
@@ -49,50 +65,67 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do
4965
assert event.tags.oban_state == "available"
5066
assert event.tags.oban_worker == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker"
5167
assert %{job: %Oban.Job{}} = event.integration_meta.oban
68+
69+
assert event.fingerprint == [@worker_as_string, "{{ default }}"]
5270
end
5371

5472
test "reports non-exception errors to Sentry" do
55-
job =
56-
%{"id" => "123", "entity" => "user", "type" => "delete"}
57-
|> MyWorker.new()
58-
|> Ecto.Changeset.apply_action!(:validate)
59-
|> Map.replace!(:unsaved_error, %{
60-
reason: :undef,
61-
kind: :error,
62-
stacktrace: []
63-
})
64-
6573
Sentry.Test.start_collecting()
6674

67-
assert :ok =
68-
ErrorReporter.handle_event(
69-
[:oban, :job, :exception],
70-
%{},
71-
%{job: job},
72-
:no_config
73-
)
75+
emit_telemetry_for_failed_job(:error, :undef, [])
7476

7577
assert [event] = Sentry.Test.pop_sentry_reports()
7678
assert %{job: %Oban.Job{}} = event.integration_meta.oban
7779

7880
assert event.message == %Sentry.Interfaces.Message{
79-
formatted:
80-
"Oban job Sentry.Integrations.Oban.ErrorReporterTest.MyWorker errored out: :undef",
81-
message:
82-
"Oban job Sentry.Integrations.Oban.ErrorReporterTest.MyWorker errored out: %s",
81+
formatted: "Oban job #{@worker_as_string} errored out: :undef",
82+
message: "Oban job #{@worker_as_string} errored out: %s",
8383
params: [":undef"]
8484
}
8585

8686
assert [%Sentry.Interfaces.Thread{stacktrace: %{frames: [stacktrace]}}] = event.threads
87-
8887
assert stacktrace.module == MyWorker
89-
90-
assert stacktrace.function ==
91-
"Sentry.Integrations.Oban.ErrorReporterTest.MyWorker.process/1"
88+
assert stacktrace.function == "#{@worker_as_string}.process/1"
9289

9390
assert event.tags.oban_queue == "default"
9491
assert event.tags.oban_state == "available"
95-
assert event.tags.oban_worker == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker"
92+
assert event.tags.oban_worker == @worker_as_string
93+
94+
assert event.fingerprint == [@worker_as_string, "{{ default }}"]
9695
end
96+
97+
for reason <- [:cancel, :discard] do
98+
test "doesn't report Oban.PerformError with reason #{inspect(reason)}" do
99+
Sentry.Test.start_collecting()
100+
101+
emit_telemetry_for_failed_job(
102+
:error,
103+
%Oban.PerformError{reason: {unquote(reason), "nah"}},
104+
[]
105+
)
106+
107+
assert Sentry.Test.pop_sentry_reports() == []
108+
end
109+
end
110+
end
111+
112+
## Helpers
113+
114+
defp emit_telemetry_for_failed_job(kind, reason, stacktrace) do
115+
job =
116+
%{"id" => "123", "entity" => "user", "type" => "delete"}
117+
|> MyWorker.new()
118+
|> Ecto.Changeset.apply_action!(:validate)
119+
|> Map.replace!(:unsaved_error, %{kind: kind, reason: reason, stacktrace: stacktrace})
120+
121+
assert :ok =
122+
ErrorReporter.handle_event(
123+
[:oban, :job, :exception],
124+
%{},
125+
%{job: job},
126+
:no_config
127+
)
128+
129+
job
97130
end
98131
end

0 commit comments

Comments
 (0)