diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index 79588767..a3100335 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -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 diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index ebbb45f4..eaa6bb29 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -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/) diff --git a/lib/sentry/integrations/oban/error_reporter.ex b/lib/sentry/integrations/oban/error_reporter.ex new file mode 100644 index 00000000..4a4fcaa4 --- /dev/null +++ b/lib/sentry/integrations/oban/error_reporter.ex @@ -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 diff --git a/pages/oban-integration.md b/pages/oban-integration.md index 6561fb66..cdcaaf8e 100644 --- a/pages/oban-integration.md +++ b/pages/oban-integration.md @@ -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: diff --git a/test/sentry/integrations/oban/error_reporter_test.exs b/test/sentry/integrations/oban/error_reporter_test.exs new file mode 100644 index 00000000..50013670 --- /dev/null +++ b/test/sentry/integrations/oban/error_reporter_test.exs @@ -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