Skip to content

Commit c5c959b

Browse files
authored
Introduce manual check-ins for crons (#697)
1 parent 47e2d65 commit c5c959b

File tree

7 files changed

+486
-6
lines changed

7 files changed

+486
-6
lines changed

lib/sentry.ex

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ defmodule Sentry do
171171
> with `:source_code_exclude_patterns`.
172172
"""
173173

174-
alias Sentry.{Client, Config, Event, LoggerUtils}
174+
alias Sentry.{CheckIn, Client, Config, Event, LoggerUtils}
175175

176176
require Logger
177177

@@ -338,6 +338,64 @@ defmodule Sentry do
338338
end
339339
end
340340

341+
@doc """
342+
Captures a check-in built with the given `options`.
343+
344+
Check-ins are used to report the status of a monitor to Sentry. This is used
345+
to track the health and progress of **cron jobs**. This function is somewhat
346+
low level, and mostly useful when you want to report the status of a cron
347+
but you are not using any common library to manage your cron jobs.
348+
349+
This function performs a *synchronous* HTTP request to Sentry. If the request
350+
performs successfully, it returns `{:ok, check_in_id}` where `check_in_id` is
351+
the ID of the check-in that was sent to Sentry. You can use this ID to send
352+
updates about the same check-in. If the request fails, it returns
353+
`{:error, reason}`.
354+
355+
> #### Setting the DSN {: .warning}
356+
>
357+
> If the `:dsn` configuration is not set, this function won't report the check-in
358+
> to Sentry and will instead return `:ignored`. This behaviour is consistent with
359+
> the rest of the SDK (such as `capture_exception/2`).
360+
361+
## Options
362+
363+
This functions supports all the options mentioned in `Sentry.CheckIn.new/1`.
364+
365+
## Examples
366+
367+
Say you have a GenServer which periodically sends a message to itself to execute some
368+
job. You could monitor the health of this GenServer by reporting a check-in to Sentry.
369+
370+
For example:
371+
372+
@impl GenServer
373+
def handle_info(:execute_periodic_job, state) do
374+
# Report that the job started.
375+
{:ok, check_in_id} = Sentry.capture_check_in(status: :in_progress, monitor_slug: "genserver-job")
376+
377+
:ok = do_job(state)
378+
379+
# Report that the job ended successfully.
380+
Sentry.capture_check_in(check_in_id: check_in_id, status: :ok, monitor_slug: "genserver-job")
381+
382+
{:noreply, state}
383+
end
384+
385+
"""
386+
@doc since: "10.2.0"
387+
@spec capture_check_in(keyword()) ::
388+
{:ok, check_in_id :: String.t()} | :ignored | {:error, term()}
389+
def capture_check_in(options) when is_list(options) do
390+
if Config.dsn() do
391+
options
392+
|> CheckIn.new()
393+
|> Client.send_check_in(options)
394+
else
395+
:ignored
396+
end
397+
end
398+
341399
@doc ~S"""
342400
Updates the value of `key` in the configuration *at runtime*.
343401

lib/sentry/check_in.ex

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
defmodule Sentry.CheckIn do
2+
@moduledoc """
3+
This module represents the struct for a "check-in".
4+
5+
Check-ins are used to report the status of a monitor to Sentry. This is used
6+
to track the health and progress of **cron jobs**. This module is somewhat
7+
low level, and mostly useful when you want to report the status of a cron
8+
but you are not using any common library to manage your cron jobs.
9+
10+
> #### Using `capture_check_in/1` {: .tip}
11+
>
12+
> Instead of using this module directly, you'll probably want to use
13+
> `Sentry.capture_check_in/1` to manually report the status of your cron jobs.
14+
15+
See <https://develop.sentry.dev/sdk/check-ins/>. This struct is available
16+
since v10.2.0.
17+
"""
18+
@moduledoc since: "10.2.0"
19+
20+
alias Sentry.{Config, Interfaces, UUID}
21+
22+
@typedoc """
23+
The possible status of the check-in.
24+
"""
25+
@type status() :: :in_progress | :ok | :error
26+
27+
@typedoc """
28+
The possible values for the `:schedule` option under `:monitor_config`.
29+
30+
If the `:type` is `:crontab`, then the `:value` must be a string representing
31+
a crontab expression. If the `:type` is `:interval`, then the `:value` must be
32+
a number representing the interval and the `:unit` must be present and be one of `:year`,
33+
`:month`, `:week`, `:day`, `:hour`, or `:minute`.
34+
"""
35+
@type monitor_config_schedule() ::
36+
%{type: :crontab, value: String.t()}
37+
| %{
38+
type: :interval,
39+
value: number(),
40+
unit: :year | :month | :week | :day | :hour | :minute
41+
}
42+
43+
@typedoc """
44+
The type for the check-in struct.
45+
"""
46+
@type t() :: %__MODULE__{
47+
check_in_id: String.t(),
48+
monitor_slug: String.t(),
49+
status: status(),
50+
duration: float() | nil,
51+
release: String.t() | nil,
52+
environment: String.t() | nil,
53+
monitor_config:
54+
nil
55+
| %{
56+
required(:schedule) => monitor_config_schedule(),
57+
optional(:checkin_margin) => number(),
58+
optional(:max_runtime) => number(),
59+
optional(:failure_issue_threshold) => number(),
60+
optional(:recovery_threshold) => number(),
61+
optional(:timezone) => String.t()
62+
},
63+
contexts: Interfaces.context()
64+
}
65+
66+
@enforce_keys [
67+
:check_in_id,
68+
:monitor_slug,
69+
:status
70+
]
71+
defstruct @enforce_keys ++
72+
[
73+
:duration,
74+
:release,
75+
:environment,
76+
:monitor_config,
77+
:contexts
78+
]
79+
80+
number_schema_opts = [type: {:or, [:integer, :float]}, type_doc: "`t:number/0`"]
81+
82+
crontab_schedule_opts_schema = [
83+
type: [type: {:in, [:crontab]}, required: true],
84+
value: [type: :string, required: true]
85+
]
86+
87+
interval_schedule_opts_schema = [
88+
type: [type: {:in, [:interval]}, required: true],
89+
value: number_schema_opts,
90+
unit: [type: {:in, [:year, :month, :week, :day, :hour, :minute]}, required: true]
91+
]
92+
93+
create_check_in_opts_schema = [
94+
check_in_id: [
95+
type: :string
96+
],
97+
status: [
98+
type: {:in, [:in_progress, :ok, :error]},
99+
required: true,
100+
type_doc: "`t:status/0`"
101+
],
102+
monitor_slug: [
103+
type: :string,
104+
required: true
105+
],
106+
duration: number_schema_opts,
107+
contexts: [
108+
type: :map,
109+
default: %{},
110+
doc: """
111+
The contexts to attach to the check-in. This is a map of arbitrary data,
112+
but right now Sentry supports the `trace_id` key under the
113+
[trace context](https://develop.sentry.dev/sdk/event-payloads/contexts/#trace-context)
114+
to connect the check-in with related errors.
115+
"""
116+
],
117+
monitor_config: [
118+
doc: "If you pass this optional option, you **must** pass the nested `:schedule` option.",
119+
type: :keyword_list,
120+
keys: [
121+
checkin_margin: number_schema_opts,
122+
max_runtime: number_schema_opts,
123+
failure_issue_threshold: number_schema_opts,
124+
recovery_threshold: number_schema_opts,
125+
timezone: [type: :string],
126+
schedule: [
127+
type:
128+
{:or,
129+
[
130+
{:keyword_list, crontab_schedule_opts_schema},
131+
{:keyword_list, interval_schedule_opts_schema}
132+
]},
133+
type_doc: "`t:monitor_config_schedule/0`"
134+
]
135+
]
136+
]
137+
]
138+
139+
@create_check_in_opts_schema NimbleOptions.new!(create_check_in_opts_schema)
140+
141+
@doc """
142+
Creates a new check-in struct with the given options.
143+
144+
## Options
145+
146+
The options you can pass match a subset of the fields of the `t:t/0` struct.
147+
You can pass:
148+
149+
#{NimbleOptions.docs(@create_check_in_opts_schema)}
150+
151+
## Examples
152+
153+
iex> check_in = CheckIn.new(status: :ok, monitor_slug: "my-slug")
154+
iex> check_in.status
155+
:ok
156+
iex> check_in.monitor_slug
157+
"my-slug"
158+
159+
"""
160+
@spec new(keyword()) :: t()
161+
def new(opts) when is_list(opts) do
162+
opts = NimbleOptions.validate!(opts, @create_check_in_opts_schema)
163+
164+
monitor_config =
165+
case Keyword.fetch(opts, :monitor_config) do
166+
{:ok, monitor_config} ->
167+
monitor_config
168+
|> Map.new()
169+
|> Map.update!(:schedule, &Map.new/1)
170+
171+
:error ->
172+
nil
173+
end
174+
175+
%__MODULE__{
176+
check_in_id: Keyword.get_lazy(opts, :check_in_id, &UUID.uuid4_hex/0),
177+
status: Keyword.fetch!(opts, :status),
178+
monitor_slug: Keyword.fetch!(opts, :monitor_slug),
179+
duration: Keyword.get(opts, :duration),
180+
release: Config.release(),
181+
environment: Config.environment_name(),
182+
monitor_config: monitor_config,
183+
contexts: Keyword.fetch!(opts, :contexts)
184+
}
185+
end
186+
187+
# Used to then encode the returned map to JSON.
188+
@doc false
189+
@spec to_map(t()) :: map()
190+
def to_map(%__MODULE__{} = check_in) do
191+
Map.from_struct(check_in)
192+
end
193+
end

lib/sentry/client.ex

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule Sentry.Client do
55
# and sampling.
66
# See https://develop.sentry.dev/sdk/unified-api/#client.
77

8-
alias Sentry.{Config, Dedupe, Envelope, Event, Interfaces, LoggerUtils, Transport}
8+
alias Sentry.{CheckIn, Config, Dedupe, Envelope, Event, Interfaces, LoggerUtils, Transport}
99

1010
require Logger
1111

@@ -78,6 +78,26 @@ defmodule Sentry.Client do
7878
Keyword.split(options, @send_event_opts_keys)
7979
end
8080

81+
@spec send_check_in(CheckIn.t(), keyword()) ::
82+
{:ok, check_in_id :: String.t()} | {:error, term()}
83+
def send_check_in(%CheckIn{} = check_in, opts) when is_list(opts) do
84+
client = Keyword.get_lazy(opts, :client, &Config.client/0)
85+
86+
# This is a "private" option, only really used in testing.
87+
request_retries =
88+
Keyword.get_lazy(opts, :request_retries, fn ->
89+
Application.get_env(:sentry, :request_retries, Transport.default_retries())
90+
end)
91+
92+
send_result =
93+
check_in
94+
|> Envelope.from_check_in()
95+
|> Transport.post_envelope(client, request_retries)
96+
97+
_ = maybe_log_send_result(send_result, check_in)
98+
send_result
99+
end
100+
81101
# This is what executes the "Event Pipeline".
82102
# See: https://develop.sentry.dev/sdk/unified-api/#event-pipeline
83103
@spec send_event(Event.t(), keyword()) ::
@@ -320,7 +340,7 @@ defmodule Sentry.Client do
320340
:ok
321341
end
322342

323-
defp maybe_log_send_result(send_result, %Event{}) do
343+
defp maybe_log_send_result(send_result, _other) do
324344
message =
325345
case send_result do
326346
{:error, {:invalid_json, error}} ->

lib/sentry/envelope.ex

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ defmodule Sentry.Envelope do
22
@moduledoc false
33
# https://develop.sentry.dev/sdk/envelopes/
44

5-
alias Sentry.{Attachment, Config, Event, UUID}
5+
alias Sentry.{Attachment, CheckIn, Config, Event, UUID}
66

77
@type t() :: %__MODULE__{
88
event_id: UUID.t(),
9-
items: [Event.t() | Attachment.t(), ...]
9+
items: [Event.t() | Attachment.t() | CheckIn.t(), ...]
1010
}
1111

1212
@enforce_keys [:event_id, :items]
@@ -23,6 +23,17 @@ defmodule Sentry.Envelope do
2323
}
2424
end
2525

26+
@doc """
27+
Creates a new envelope containing the given check-in.
28+
"""
29+
@spec from_check_in(CheckIn.t()) :: t()
30+
def from_check_in(%CheckIn{} = check_in) do
31+
%__MODULE__{
32+
event_id: UUID.uuid4_hex(),
33+
items: [check_in]
34+
}
35+
end
36+
2637
@doc """
2738
Encodes the envelope into its binary representation.
2839
@@ -70,4 +81,15 @@ defmodule Sentry.Envelope do
7081

7182
[header_iodata, ?\n, attachment.data, ?\n]
7283
end
84+
85+
defp item_to_binary(json_library, %CheckIn{} = check_in) do
86+
case check_in |> CheckIn.to_map() |> json_library.encode() do
87+
{:ok, encoded_check_in} ->
88+
header = ~s({"type": "check_in", "length": #{byte_size(encoded_check_in)}})
89+
[header, ?\n, encoded_check_in, ?\n]
90+
91+
{:error, _reason} = error ->
92+
throw(error)
93+
end
94+
end
7395
end

test/envelope_test.exs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule Sentry.EnvelopeTest do
33

44
import Sentry.TestHelpers
55

6-
alias Sentry.{Attachment, Envelope, Event}
6+
alias Sentry.{Attachment, CheckIn, Envelope, Event}
77

88
describe "to_binary/1" do
99
test "encodes an envelope" do
@@ -94,5 +94,24 @@ defmodule Sentry.EnvelopeTest do
9494
"attachment_type" => "event.minidump"
9595
}
9696
end
97+
98+
test "works with check-ins" do
99+
put_test_config(environment_name: "test")
100+
check_in_id = Sentry.UUID.uuid4_hex()
101+
check_in = %CheckIn{check_in_id: check_in_id, monitor_slug: "test", status: :ok}
102+
103+
envelope = Envelope.from_check_in(check_in)
104+
105+
assert {:ok, encoded} = Envelope.to_binary(envelope)
106+
107+
assert [id_line, header_line, event_line] = String.split(encoded, "\n", trim: true)
108+
assert %{"event_id" => _} = Jason.decode!(id_line)
109+
assert %{"type" => "check_in", "length" => _} = Jason.decode!(header_line)
110+
111+
assert {:ok, decoded_check_in} = Jason.decode(event_line)
112+
assert decoded_check_in["check_in_id"] == check_in_id
113+
assert decoded_check_in["monitor_slug"] == "test"
114+
assert decoded_check_in["status"] == "ok"
115+
end
97116
end
98117
end

0 commit comments

Comments
 (0)