diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index 77c56da7..04593b78 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -79,7 +79,7 @@ defmodule Sentry.Application do end if config[:oban][:capture_errors] do - Sentry.Integrations.Oban.ErrorReporter.attach() + Sentry.Integrations.Oban.ErrorReporter.attach(config[:oban]) end if config[:quantum][:cron][:enabled] do diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index 5b1f88f8..9fc8cd2c 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -8,6 +8,13 @@ defmodule Sentry.Config do """ @type traces_sampler_function :: (map() -> boolean() | float()) | {module(), atom()} + @typedoc """ + A function that transforms an Oban job into a map of Sentry tags. + + The function receives an Oban job struct and should return a map of tags and their values to be added to Sentry reported error. + """ + @type oban_tags_to_sentry_tags_function :: (map() -> map()) | {module(), atom()} + integrations_schema = [ max_expected_check_in_time: [ type: :integer, @@ -51,6 +58,24 @@ defmodule Sentry.Config do tuples. *Available since 10.3.0*. """ ], + oban_tags_to_sentry_tags: [ + type: {:custom, __MODULE__, :__validate_oban_tags_to_sentry_tags__, []}, + default: nil, + type_doc: "`t:oban_tags_to_sentry_tags_function/0` or `nil`", + doc: """ + A function that determines the Sentry tags to be added based on the Oban job. This function receives an Oban.Job struct + and should return a map of tags and their values to be sent to Sentry. + + Example: + ```elixir + oban_tags_to_sentry_tags: fn job -> + Map.new(job.tags, fn tag -> {"oban_tags.\#{tag}", true} end) + end + ``` + + This example transforms all Oban job tags into Sentry tags prefixed with "oban_tags." and with a value of "true". + """ + ], cron: [ doc: """ Configuration options for configuring [*crons*](https://docs.sentry.io/product/crons/) @@ -956,4 +981,24 @@ defmodule Sentry.Config do def __validate_source_code_exclude_pattern__(term) do {:error, "expected a Regex or a string pattern, got: #{inspect(term)}"} end + + def __validate_oban_tags_to_sentry_tags__(nil), do: {:ok, nil} + + def __validate_oban_tags_to_sentry_tags__(fun) when is_function(fun, 1) do + {:ok, fun} + end + + def __validate_oban_tags_to_sentry_tags__({module, function}) + when is_atom(module) and is_atom(function) do + if function_exported?(module, function, 1) do + {:ok, {module, function}} + else + {:error, "function #{module}.#{function}/1 is not exported"} + end + end + + def __validate_oban_tags_to_sentry_tags__(other) do + {:error, + "expected :oban_tags_to_sentry_tags to be nil, a function with arity 1, or a {module, function} tuple, got: #{inspect(other)}"} + end end diff --git a/lib/sentry/integrations/oban/error_reporter.ex b/lib/sentry/integrations/oban/error_reporter.ex index 3a11ba0a..de4e8a73 100644 --- a/lib/sentry/integrations/oban/error_reporter.ex +++ b/lib/sentry/integrations/oban/error_reporter.ex @@ -4,14 +4,16 @@ defmodule Sentry.Integrations.Oban.ErrorReporter do # See this blog post: # https://getoban.pro/articles/enhancing-error-reporting - @spec attach() :: :ok - def attach do + require Logger + + @spec attach(keyword()) :: :ok + def attach(config \\ []) when is_list(config) do _ = :telemetry.attach( __MODULE__, [:oban, :job, :exception], &__MODULE__.handle_event/4, - :no_config + config ) :ok @@ -21,32 +23,36 @@ defmodule Sentry.Integrations.Oban.ErrorReporter do [atom(), ...], term(), %{required(:job) => struct(), optional(term()) => term()}, - :no_config + keyword() ) :: :ok def handle_event( [:oban, :job, :exception], _measurements, %{job: job, kind: kind, reason: reason, stacktrace: stacktrace} = _metadata, - :no_config + config ) do if report?(reason) do - report(job, kind, reason, stacktrace) + report(job, kind, reason, stacktrace, config) else :ok end end - defp report(job, kind, reason, stacktrace) do + defp report(job, kind, reason, stacktrace, config) do stacktrace = case {apply(Oban.Worker, :from_string, [job.worker]), stacktrace} do {{:ok, atom_worker}, []} -> [{atom_worker, :process, 1, []}] _ -> stacktrace end + base_tags = %{oban_worker: job.worker, oban_queue: job.queue, oban_state: job.state} + + tags = merge_oban_tags(base_tags, config[:oban_tags_to_sentry_tags], job) + opts = [ stacktrace: stacktrace, - tags: %{oban_worker: job.worker, oban_queue: job.queue, oban_state: job.state}, + tags: tags, fingerprint: [job.worker, "{{ default }}"], extra: Map.take(job, [:args, :attempt, :id, :max_attempts, :meta, :queue, :tags, :worker]), @@ -94,4 +100,35 @@ defmodule Sentry.Integrations.Oban.ErrorReporter do defp maybe_unwrap_exception(kind, reason, stacktrace) do Exception.normalize(kind, reason, stacktrace) end + + defp merge_oban_tags(base_tags, nil, _job), do: base_tags + + defp merge_oban_tags(base_tags, tags_config, job) do + try do + custom_tags = call_oban_tags_to_sentry_tags(tags_config, job) + + if is_map(custom_tags) do + Map.merge(base_tags, custom_tags) + else + Logger.warning( + "oban_tags_to_sentry_tags function returned a non-map value: #{inspect(custom_tags)}" + ) + + base_tags + end + rescue + error -> + Logger.warning("oban_tags_to_sentry_tags function failed: #{inspect(error)}") + + base_tags + end + end + + defp call_oban_tags_to_sentry_tags(fun, job) when is_function(fun, 1) do + fun.(job) + end + + defp call_oban_tags_to_sentry_tags({module, function}, job) do + apply(module, function, [job]) + end end diff --git a/test/sentry/config_oban_tags_to_sentry_tags_test.exs b/test/sentry/config_oban_tags_to_sentry_tags_test.exs new file mode 100644 index 00000000..5be75111 --- /dev/null +++ b/test/sentry/config_oban_tags_to_sentry_tags_test.exs @@ -0,0 +1,62 @@ +defmodule Sentry.ConfigObanTagsToSentryTagsTest do + use ExUnit.Case, async: false + + import Sentry.TestHelpers + + describe "oban_tags_to_sentry_tags configuration validation" do + defmodule TestTagsTransform do + def transform(_job), do: %{"one_tag" => "one_tag_value"} + end + + test "accepts nil" do + assert :ok = put_test_config(integrations: [oban: [oban_tags_to_sentry_tags: nil]]) + assert Sentry.Config.integrations()[:oban][:oban_tags_to_sentry_tags] == nil + end + + test "accepts function with arity 1" do + fun = fn _job -> [] end + assert :ok = put_test_config(integrations: [oban: [oban_tags_to_sentry_tags: fun]]) + assert Sentry.Config.integrations()[:oban][:oban_tags_to_sentry_tags] == fun + end + + test "accepts MFA tuple with exported function" do + assert :ok = + put_test_config( + integrations: [oban: [oban_tags_to_sentry_tags: {TestTagsTransform, :transform}]] + ) + + assert Sentry.Config.integrations()[:oban][:oban_tags_to_sentry_tags] == + {TestTagsTransform, :transform} + end + + test "rejects MFA tuple with non-exported function" do + assert_raise ArgumentError, ~r/function.*is not exported/, fn -> + put_test_config( + integrations: [oban: [oban_tags_to_sentry_tags: {TestTagsTransform, :non_existent}]] + ) + end + end + + test "rejects function with wrong arity" do + fun = fn -> ["one_tag"] end + + assert_raise ArgumentError, ~r/expected :oban_tags_to_sentry_tags to be/, fn -> + put_test_config(integrations: [oban: [oban_tags_to_sentry_tags: fun]]) + end + end + + test "rejects invalid types" do + assert_raise ArgumentError, ~r/expected :oban_tags_to_sentry_tags to be/, fn -> + put_test_config(integrations: [oban: [oban_tags_to_sentry_tags: "invalid"]]) + end + + assert_raise ArgumentError, ~r/expected :oban_tags_to_sentry_tags to be/, fn -> + put_test_config(integrations: [oban: [oban_tags_to_sentry_tags: 123]]) + end + + assert_raise ArgumentError, ~r/expected :oban_tags_to_sentry_tags to be/, fn -> + put_test_config(integrations: [oban: [oban_tags_to_sentry_tags: []]]) + end + end + end +end diff --git a/test/sentry/integrations/oban/error_reporter_test.exs b/test/sentry/integrations/oban/error_reporter_test.exs index 0d48fcdb..d56f5889 100644 --- a/test/sentry/integrations/oban/error_reporter_test.exs +++ b/test/sentry/integrations/oban/error_reporter_test.exs @@ -154,11 +154,67 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do assert Sentry.Test.pop_sentry_reports() == [] end end + + test "includes custom tags when oban_tags_to_sentry_tags function config option is set and returns non empty map" do + Sentry.Test.start_collecting() + + emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], + oban_tags_to_sentry_tags: fn _job -> %{custom_tag: "custom_value"} end + ) + + assert [event] = Sentry.Test.pop_sentry_reports() + assert event.tags.custom_tag == "custom_value" + end + + test "handles oban_tags_to_sentry_tags errors gracefully" do + Sentry.Test.start_collecting() + + emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], + oban_tags_to_sentry_tags: fn _job -> raise "tag transform error" end + ) + + assert [_event] = Sentry.Test.pop_sentry_reports() + end + + test "handles invalid oban_tags_to_sentry_tags return values gracefully" do + Sentry.Test.start_collecting() + + test_cases = [ + 1, + "invalid", + :invalid, + [1, 2, 3], + nil + ] + + Enum.each(test_cases, fn invalid_value -> + emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], + oban_tags_to_sentry_tags: fn _job -> invalid_value end + ) + + assert [_event] = Sentry.Test.pop_sentry_reports() + end) + end + + test "supports MFA tuple for oban_tags_to_sentry_tags" do + defmodule TestTagsTransform do + def transform(_job), do: %{custom_tag: "custom_value"} + end + + Sentry.Test.start_collecting() + + emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], + oban_tags_to_sentry_tags: {TestTagsTransform, :transform} + ) + + assert [event] = Sentry.Test.pop_sentry_reports() + assert event.tags.custom_tag == "custom_value" + end end ## Helpers - defp emit_telemetry_for_failed_job(kind, reason, stacktrace) do + defp emit_telemetry_for_failed_job(kind, reason, stacktrace, config \\ []) do job = %{"id" => "123", "entity" => "user", "type" => "delete"} |> MyWorker.new() @@ -169,7 +225,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do [:oban, :job, :exception], %{}, %{job: job, kind: kind, reason: reason, stacktrace: stacktrace}, - :no_config + config ) job