Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions lib/sentry/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ defmodule Sentry.Application do
Sentry.Cron.Oban.attach_telemetry_handler()
end

if config[:oban][:capture_errors] do
Sentry.Integrations.Oban.ErrorReporter.attach()
end

if config[:quantum][:cron][:enabled] do
Sentry.Cron.Quantum.attach_telemetry_handler()
end
Expand Down
9 changes: 9 additions & 0 deletions lib/sentry/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ defmodule Sentry.Config do
since v10.2.0*.
""",
keys: [
capture_errors: [
type: :boolean,
default: false,
doc: """
Whether to capture errors from Oban jobs. When enabled, the Sentry SDK will capture
errors that happen in Oban jobs, including when errors return `{:error, reason}`
tuples. *Available since 10.3.0*.
"""
],
cron: [
doc: """
Configuration options for configuring [*crons*](https://docs.sentry.io/product/crons/)
Expand Down
50 changes: 50 additions & 0 deletions lib/sentry/integrations/oban/error_reporter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
defmodule Sentry.Integrations.Oban.ErrorReporter do
@moduledoc false

# See this blog post:
# https://getoban.pro/articles/enhancing-error-reporting

@spec attach() :: :ok
def attach do
_ =
:telemetry.attach(
__MODULE__,
[:oban, :job, :exception],
&__MODULE__.handle_event/4,
:no_config
)

:ok
end

@spec handle_event(
[atom(), ...],
term(),
%{required(:job) => struct(), optional(term()) => term()},
:no_config
) :: :ok
def handle_event([:oban, :job, :exception], _measurements, %{job: job} = _metadata, :no_config) do
oban_worker_mod = Oban.Worker
%{reason: exception, stacktrace: stacktrace} = job.unsaved_error

stacktrace =
case {oban_worker_mod.from_string(job.worker), stacktrace} do
{{:ok, atom_worker}, []} -> [{atom_worker, :process, 1, []}]
_ -> stacktrace
end

_ =
Sentry.capture_exception(exception,
stacktrace: stacktrace,
tags: %{oban_worker: job.worker, oban_queue: job.queue, oban_state: job.state},
fingerprint: [
inspect(exception.__struct__),
inspect(job.worker),
Exception.message(exception)
],
extra: Map.take(job, [:args, :attempt, :id, :max_attempts, :meta, :queue, :tags, :worker])
)

:ok
end
end
17 changes: 17 additions & 0 deletions pages/oban-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ The Oban integration is available since *v10.2.0* of the Sentry SDK, and it requ
1. Oban version 2.17.6 or greater.
1. Elixir 1.13 or later, since that is required by Oban itself.

## Automatic Error Capturing

*Available since 10.3.0*.

You can enable automatic capturing of errors that happen in Oban jobs. This includes jobs that return `{:error, reason}`, raise an exception, exit, and so on.

To enable support:

```elixir
config :sentry,
integrations: [
oban: [
capture_errors: true
]
]
```

## Cron Support

To enable support for monitoring Oban jobs via [Sentry Cron](https://docs.sentry.io/product/crons/), make sure the following `:oban` configuration is in your Sentry configuration:
Expand Down
56 changes: 56 additions & 0 deletions test/sentry/integrations/oban/error_reporter_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# TODO: Oban requires Elixir 1.13+, remove this once we depend on that too.
if Version.match?(System.version(), "~> 1.13") do
defmodule Sentry.Integrations.Oban.ErrorReporterTest do
use ExUnit.Case, async: true

alias Sentry.Integrations.Oban.ErrorReporter

defmodule MyWorker do
use Oban.Worker

@impl Oban.Worker
def perform(%Oban.Job{}), do: :ok
end

describe "handle_event/4" do
test "reports the correct error to Sentry" do
# Any worker is okay here, this is just an easier way to get a job struct.
job =
%{"id" => "123", "entity" => "user", "type" => "delete"}
|> MyWorker.new()
|> Ecto.Changeset.apply_action!(:validate)
|> Map.replace!(:unsaved_error, %{
reason: %RuntimeError{message: "oops"},
kind: :error,
stacktrace: []
})

Sentry.Test.start_collecting()

assert :ok =
ErrorReporter.handle_event(
[:oban, :job, :exception],
%{},
%{job: job},
:no_config
)

assert [event] = Sentry.Test.pop_sentry_reports()
assert event.original_exception == %RuntimeError{message: "oops"}
assert [%{stacktrace: %{frames: [stacktrace]}} = exception] = event.exception

assert exception.type == "RuntimeError"
assert exception.value == "oops"
assert exception.mechanism.handled == true
assert stacktrace.module == MyWorker

assert stacktrace.function ==
"Sentry.Integrations.Oban.ErrorReporterTest.MyWorker.process/1"

assert event.tags.oban_queue == "default"
assert event.tags.oban_state == "available"
assert event.tags.oban_worker == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker"
end
end
end
end