Current section

Files

Jump to
sentry lib sentry integrations oban cron.ex
Raw

lib/sentry/integrations/oban/cron.ex

defmodule Sentry.Integrations.Oban.Cron do
@moduledoc """
This module provides built-in integration for cron jobs managed by Oban.
"""
@moduledoc since: "10.9.0"
alias Sentry.Integrations.CheckInIDMappings
@doc """
The Oban integration calls this callback (if present) to customize
the configuration options for the check-in.
This function must return options compatible with the ones passed to `Sentry.CheckIn.new/1`.
Options returned by this function overwrite any option inferred by the specific
integration for the check in. We perform *deep merging* of nested keyword options.
"""
@doc since: "10.9.0"
@callback sentry_check_in_configuration(oban_job :: struct()) :: options_to_merge :: keyword()
@events [
[:oban, :job, :start],
[:oban, :job, :stop],
[:oban, :job, :exception]
]
@doc false
@spec attach_telemetry_handler(keyword()) :: :ok
def attach_telemetry_handler(config) when is_list(config) do
_ = :telemetry.attach_many(__MODULE__, @events, &__MODULE__.handle_event/4, config)
:ok
end
@doc false
@spec handle_event([atom()], term(), term(), keyword()) :: :ok
def handle_event(event, measurements, metadata, config)
def handle_event(
[:oban, :job, event],
measurements,
%{job: %mod{meta: %{"cron" => true, "cron_expr" => cron_expr}}} = metadata,
config
)
when event in [:start, :stop, :exception] and mod == Oban.Job and is_binary(cron_expr) do
_ = handle_oban_job_event(event, measurements, metadata, config)
:ok
end
def handle_event([:oban, :job, event], _measurements, _metadata, _config)
when event in [:start, :stop, :exception] do
:ok
end
## Helpers
defp handle_oban_job_event(:start, _measurements, metadata, config) do
if opts = job_to_check_in_opts(metadata.job, config) do
opts
|> Keyword.merge(status: :in_progress)
|> Sentry.capture_check_in()
end
end
defp handle_oban_job_event(:stop, measurements, metadata, config) do
if opts = job_to_check_in_opts(metadata.job, config) do
status =
case metadata.state do
:success -> :ok
:failure -> :error
:cancelled -> :ok
:discard -> :ok
:snoozed -> :ok
end
opts
|> Keyword.merge(status: status, duration: duration_in_seconds(measurements))
|> Sentry.capture_check_in()
end
end
defp handle_oban_job_event(:exception, measurements, metadata, config) do
if opts = job_to_check_in_opts(metadata.job, config) do
opts
|> Keyword.merge(status: :error, duration: duration_in_seconds(measurements))
|> Sentry.capture_check_in()
end
end
defp job_to_check_in_opts(job, config) when is_struct(job, Oban.Job) do
# Check schedule first - if empty (e.g., @reboot), skip check-in entirely
# since @reboot can't be expressed as a cron/interval schedule
case schedule_opts(job) do
[] ->
nil
schedule_opts ->
monitor_config_opts = Sentry.Config.integrations()[:monitor_config_defaults]
monitor_config_opts = maybe_put_timezone_option(monitor_config_opts, job)
monitor_config_opts = Keyword.merge(monitor_config_opts, schedule_opts)
monitor_slug =
case config[:monitor_slug_generator] do
nil ->
slugify(job.worker)
{mod, fun} when is_atom(mod) and is_atom(fun) ->
mod |> apply(fun, [job]) |> slugify()
end
id = CheckInIDMappings.lookup_or_insert_new(job.id)
opts = [
check_in_id: id,
# This is already a binary.
monitor_slug: monitor_slug,
monitor_config: monitor_config_opts
]
resolve_custom_opts(opts, job)
end
end
defp resolve_custom_opts(opts, %{worker: worker} = job)
when is_struct(job, Oban.Job) and is_binary(worker) do
job.worker |> String.split(".") |> Module.safe_concat()
rescue
ArgumentError -> opts
else
worker ->
if Code.ensure_loaded?(worker) do
resolve_custom_opts(opts, worker, job)
else
opts
end
end
defp resolve_custom_opts(opts, _job) do
opts
end
defp resolve_custom_opts(options, mod, per_integration_term) do
custom_opts =
if function_exported?(mod, :sentry_check_in_configuration, 1) do
mod.sentry_check_in_configuration(per_integration_term)
else
[]
end
deep_merge_keyword(options, custom_opts)
end
defp deep_merge_keyword(left, right) do
Keyword.merge(left, right, fn _key, left_val, right_val ->
if Keyword.keyword?(left_val) and Keyword.keyword?(right_val) do
deep_merge_keyword(left_val, right_val)
else
right_val
end
end)
end
defp schedule_opts(%{meta: meta} = job) when is_struct(job, Oban.Job) do
case meta["cron_expr"] do
"@hourly" -> [schedule: [type: :interval, value: 1, unit: :hour]]
"@daily" -> [schedule: [type: :interval, value: 1, unit: :day]]
"@weekly" -> [schedule: [type: :interval, value: 1, unit: :week]]
"@monthly" -> [schedule: [type: :interval, value: 1, unit: :month]]
"@yearly" -> [schedule: [type: :interval, value: 1, unit: :year]]
"@annually" -> [schedule: [type: :interval, value: 1, unit: :year]]
"@reboot" -> []
cron_expr when is_binary(cron_expr) -> [schedule: [type: :crontab, value: cron_expr]]
_other -> []
end
end
defp maybe_put_timezone_option(opts, %{meta: %{"cron_tz" => tz}} = _job) do
Keyword.put(opts, :timezone, tz)
end
defp maybe_put_timezone_option(opts, _job) do
opts
end
defp duration_in_seconds(%{duration: duration} = _measurements) do
duration
|> System.convert_time_unit(:native, :millisecond)
|> Kernel./(1000)
end
# MyApp.SomeWorker -> "my-app-some-worker"
defp slugify(worker_name) do
worker_name
|> String.split(".")
|> Enum.map_join("-", &(&1 |> Macro.underscore() |> String.replace("_", "-")))
|> String.slice(0, 50)
end
end