Skip to content

Commit

Permalink
Introduce manual check-ins for crons (#697)
Browse files Browse the repository at this point in the history
  • Loading branch information
whatyouhide authored Feb 26, 2024
1 parent 47e2d65 commit c5c959b
Show file tree
Hide file tree
Showing 7 changed files with 486 additions and 6 deletions.
60 changes: 59 additions & 1 deletion lib/sentry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ defmodule Sentry do
> with `:source_code_exclude_patterns`.
"""

alias Sentry.{Client, Config, Event, LoggerUtils}
alias Sentry.{CheckIn, Client, Config, Event, LoggerUtils}

require Logger

Expand Down Expand Up @@ -338,6 +338,64 @@ defmodule Sentry do
end
end

@doc """
Captures a check-in built with the given `options`.
Check-ins are used to report the status of a monitor to Sentry. This is used
to track the health and progress of **cron jobs**. This function is somewhat
low level, and mostly useful when you want to report the status of a cron
but you are not using any common library to manage your cron jobs.
This function performs a *synchronous* HTTP request to Sentry. If the request
performs successfully, it returns `{:ok, check_in_id}` where `check_in_id` is
the ID of the check-in that was sent to Sentry. You can use this ID to send
updates about the same check-in. If the request fails, it returns
`{:error, reason}`.
> #### Setting the DSN {: .warning}
>
> If the `:dsn` configuration is not set, this function won't report the check-in
> to Sentry and will instead return `:ignored`. This behaviour is consistent with
> the rest of the SDK (such as `capture_exception/2`).
## Options
This functions supports all the options mentioned in `Sentry.CheckIn.new/1`.
## Examples
Say you have a GenServer which periodically sends a message to itself to execute some
job. You could monitor the health of this GenServer by reporting a check-in to Sentry.
For example:
@impl GenServer
def handle_info(:execute_periodic_job, state) do
# Report that the job started.
{:ok, check_in_id} = Sentry.capture_check_in(status: :in_progress, monitor_slug: "genserver-job")
:ok = do_job(state)
# Report that the job ended successfully.
Sentry.capture_check_in(check_in_id: check_in_id, status: :ok, monitor_slug: "genserver-job")
{:noreply, state}
end
"""
@doc since: "10.2.0"
@spec capture_check_in(keyword()) ::
{:ok, check_in_id :: String.t()} | :ignored | {:error, term()}
def capture_check_in(options) when is_list(options) do
if Config.dsn() do
options
|> CheckIn.new()
|> Client.send_check_in(options)
else
:ignored
end
end

@doc ~S"""
Updates the value of `key` in the configuration *at runtime*.
Expand Down
193 changes: 193 additions & 0 deletions lib/sentry/check_in.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
defmodule Sentry.CheckIn do
@moduledoc """
This module represents the struct for a "check-in".
Check-ins are used to report the status of a monitor to Sentry. This is used
to track the health and progress of **cron jobs**. This module is somewhat
low level, and mostly useful when you want to report the status of a cron
but you are not using any common library to manage your cron jobs.
> #### Using `capture_check_in/1` {: .tip}
>
> Instead of using this module directly, you'll probably want to use
> `Sentry.capture_check_in/1` to manually report the status of your cron jobs.
See <https://develop.sentry.dev/sdk/check-ins/>. This struct is available
since v10.2.0.
"""
@moduledoc since: "10.2.0"

alias Sentry.{Config, Interfaces, UUID}

@typedoc """
The possible status of the check-in.
"""
@type status() :: :in_progress | :ok | :error

@typedoc """
The possible values for the `:schedule` option under `:monitor_config`.
If the `:type` is `:crontab`, then the `:value` must be a string representing
a crontab expression. If the `:type` is `:interval`, then the `:value` must be
a number representing the interval and the `:unit` must be present and be one of `:year`,
`:month`, `:week`, `:day`, `:hour`, or `:minute`.
"""
@type monitor_config_schedule() ::
%{type: :crontab, value: String.t()}
| %{
type: :interval,
value: number(),
unit: :year | :month | :week | :day | :hour | :minute
}

@typedoc """
The type for the check-in struct.
"""
@type t() :: %__MODULE__{
check_in_id: String.t(),
monitor_slug: String.t(),
status: status(),
duration: float() | nil,
release: String.t() | nil,
environment: String.t() | nil,
monitor_config:
nil
| %{
required(:schedule) => monitor_config_schedule(),
optional(:checkin_margin) => number(),
optional(:max_runtime) => number(),
optional(:failure_issue_threshold) => number(),
optional(:recovery_threshold) => number(),
optional(:timezone) => String.t()
},
contexts: Interfaces.context()
}

@enforce_keys [
:check_in_id,
:monitor_slug,
:status
]
defstruct @enforce_keys ++
[
:duration,
:release,
:environment,
:monitor_config,
:contexts
]

number_schema_opts = [type: {:or, [:integer, :float]}, type_doc: "`t:number/0`"]

crontab_schedule_opts_schema = [
type: [type: {:in, [:crontab]}, required: true],
value: [type: :string, required: true]
]

interval_schedule_opts_schema = [
type: [type: {:in, [:interval]}, required: true],
value: number_schema_opts,
unit: [type: {:in, [:year, :month, :week, :day, :hour, :minute]}, required: true]
]

create_check_in_opts_schema = [
check_in_id: [
type: :string
],
status: [
type: {:in, [:in_progress, :ok, :error]},
required: true,
type_doc: "`t:status/0`"
],
monitor_slug: [
type: :string,
required: true
],
duration: number_schema_opts,
contexts: [
type: :map,
default: %{},
doc: """
The contexts to attach to the check-in. This is a map of arbitrary data,
but right now Sentry supports the `trace_id` key under the
[trace context](https://develop.sentry.dev/sdk/event-payloads/contexts/#trace-context)
to connect the check-in with related errors.
"""
],
monitor_config: [
doc: "If you pass this optional option, you **must** pass the nested `:schedule` option.",
type: :keyword_list,
keys: [
checkin_margin: number_schema_opts,
max_runtime: number_schema_opts,
failure_issue_threshold: number_schema_opts,
recovery_threshold: number_schema_opts,
timezone: [type: :string],
schedule: [
type:
{:or,
[
{:keyword_list, crontab_schedule_opts_schema},
{:keyword_list, interval_schedule_opts_schema}
]},
type_doc: "`t:monitor_config_schedule/0`"
]
]
]
]

@create_check_in_opts_schema NimbleOptions.new!(create_check_in_opts_schema)

@doc """
Creates a new check-in struct with the given options.
## Options
The options you can pass match a subset of the fields of the `t:t/0` struct.
You can pass:
#{NimbleOptions.docs(@create_check_in_opts_schema)}
## Examples
iex> check_in = CheckIn.new(status: :ok, monitor_slug: "my-slug")
iex> check_in.status
:ok
iex> check_in.monitor_slug
"my-slug"
"""
@spec new(keyword()) :: t()
def new(opts) when is_list(opts) do
opts = NimbleOptions.validate!(opts, @create_check_in_opts_schema)

monitor_config =
case Keyword.fetch(opts, :monitor_config) do
{:ok, monitor_config} ->
monitor_config
|> Map.new()
|> Map.update!(:schedule, &Map.new/1)

:error ->
nil
end

%__MODULE__{
check_in_id: Keyword.get_lazy(opts, :check_in_id, &UUID.uuid4_hex/0),
status: Keyword.fetch!(opts, :status),
monitor_slug: Keyword.fetch!(opts, :monitor_slug),
duration: Keyword.get(opts, :duration),
release: Config.release(),
environment: Config.environment_name(),
monitor_config: monitor_config,
contexts: Keyword.fetch!(opts, :contexts)
}
end

# Used to then encode the returned map to JSON.
@doc false
@spec to_map(t()) :: map()
def to_map(%__MODULE__{} = check_in) do
Map.from_struct(check_in)
end
end
24 changes: 22 additions & 2 deletions lib/sentry/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule Sentry.Client do
# and sampling.
# See https://develop.sentry.dev/sdk/unified-api/#client.

alias Sentry.{Config, Dedupe, Envelope, Event, Interfaces, LoggerUtils, Transport}
alias Sentry.{CheckIn, Config, Dedupe, Envelope, Event, Interfaces, LoggerUtils, Transport}

require Logger

Expand Down Expand Up @@ -78,6 +78,26 @@ defmodule Sentry.Client do
Keyword.split(options, @send_event_opts_keys)
end

@spec send_check_in(CheckIn.t(), keyword()) ::
{:ok, check_in_id :: String.t()} | {:error, term()}
def send_check_in(%CheckIn{} = check_in, opts) when is_list(opts) do
client = Keyword.get_lazy(opts, :client, &Config.client/0)

# This is a "private" option, only really used in testing.
request_retries =
Keyword.get_lazy(opts, :request_retries, fn ->
Application.get_env(:sentry, :request_retries, Transport.default_retries())
end)

send_result =
check_in
|> Envelope.from_check_in()
|> Transport.post_envelope(client, request_retries)

_ = maybe_log_send_result(send_result, check_in)
send_result
end

# This is what executes the "Event Pipeline".
# See: https://develop.sentry.dev/sdk/unified-api/#event-pipeline
@spec send_event(Event.t(), keyword()) ::
Expand Down Expand Up @@ -320,7 +340,7 @@ defmodule Sentry.Client do
:ok
end

defp maybe_log_send_result(send_result, %Event{}) do
defp maybe_log_send_result(send_result, _other) do
message =
case send_result do
{:error, {:invalid_json, error}} ->
Expand Down
26 changes: 24 additions & 2 deletions lib/sentry/envelope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ defmodule Sentry.Envelope do
@moduledoc false
# https://develop.sentry.dev/sdk/envelopes/

alias Sentry.{Attachment, Config, Event, UUID}
alias Sentry.{Attachment, CheckIn, Config, Event, UUID}

@type t() :: %__MODULE__{
event_id: UUID.t(),
items: [Event.t() | Attachment.t(), ...]
items: [Event.t() | Attachment.t() | CheckIn.t(), ...]
}

@enforce_keys [:event_id, :items]
Expand All @@ -23,6 +23,17 @@ defmodule Sentry.Envelope do
}
end

@doc """
Creates a new envelope containing the given check-in.
"""
@spec from_check_in(CheckIn.t()) :: t()
def from_check_in(%CheckIn{} = check_in) do
%__MODULE__{
event_id: UUID.uuid4_hex(),
items: [check_in]
}
end

@doc """
Encodes the envelope into its binary representation.
Expand Down Expand Up @@ -70,4 +81,15 @@ defmodule Sentry.Envelope do

[header_iodata, ?\n, attachment.data, ?\n]
end

defp item_to_binary(json_library, %CheckIn{} = check_in) do
case check_in |> CheckIn.to_map() |> json_library.encode() do
{:ok, encoded_check_in} ->
header = ~s({"type": "check_in", "length": #{byte_size(encoded_check_in)}})
[header, ?\n, encoded_check_in, ?\n]

{:error, _reason} = error ->
throw(error)
end
end
end
21 changes: 20 additions & 1 deletion test/envelope_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule Sentry.EnvelopeTest do

import Sentry.TestHelpers

alias Sentry.{Attachment, Envelope, Event}
alias Sentry.{Attachment, CheckIn, Envelope, Event}

describe "to_binary/1" do
test "encodes an envelope" do
Expand Down Expand Up @@ -94,5 +94,24 @@ defmodule Sentry.EnvelopeTest do
"attachment_type" => "event.minidump"
}
end

test "works with check-ins" do
put_test_config(environment_name: "test")
check_in_id = Sentry.UUID.uuid4_hex()
check_in = %CheckIn{check_in_id: check_in_id, monitor_slug: "test", status: :ok}

envelope = Envelope.from_check_in(check_in)

assert {:ok, encoded} = Envelope.to_binary(envelope)

assert [id_line, header_line, event_line] = String.split(encoded, "\n", trim: true)
assert %{"event_id" => _} = Jason.decode!(id_line)
assert %{"type" => "check_in", "length" => _} = Jason.decode!(header_line)

assert {:ok, decoded_check_in} = Jason.decode(event_line)
assert decoded_check_in["check_in_id"] == check_in_id
assert decoded_check_in["monitor_slug"] == "test"
assert decoded_check_in["status"] == "ok"
end
end
end
Loading

0 comments on commit c5c959b

Please sign in to comment.