Skip to content

Commit a3fbcff

Browse files
authored
Merge pull request #746 from DougReeder/health-controller-exceptions
Health probe failures log useful message
2 parents e76cd70 + 78bb562 commit a3fbcff

6 files changed

Lines changed: 261 additions & 12 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ Run the following commands at the root of the reticulum directory:
6161

6262
Run `scripts/run.sh` if you have the hubs secret repo cloned. Otherwise `iex -S mix phx.server`
6363

64+
## Development Aids
65+
66+
### Environment Variables [INCOMPLETE]
67+
68+
* STACKTRACE
69+
* For errors logged using `log_our_code_location/3`, controls output verbosity.
70+
* Valid values:
71+
* `FULL`: prints a full stack trace in addition to the location in project code
72+
* undefined or any other value: prints only the location in project code
73+
74+
6475
## Run Hubs Against a Local Reticulum Instance
6576

6677
### 1. Setup the `hubs.local` hostname

config/test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ config :ret, RetWeb.Endpoint,
1111
server: false
1212

1313
# Print only warnings and errors during test
14-
config :logger, level: :warn
14+
config :logger, level: :warning
1515

1616
config :ret, Ret.AppConfig, caching?: false
1717

lib/ret_web/controllers/controller_helpers.ex

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ defmodule RetWeb.ControllerHelpers do
22
import Plug.Conn
33
import Phoenix.Controller
44
import RetWeb.ErrorHelpers
5+
require Logger
56

67
def render_error_json(conn, status, params) do
78
conn
@@ -21,4 +22,62 @@ defmodule RetWeb.ControllerHelpers do
2122
reason = Plug.Conn.Status.reason_phrase(code)
2223
render_error_json(conn, status, reason)
2324
end
25+
26+
def log_our_code_location(stacktrace, error, description \\ "Failure") do
27+
{filename, line, function} =
28+
try do
29+
ind =
30+
Enum.find_index(stacktrace, fn
31+
{module, _function, _arity, [file: filepath, line: _line]} ->
32+
filepath = List.to_string(filepath)
33+
34+
is_not_dependency =
35+
!String.contains?(filepath, "/deps/") && !String.contains?(filepath, "deps/")
36+
37+
is_reticulum_module = String.starts_with?(to_string(module), "Elixir.Ret")
38+
is_not_dependency && is_reticulum_module
39+
40+
# if stacktrace entry isn't in usual format, skips it
41+
_ ->
42+
false
43+
# if no matching entry found, returns first entry
44+
end) || 0
45+
46+
{_module, _function, _arity, location} = Enum.at(stacktrace, ind)
47+
filepath = Keyword.get(location, :file)
48+
line = Keyword.get(location, :line, 0)
49+
filename = if filepath, do: Path.basename(List.to_string(filepath)), else: "<unknown>"
50+
51+
function =
52+
if ind > 0 do
53+
{module, function, _ar, _location} = Enum.at(stacktrace, ind - 1)
54+
"#{String.replace_prefix(to_string(module), "Elixir.", "")}.#{function}"
55+
else
56+
:unknown
57+
end
58+
59+
{filename, line, function}
60+
rescue
61+
_coding_error ->
62+
{"<malformed stacktrace>", 0, :unknown}
63+
end
64+
65+
Logger.error("#{description} at #{filename}:#{line} calling #{function}: #{inspect(error)}")
66+
67+
if System.get_env("STACKTRACE") === "FULL" or filename === "<unknown>" or
68+
filename === "<malformed stacktrace>" do
69+
try do
70+
Logger.error(
71+
"Stack trace (most recent call first):\n" <> Exception.format_stacktrace(stacktrace)
72+
)
73+
rescue
74+
_ ->
75+
Logger.error("Stack trace (nonstandard):\n" <> inspect(stacktrace))
76+
end
77+
else
78+
Logger.info("For full stacktraces, set the environment variable STACKTRACE to FULL.")
79+
end
80+
81+
{filename, line, function}
82+
end
2483
end

lib/ret_web/controllers/health_controller.ex

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,27 @@ defmodule RetWeb.HealthController do
33
import Ecto.Query
44

55
def index(conn, _params) do
6-
# Check database
7-
if module_config(:check_repo) do
8-
Ret.Repo.all(from Ret.Hub, limit: 0)
9-
end
6+
try do
7+
# Check database
8+
if module_config(:check_repo) do
9+
Ret.Repo.all(from Ret.Hub, limit: 0)
10+
end
11+
12+
# Check page cache
13+
true = Cachex.get(:page_chunks, {:hubs, "index.html"}) |> elem(1) |> Enum.count() > 0
14+
true = Cachex.get(:page_chunks, {:hubs, "hub.html"}) |> elem(1) |> Enum.count() > 0
15+
true = Cachex.get(:page_chunks, {:spoke, "index.html"}) |> elem(1) |> Enum.count() > 0
1016

11-
# Check page cache
12-
true = Cachex.get(:page_chunks, {:hubs, "index.html"}) |> elem(1) |> Enum.count() > 0
13-
true = Cachex.get(:page_chunks, {:hubs, "hub.html"}) |> elem(1) |> Enum.count() > 0
14-
true = Cachex.get(:page_chunks, {:spoke, "index.html"}) |> elem(1) |> Enum.count() > 0
17+
# Check room routing
18+
true = Ret.RoomAssigner.get_available_host("") != nil
1519

16-
# Check room routing
17-
true = Ret.RoomAssigner.get_available_host("") != nil
20+
send_resp(conn, 200, "ok")
21+
rescue
22+
error ->
23+
log_our_code_location(__STACKTRACE__, error, "Health check failed")
1824

19-
send_resp(conn, 200, "ok")
25+
send_resp(conn, 500, "error")
26+
end
2027
end
2128

2229
defp module_config(key) do
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
defmodule RetWeb.ControllerHelpersTest do
2+
use ExUnit.Case, async: true
3+
4+
alias RetWeb.ControllerHelpers
5+
import ExUnit.CaptureLog
6+
require Logger
7+
8+
setup_all do
9+
Logger.configure(level: :info)
10+
:ok
11+
end
12+
13+
describe "log_our_code_location/3" do
14+
@tag :error_logging
15+
test "ignores dependency modules and finds innermost project module" do
16+
log =
17+
capture_log([level: :info], fn ->
18+
stacktrace = [
19+
{Bamboo.Email, :new_email, 0,
20+
[file: ~c"_build/test/lib/bamboo/ebin/Elixir.Bamboo.Email.beam", line: 190]},
21+
{RetWeb.Email, :auth_email, 2, [file: ~c"lib/ret_web/email.ex", line: 19]},
22+
{RetWeb.Endpoint, :get_cors_origins, 0,
23+
[file: ~c"lib/ret_web/endpoint.ex", line: 10]},
24+
{RetWeb.Endpoint, :allowed_origin?, 1, [file: ~c"lib/ret_web/endpoint.ex", line: 16]}
25+
]
26+
27+
ControllerHelpers.log_our_code_location(stacktrace, :email_error, "Pseudo-failure")
28+
end)
29+
30+
assert log =~ "Pseudo-failure"
31+
assert log =~ "at email.ex:19"
32+
assert log =~ "calling Bamboo.Email.new_email"
33+
assert log =~ ":email_error"
34+
assert log =~ "For full stacktraces, set the environment variable STACKTRACE to FULL."
35+
refute log =~ "Stack trace (most recent call first)"
36+
end
37+
38+
@tag :error_logging
39+
test "falls back to the first entry if no project module is found" do
40+
log =
41+
capture_log([level: :info], fn ->
42+
stacktrace = [
43+
{Plug.Conn, :send_resp, 3, [file: ~c"deps/plug/lib/plug/spam.ex", line: 400]},
44+
{Phoenix.Controller, :render, 3,
45+
[file: ~c"deps/phoenix/lib/phoenix/controller.ex", line: 100]}
46+
]
47+
48+
ControllerHelpers.log_our_code_location(stacktrace, :another_error)
49+
end)
50+
51+
assert log =~ "Failure"
52+
assert log =~ "at spam.ex:400"
53+
assert log =~ "calling unknown"
54+
assert log =~ ":another_error"
55+
assert log =~ "For full stacktraces, set the environment variable STACKTRACE to FULL."
56+
refute log =~ "Stack trace (most recent call first)"
57+
end
58+
59+
@tag :error_logging
60+
test "handles malformed stacktrace entries by logging full stack trace" do
61+
log =
62+
capture_log([level: :info], fn ->
63+
# This should trigger the rescue block because pattern doesn't match
64+
stacktrace = [
65+
{:not, :a, :standard, :entry}
66+
]
67+
68+
ControllerHelpers.log_our_code_location(stacktrace, :spam_error, "Probe failure")
69+
end)
70+
71+
assert log =~ "Probe failure"
72+
assert log =~ "at <malformed stacktrace>:0"
73+
assert log =~ "calling unknown"
74+
assert log =~ ":spam_error"
75+
refute log =~ "For full stacktraces, set the environment variable STACKTRACE to FULL."
76+
assert log =~ "Stack trace (nonstandard)"
77+
end
78+
79+
@tag :error_logging
80+
test "handles empty stacktrace by logging full stacktrace" do
81+
log =
82+
capture_log([level: :info], fn ->
83+
# This should trigger the rescue block because there's no entry to match
84+
ControllerHelpers.log_our_code_location([], :strange_error, "Weird failure")
85+
end)
86+
87+
assert log =~ "Weird failure"
88+
assert log =~ "at <malformed stacktrace>:0"
89+
assert log =~ "calling unknown"
90+
assert log =~ ":strange_error"
91+
refute log =~ "For full stacktraces, set the environment variable STACKTRACE to FULL."
92+
assert log =~ "Stack trace (most recent call first)"
93+
end
94+
95+
@tag :error_logging
96+
test "logs full stacktrace when STACKTRACE environment variable is set to FULL" do
97+
# Note: manipulation of this environment variable within async tests is deemed safe because it only relates to this module.
98+
System.put_env("STACKTRACE", "FULL")
99+
100+
on_exit(fn ->
101+
System.delete_env("STACKTRACE")
102+
end)
103+
104+
log =
105+
capture_log([level: :info], fn ->
106+
stacktrace = [
107+
{RetWeb.Email, :auth_email, 2, [file: ~c"lib/ret_web/email.ex", line: 19]},
108+
{RetWeb.Endpoint, :get_cors_origins, 0, [file: ~c"lib/ret_web/endpoint.ex", line: 10]}
109+
]
110+
111+
ControllerHelpers.log_our_code_location(stacktrace, :full_error, "Full failure")
112+
end)
113+
114+
assert log =~ "Full failure"
115+
assert log =~ "at email.ex:19"
116+
assert log =~ "calling unknown"
117+
assert log =~ ":full_error"
118+
refute log =~ "For full stacktraces, set the environment variable STACKTRACE to FULL."
119+
assert log =~ "Stack trace (most recent call first)"
120+
assert log =~ "RetWeb.Email.auth_email/2"
121+
assert log =~ "RetWeb.Endpoint.get_cors_origins/0"
122+
end
123+
124+
@tag :error_logging
125+
test "handles stacktrace entry without filepath by returning <unknown>" do
126+
log =
127+
capture_log([level: :info], fn ->
128+
# Entry with arity but no location information
129+
stacktrace = [
130+
{RetWeb.HealthController, :index, 2, []}
131+
]
132+
133+
ControllerHelpers.log_our_code_location(
134+
stacktrace,
135+
:no_filepath_error,
136+
"No-filepath failure"
137+
)
138+
end)
139+
140+
assert log =~ "No-filepath failure"
141+
assert log =~ "at <unknown>:0"
142+
assert log =~ "calling unknown"
143+
assert log =~ ":no_filepath_error"
144+
refute log =~ "For full stacktraces, set the environment variable STACKTRACE to FULL."
145+
assert log =~ "Stack trace (most recent call first)"
146+
assert log =~ "RetWeb.HealthController.index/2"
147+
end
148+
end
149+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule RetWeb.HealthControllerTest do
2+
use RetWeb.ConnCase
3+
4+
import ExUnit.CaptureLog
5+
require Logger
6+
7+
@tag :error_logging
8+
test "GET /health, when a check fails, returns 500 and logs error & location", %{conn: conn} do
9+
log =
10+
capture_log([level: :error], fn ->
11+
# Cachex and RoomAssigner aren't mocked so this will fail.
12+
resp = conn |> get("/health")
13+
assert resp.status === 500
14+
assert resp.resp_body === "error"
15+
end)
16+
17+
# It should log health_controller.ex (reticulum code) even if the error
18+
# occurs inside a library (like Enum or Cachex).
19+
assert log =~ "Health check failed"
20+
assert log =~ "at health_controller.ex:13"
21+
assert log =~ "calling Enum.count"
22+
end
23+
end

0 commit comments

Comments
 (0)