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
2 changes: 1 addition & 1 deletion lib/sentry/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions lib/sentry/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/)
Expand Down Expand Up @@ -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
53 changes: 45 additions & 8 deletions lib/sentry/integrations/oban/error_reporter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]),
Expand Down Expand Up @@ -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
62 changes: 62 additions & 0 deletions test/sentry/config_oban_tags_to_sentry_tags_test.exs
Original file line number Diff line number Diff line change
@@ -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
60 changes: 58 additions & 2 deletions test/sentry/integrations/oban/error_reporter_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
Loading