Skip to content

Commit 7b2ff32

Browse files
committed
Implement heartbeat check-ins
Add an `Appsignal.CheckIn.heartbeat` helper that emits a single heartbeat for the check-in identifier given. When called with `continuous: true` as the second argument, it starts and links a separate Elixir process that emits a heartbeat every thirty seconds. Unlike the equivalent functionality in the Ruby integration, which spawns a thread that will stay alive for the lifetime of the Ruby process, the Elixir process is linked to the process that spawned it, meaning it will be shut down when its parent process is shut down. This allows it to be used to track the lifetime of individual Elixir processes. Additionally, it is also possible to add `Appsignal.CheckIn.Heartbeat` as a child process to a supervisor, meaning its lifetime will be tied to that of the other processes supervised by it. Finally, the functionality seen in the Ruby integration could also be achieved by manually calling `Appsignal.CheckIn.Heartbeat.start/1`, keeping the process unlinked and therefore alive for the entirety of the Elixir node's lifetime, though this is unlikely to be the preferred usage under the Elixir process model.
1 parent ab6d548 commit 7b2ff32

File tree

11 files changed

+435
-83
lines changed

11 files changed

+435
-83
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
bump: minor
3+
type: add
4+
---
5+
6+
Add support for heartbeat check-ins.
7+
8+
Use the `Appsignal.CheckIn.heartbeat` method to send a single heartbeat check-in event from your application. This can be used, for example, in a `GenServer`'s callback:
9+
10+
```elixir
11+
@impl true
12+
def handle_cast({:process_job, job}, jobs) do
13+
Appsignal.CheckIn.heartbeat("job_processor")
14+
{:noreply, [job | jobs], {:continue, :process_job}}
15+
end
16+
```
17+
18+
Heartbeats are deduplicated and sent asynchronously, without blocking the current thread. Regardless of how often the `.heartbeat` method is called, at most one heartbeat with the same identifier will be sent every ten seconds.
19+
20+
Pass `continuous: true` as the second argument to send heartbeats continuously during the entire lifetime of the current process. This can be used, for example, during a `GenServer`'s initialisation:
21+
22+
```elixir
23+
@impl true
24+
def init(_arg) do
25+
Appsignal.CheckIn.heartbeat("my_genserver", continuous: true)
26+
{:ok, nil}
27+
end
28+
```
29+
30+
You can also use `Appsignal.CheckIn.Heartbeat` as a supervisor's child process, in order for heartbeats to be sent continuously during the lifetime of the supervisor. This can be used, for example, during an `Application`'s start:
31+
32+
```elixir
33+
@impl true
34+
def start(_type, _args) do
35+
Supervisor.start_link([
36+
{Appsignal.CheckIn.Heartbeat, "my_application"}
37+
], strategy: :one_for_one, name: MyApplication.Supervisor)
38+
end
39+
```

config/config.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ if Mix.env() in [:bench, :test, :test_no_nif] do
2020
config :appsignal, appsignal_span: Appsignal.Test.Span
2121
config :appsignal, appsignal_tracer: Appsignal.Test.Tracer
2222
config :appsignal, appsignal_tracer_nif: Appsignal.Test.Nif
23+
2324
config :appsignal, deletion_delay: 100
25+
config :appsignal, appsignal_checkin_heartbeat_interval_milliseconds: 10
2426

2527
config :appsignal, :config,
2628
otp_app: :appsignal,

lib/appsignal/check_in/check_in.ex

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
defmodule Appsignal.CheckIn do
22
alias Appsignal.CheckIn.Cron
3+
alias Appsignal.CheckIn.Event
4+
5+
@scheduler Application.compile_env(
6+
:appsignal,
7+
:appsignal_checkin_scheduler,
8+
Appsignal.CheckIn.Scheduler
9+
)
310

411
@spec cron(String.t()) :: :ok
512
def cron(identifier) do
@@ -16,4 +23,18 @@ defmodule Appsignal.CheckIn do
1623

1724
output
1825
end
26+
27+
@spec heartbeat(String.t()) :: :ok
28+
@spec heartbeat(String.t(), continuous: boolean) :: :ok
29+
def heartbeat(identifier) do
30+
@scheduler.schedule(Event.heartbeat(identifier))
31+
:ok
32+
end
33+
34+
def heartbeat(identifier, continuous: true) do
35+
Appsignal.CheckIn.Heartbeat.start_link(identifier)
36+
:ok
37+
end
38+
39+
def heartbeat(identifier, _), do: heartbeat(identifier)
1940
end

lib/appsignal/check_in/cron.ex

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
defmodule Appsignal.CheckIn.Cron do
22
alias __MODULE__
3-
alias Appsignal.CheckIn.Cron.Event
3+
alias Appsignal.CheckIn.Event
44

55
@scheduler Application.compile_env(
66
:appsignal,
@@ -25,40 +25,11 @@ defmodule Appsignal.CheckIn.Cron do
2525

2626
@spec start(Cron.t()) :: :ok
2727
def start(cron) do
28-
@scheduler.schedule(Event.new(cron, :start))
28+
@scheduler.schedule(Event.cron(cron, :start))
2929
end
3030

3131
@spec finish(Cron.t()) :: :ok
3232
def finish(cron) do
33-
@scheduler.schedule(Event.new(cron, :finish))
34-
end
35-
end
36-
37-
defmodule Appsignal.CheckIn.Cron.Event do
38-
alias __MODULE__
39-
alias Appsignal.CheckIn.Cron
40-
41-
@derive Jason.Encoder
42-
43-
@type kind :: :start | :finish
44-
@type t :: %Event{
45-
identifier: String.t(),
46-
digest: String.t(),
47-
kind: kind,
48-
timestamp: integer,
49-
check_in_type: :cron
50-
}
51-
52-
defstruct [:identifier, :digest, :kind, :timestamp, :check_in_type]
53-
54-
@spec new(Cron.t(), kind) :: t
55-
def new(%Cron{identifier: identifier, digest: digest}, kind) do
56-
%Event{
57-
identifier: identifier,
58-
digest: digest,
59-
kind: kind,
60-
timestamp: System.system_time(:second),
61-
check_in_type: :cron
62-
}
33+
@scheduler.schedule(Event.cron(cron, :finish))
6334
end
6435
end

lib/appsignal/check_in/event.ex

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
defmodule Appsignal.CheckIn.Event do
2+
alias __MODULE__
3+
alias Appsignal.CheckIn.Cron
4+
5+
@type kind :: :start | :finish
6+
@type check_in_type :: :cron | :heartbeat
7+
@type t :: %Event{
8+
identifier: String.t(),
9+
digest: String.t() | nil,
10+
kind: kind | nil,
11+
timestamp: integer,
12+
check_in_type: check_in_type
13+
}
14+
15+
defstruct [:identifier, :digest, :kind, :timestamp, :check_in_type]
16+
17+
@spec cron(Cron.t(), kind) :: t
18+
def cron(%Cron{identifier: identifier, digest: digest}, kind) do
19+
%Event{
20+
identifier: identifier,
21+
digest: digest,
22+
kind: kind,
23+
timestamp: System.system_time(:second),
24+
check_in_type: :cron
25+
}
26+
end
27+
28+
@spec heartbeat(String.t()) :: t
29+
def heartbeat(identifier) do
30+
%Event{
31+
identifier: identifier,
32+
timestamp: System.system_time(:second),
33+
check_in_type: :heartbeat
34+
}
35+
end
36+
37+
@spec describe([t]) :: String.t()
38+
def describe([]) do
39+
# This shouldn't happen.
40+
"no check-in events"
41+
end
42+
43+
def describe([%Event{check_in_type: :cron} = event]) do
44+
"cron check-in `#{event.identifier || "unknown"}` " <>
45+
"#{event.kind || "unknown"} event (digest #{event.digest || "unknown"})"
46+
end
47+
48+
def describe([%Event{check_in_type: :heartbeat} = event]) do
49+
"heartbeat check-in `#{event.identifier || "unknown"}` event"
50+
end
51+
52+
def describe([_event]) do
53+
# This shouldn't happen.
54+
"unknown check-in event"
55+
end
56+
57+
def describe(events) do
58+
"#{Enum.count(events)} check-in events"
59+
end
60+
61+
@spec redundant?(t, t) :: boolean
62+
def redundant?(
63+
%Event{check_in_type: :cron} = event,
64+
%Event{check_in_type: :cron} = new_event
65+
) do
66+
# Consider any existing cron check-in event redundant if it has the
67+
# same identifier, digest and kind as the one we're adding.
68+
event.identifier == new_event.identifier &&
69+
event.kind == new_event.kind &&
70+
event.digest == new_event.digest
71+
end
72+
73+
def redundant?(
74+
%Event{check_in_type: :heartbeat} = event,
75+
%Event{check_in_type: :heartbeat} = new_event
76+
) do
77+
# Consider any existing heartbeat check-in event redundant if it has
78+
# the same identifier as the one we're adding.
79+
event.identifier == new_event.identifier
80+
end
81+
82+
def redundant?(_event, _new_event), do: false
83+
end
84+
85+
defimpl Jason.Encoder, for: Appsignal.CheckIn.Event do
86+
def encode(%Appsignal.CheckIn.Event{} = event, opts) do
87+
event
88+
|> Map.from_struct()
89+
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
90+
|> Enum.into(%{})
91+
|> Jason.Encode.map(opts)
92+
end
93+
end

lib/appsignal/check_in/heartbeat.ex

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
defmodule Appsignal.CheckIn.Heartbeat do
2+
use GenServer, shutdown: :brutal_kill
3+
4+
@interval_milliseconds Application.compile_env(
5+
:appsignal,
6+
:appsignal_checkin_heartbeat_interval_milliseconds,
7+
30_000
8+
)
9+
10+
@impl true
11+
def init(identifier) do
12+
{:ok, identifier, {:continue, :heartbeat}}
13+
end
14+
15+
def start(identifier) do
16+
GenServer.start(__MODULE__, identifier)
17+
end
18+
19+
def start_link(identifier) do
20+
GenServer.start_link(__MODULE__, identifier)
21+
end
22+
23+
def heartbeat(identifier) do
24+
GenServer.cast(__MODULE__, {:heartbeat, identifier})
25+
:ok
26+
end
27+
28+
@impl true
29+
def handle_continue(:heartbeat, identifier) do
30+
Appsignal.CheckIn.heartbeat(identifier)
31+
Process.send_after(self(), :heartbeat, @interval_milliseconds)
32+
{:noreply, identifier}
33+
end
34+
35+
@impl true
36+
def handle_info(:heartbeat, identifier) do
37+
{:noreply, identifier, {:continue, :heartbeat}}
38+
end
39+
end

lib/appsignal/check_in/scheduler.ex

Lines changed: 16 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ end
2626
defmodule Appsignal.CheckIn.Scheduler do
2727
use GenServer
2828

29-
alias Appsignal.CheckIn.Cron
29+
alias Appsignal.CheckIn.Event
3030

3131
@debounce Application.compile_env(
3232
:appsignal,
@@ -68,17 +68,15 @@ defmodule Appsignal.CheckIn.Scheduler do
6868
if Appsignal.Config.active?() do
6969
GenServer.cast(__MODULE__, {:schedule, event})
7070
else
71-
@integration_logger.debug(
72-
"AppSignal not active, not scheduling #{describe_events([event])}"
73-
)
71+
@integration_logger.debug("AppSignal not active, not scheduling #{Event.describe([event])}")
7472
end
7573

7674
:ok
7775
end
7876

7977
@impl true
8078
def handle_cast({:schedule, event}, state) do
81-
@integration_logger.trace("Scheduling #{describe_events([event])} to be transmitted")
79+
@integration_logger.trace("Scheduling #{Event.describe([event])} to be transmitted")
8280

8381
schedule_transmission(state)
8482

@@ -95,7 +93,7 @@ defmodule Appsignal.CheckIn.Scheduler do
9593

9694
@impl true
9795
def handle_continue({:transmit, events}, state) do
98-
description = describe_events(events)
96+
description = Event.describe(events)
9997

10098
config = Appsignal.Config.config()
10199
endpoint = "#{config[:logging_endpoint]}/check_ins/json"
@@ -150,42 +148,17 @@ defmodule Appsignal.CheckIn.Scheduler do
150148
defp add_event(events, event) do
151149
# Remove redundant events, keeping the newly added one, which
152150
# should be the one with the most recent timestamp.
153-
[event | Enum.reject(events, &redundant_event?(&1, event))]
154-
end
155-
156-
defp redundant_event?(%Cron.Event{} = event, %Cron.Event{} = new_event) do
157-
# Consider any existing cron check-in event redundant if it has the
158-
# same identifier, digest and kind as the one we're adding.
159-
is_redundant =
160-
event.identifier == new_event.identifier &&
161-
event.kind == new_event.kind &&
162-
event.digest == new_event.digest
163-
164-
if is_redundant do
165-
@integration_logger.debug("Replacing previously scheduled #{describe_events([event])}")
166-
end
167-
168-
is_redundant
169-
end
170-
171-
defp redundant_event?(_event, _new_event), do: false
172-
173-
defp describe_events([]) do
174-
# This shouldn't happen.
175-
"no check-in events"
176-
end
177-
178-
defp describe_events(events) when length(events) > 1 do
179-
"#{Enum.count(events)} check-in events"
180-
end
181-
182-
defp describe_events([%Cron.Event{} = event]) do
183-
"cron check-in `#{event.identifier || "unknown"}` " <>
184-
"#{event.kind || "unknown"} event (digest #{event.digest || "unknown"})"
185-
end
186-
187-
defp describe_events([_event]) do
188-
# This shouldn't happen.
189-
"unknown check-in event"
151+
[
152+
event
153+
| Enum.reject(events, fn existing_event ->
154+
is_redundant = Event.redundant?(existing_event, event)
155+
156+
if is_redundant do
157+
@integration_logger.debug("Replacing previously scheduled #{Event.describe([event])}")
158+
end
159+
160+
is_redundant
161+
end)
162+
]
190163
end
191164
end

0 commit comments

Comments
 (0)