Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use a separate client process #93

Merged
merged 12 commits into from Oct 10, 2017
6 changes: 5 additions & 1 deletion config/test.exs
Expand Up @@ -3,4 +3,8 @@ use Mix.Config
config :honeybadger,
environment_name: :test,
api_key: "abc123",
origin: "https://localhost:4000"
origin: "http://localhost:4444"

config :ex_unit,
assert_receive_timeout: 400,
refute_receive_timeout: 200
158 changes: 89 additions & 69 deletions lib/honeybadger.ex
@@ -1,9 +1,7 @@
defmodule Honeybadger do
use Application

alias Honeybadger.Backtrace
alias Honeybadger.Client
alias Honeybadger.Notice
alias Honeybadger.{Backtrace, Client, Notice}

defmodule MissingEnvironmentNameError do
defexception message: """
Expand Down Expand Up @@ -132,102 +130,124 @@ defmodule Honeybadger do

@context :honeybadger_context

@doc """
This is here as a callback to Application to configure and start the
Honeybadger client's dependencies. You'll likely never need to call this
function yourself.
"""
@doc false
def start(_type, _opts) do
require_environment_name!()
import Supervisor.Spec

app_config = Application.get_all_env(:honeybadger)
config = Keyword.merge(default_config(), app_config)
update_application_config!(config)
config =
get_all_env()
|> put_dynamic_env()
|> verify_environment_name!()
|> persist_all_env()

if config[:use_logger] do
:error_logger.add_report_handler(Honeybadger.Logger)
end

{Application.ensure_started(:httpoison), self()}
children = [
worker(Client, [config])
]

Supervisor.start_link(children, strategy: :one_for_one)
end

defmacro notify(exception) do
macro_notify(exception, {:%{}, [], []}, [])
def notify(exception, metadata \\ %{}, stacktrace \\ []) do
notice = Notice.new(exception,
contextual_metadata(metadata),
backtrace(stacktrace))

Client.send_notice(notice)
end

defmacro notify(exception, metadata) do
macro_notify(exception, metadata, [])
def context do
(Process.get(@context) || %{}) |> Enum.into(Map.new)
end

defmacro notify(exception, metadata, stacktrace) do
macro_notify(exception, metadata, stacktrace)
def context(keyword_or_map) do
Process.put(@context, Map.merge(context(), Enum.into(keyword_or_map, %{})))
context()
end

defp macro_notify(exception, metadata, stacktrace) do
quote do
Task.start fn ->
Honeybadger.do_notify(unquote(exception), unquote(metadata), unquote(stacktrace))
end
@doc """
Fetch configuration specific to the :honeybadger application.

## Example

Honeybadger.get_env(:exclude_envs)
#=> [:dev, :test]
"""
@spec get_env(atom) :: any | no_return
def get_env(key) when is_atom(key) do
case Application.fetch_env(:honeybadger, key) do
{:ok, {:system, var}} when is_binary(var) ->
System.get_env(var) || raise ArgumentError, "system variable #{inspect(var)} is not set"
{:ok, value} ->
value
:error ->
raise ArgumentError, "the configuration parameter #{inspect(key)} is not set"
end
end

def do_notify(exception, metadata, []) do
{:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)
do_notify(exception, metadata, stacktrace)
end
@doc """
Fetch all configuration specific to the :honeybadger application.

def do_notify(exception, %{context: _} = metadata, stacktrace) do
client = Client.new
backtrace = Backtrace.from_stacktrace(stacktrace)
notice = Notice.new(exception, metadata, backtrace)
Client.send_notice(client, notice)
end
This resolves values the same way that `get_env/1` does, so it resolves
:system tuple variables correctly.

def do_notify(exception, metadata, stacktrace) do
metadata = %{context: metadata}
do_notify(exception, metadata, stacktrace)
end
## Example

def context do
(Process.get(@context) || %{}) |> Enum.into(Map.new)
Honeybadger.get_all_env()
#=> [api_key: "12345", environment_name: "dev", ...]
"""
@spec get_all_env() :: [{atom, any}]
def get_all_env do
for {key, _value} <- Application.get_all_env(:honeybadger) do
{key, get_env(key)}
end
end

def context(keyword_or_map) do
Process.put(@context, Map.merge(context(), Enum.into(keyword_or_map, %{})))
context()
end
# Helpers

defp put_dynamic_env(config) do
hostname = fn ->
:inet.gethostname()
|> elem(1)
|> List.to_string()
end

defp default_config do
[api_key: System.get_env("HONEYBADGER_API_KEY"),
exclude_envs: [:dev, :test],
hostname: :inet.gethostname |> elem(1) |> List.to_string,
origin: "https://api.honeybadger.io",
proxy: nil,
proxy_auth: {nil, nil},
project_root: System.cwd,
use_logger: true,
notice_filter: Honeybadger.DefaultNoticeFilter,
filter: Honeybadger.DefaultFilter,
filter_keys: [:password, :credit_card],
filter_disable_url: false,
filter_disable_params: false,
filter_disable_session: false]
config
|> Keyword.put_new_lazy(:hostname, hostname)
|> Keyword.put_new_lazy(:project_root, &System.cwd/0)
end

defp require_environment_name! do
if is_nil(Application.get_env(:honeybadger, :environment_name)) do
case System.get_env("MIX_ENV") do
nil ->
raise MissingEnvironmentNameError
env ->
Application.put_env(:honeybadger, :environment_name, String.to_atom(env))
end
defp verify_environment_name!(config) do
case Keyword.get(config, :environment_name) do
nil -> raise MissingEnvironmentNameError
_ -> config
end
end

defp update_application_config!(config) do
Enum.each(config, fn({key, value}) ->
defp persist_all_env(config) do
Enum.each(config, fn {key, value} ->
Application.put_env(:honeybadger, key, value)
end)

config
end

defp backtrace([]) do
{:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)

backtrace(stacktrace)
end
defp backtrace(stacktrace) do
Backtrace.from_stacktrace(stacktrace)
end

defp contextual_metadata(%{context: _} = metadata) do
metadata
end
defp contextual_metadata(metadata) do
%{context: metadata}
end
end
2 changes: 1 addition & 1 deletion lib/honeybadger/backtrace.ex
Expand Up @@ -21,7 +21,7 @@ defmodule Honeybadger.Backtrace do
end

defp otp_app do
Application.get_env(:honeybadger, :app)
Honeybadger.get_env(:app)
end

defp get_context(app, app) when app != nil, do: "app"
Expand Down
138 changes: 96 additions & 42 deletions lib/honeybadger/client.ex
@@ -1,57 +1,111 @@
defmodule Honeybadger.Client do
alias Poison, as: JSON
@moduledoc false

defstruct [:environment_name, :headers, :hostname, :origin, :proxy, :proxy_auth]
use GenServer

require Logger

@notices_endpoint "/v1/notices"
@headers [
{"Accept", "application/json"},
{"Content-Type", "application/json"}
{"Content-Type", "application/json"},
{"User-Agent", "Honeybadger Elixir"}
]
@max_connections 20
@notices_endpoint "/v1/notices"

# State

defstruct [:enabled, :headers, :proxy, :proxy_auth, :url]

# API

def start_link(config_opts) do
state = new(config_opts)

GenServer.start_link(__MODULE__, state, [name: __MODULE__])
end

def new do
origin = Application.get_env(:honeybadger, :origin)
api_key = Application.get_env(:honeybadger, :api_key)
env_name = Application.get_env(:honeybadger, :environment_name)
hostname = Application.get_env(:honeybadger, :hostname)
proxy = Application.get_env(:honeybadger, :proxy)
proxy_auth = Application.get_env(:honeybadger, :proxy_auth)
%__MODULE__{origin: origin,
headers: headers(api_key),
environment_name: env_name,
hostname: hostname,
proxy: proxy,
proxy_auth: proxy_auth}
end

def send_notice(%__MODULE__{} = client, notice, http_mod \\ HTTPoison) do
encoded_notice = JSON.encode!(notice)
do_send_notice(client, encoded_notice, http_mod, active_environment?())
end

defp do_send_notice(_client, _encoded_notice, _http_mod, false), do: {:ok, :unsent}
defp do_send_notice(client, encoded_notice, http_mod, true) do
case client.proxy do
nil ->
http_mod.post(
client.origin <> @notices_endpoint, encoded_notice, client.headers
)
_ ->
http_mod.post(
client.origin <> @notices_endpoint, encoded_notice, client.headers,
[proxy: client.proxy, proxy_auth: client.proxy_auth]
)
@doc false
def new(opts) do
%__MODULE__{enabled: enabled?(opts),
headers: build_headers(opts),
proxy: get_env(opts, :proxy),
proxy_auth: get_env(opts, :proxy_auth),
url: get_env(opts, :origin) <> @notices_endpoint}
end

@doc false
def send_notice(notice) when is_map(notice) do
if pid = Process.whereis(__MODULE__) do
GenServer.cast(pid, {:notice, notice})
else
Logger.warn("[Honeybadger] Unable to notify, the :honeybadger client isn't running")
end
end

defp headers(api_key) do
[{"X-API-Key", api_key}] ++ @headers
# Callbacks

def init(state) do
:ok = :hackney_pool.start_pool(__MODULE__, [max_connections: @max_connections])

{:ok, state}
end

def terminate(_reason, _state) do
:ok = :hackney_pool.stop_pool(__MODULE__)
end

def active_environment? do
env = Application.get_env(:honeybadger, :environment_name)
exclude_envs = Application.get_env(:honeybadger, :exclude_envs, [:dev, :test])
not env in exclude_envs
def handle_cast({:notice, _notice}, %{enabled: false} = state) do
{:noreply, state}
end

def handle_cast({:notice, notice}, %{enabled: true} = state) do
payload = Poison.encode!(notice)

opts =
state
|> Map.take([:proxy, :proxy_auth])
|> Enum.into(Keyword.new)
|> Keyword.put(:pool, __MODULE__)

case :hackney.post(state.url, state.headers, payload, opts) do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a test app I'm trying to configure honeybadger via the HONEYBADGER_API_KEY environment variable. In a phoneix app, the app boots up fine (it determines the environment variable is present), but when it reports an error the Client GenServer is crashing with a bunch of errors like this (eventually the application shuts down instead of looping forever, which is nice):

14:37:52.948 request_id=b5a0okef3ctq36ktl0skaa4jafbreqb8 [info] GET /runtime_error
14:37:52.973 request_id=b5a0okef3ctq36ktl0skaa4jafbreqb8 [info] Sent 500 in 25ms
14:37:52.981 [error] #PID<0.458.0> running CrywolfWeb.Endpoint terminated
Server: localhost:4000 (http)
Request: GET /runtime_error
** (exit) an exception was raised:
    ** (RuntimeError) This is a runtime error generated by the crywolf app.
        (crywolf) lib/crywolf_web/controllers/page_controller.ex:23: CrywolfWeb.PageController.runtime_error/2
        (crywolf) lib/crywolf_web/controllers/page_controller.ex:1: CrywolfWeb.PageController.action/2
        (crywolf) lib/crywolf_web/controllers/page_controller.ex:1: CrywolfWeb.PageController.phoenix_controller_pipeline/2
        (crywolf) lib/crywolf_web/endpoint.ex:1: CrywolfWeb.Endpoint.instrument/4
        (phoenix) lib/phoenix/router.ex:278: Phoenix.Router.__call__/1
        (crywolf) lib/crywolf_web/router.ex:3: CrywolfWeb.Router.call/2
        (crywolf) lib/crywolf_web/endpoint.ex:1: CrywolfWeb.Endpoint.plug_builder_call/2
        (crywolf) lib/crywolf_web/endpoint.ex:1: CrywolfWeb.Endpoint.call/2
14:37:53.440 [error] GenServer Honeybadger.Client terminating
** (FunctionClauseError) no function clause matching in :hackney_headers_new.params_to_iolist/2
    (hackney) /home/josh/code/honeybadger-io/crywolf-elixir-new/deps/hackney/src/hackney_headers_new.erl:201: :hackney_headers_new.params_to_iolist("HONEYBADGER_API_KEY", [])
    (hackney) /home/josh/code/honeybadger-io/crywolf-elixir-new/deps/hackney/src/hackney_headers_new.erl:190: anonymous fn/3 in :hackney_headers_new.to_iolist/1
    (hackney) /home/josh/code/honeybadger-io/crywolf-elixir-new/deps/hackney/src/hackney_headers_new.erl:148: :hackney_headers_new.do_fold/3
    (hackney) /home/josh/code/honeybadger-io/crywolf-elixir-new/deps/hackney/src/hackney_headers_new.erl:184: :hackney_headers_new.to_iolist/1
    (hackney) /home/josh/code/honeybadger-io/crywolf-elixir-new/deps/hackney/src/hackney_request.erl:95: :hackney_request.perform/2
    (hackney) /home/josh/code/honeybadger-io/crywolf-elixir-new/deps/hackney/src/hackney.erl:358: :hackney.send_request/2
    (honeybadger) lib/honeybadger/client.ex:71: Honeybadger.Client.handle_cast/2
    (stdlib) gen_server.erl:616: :gen_server.try_dispatch/4
Last message: {:"$gen_cast", {:notice, %Honeybadger.Notice{ ... }}}

...

14:37:54.528 [warn] [Honeybadger] Unable to notify, the :honeybadger client isn't running
14:37:54.528 [info] Application honeybadger exited: shutdown
{"Kernel pid terminated",application_controller,"{application_terminated,honeybadger,shutdown}"}
Kernel pid terminated (application_controller) ({application_terminated,honeybadger,shutdown})

Crash dump is being written to: erl_crash.dump...done

(note that I omitted the contents of %Honeybadger.Notice{} in the "Last message:" line for brevity.)

Copy link
Member

@joshuap joshuap Oct 4, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this may be an issue with how we're merging the environment variables. This is what the config looks like:

[api_key: {:system, "HONEYBADGER_API_KEY"}, app: nil, environment_name: {:system, "MIX_ENV"}, exclude_envs: [:dev, :test], hostname: "zoltan", origin: "https://api.honeybadger.io", proxy: nil, proxy_auth: {nil, nil}, project_root: "/home/josh/code/honeybadger-io/crywolf-elixir-new", use_logger: true, notice_filter: Honeybadger.DefaultNoticeFilter, filter: Honeybadger.DefaultFilter, filter_keys: [:password, :credit_card], filter_disable_url: false, filter_disable_params: false, filter_disable_session: false, included_applications: []]

Edit: Since the tests didn't cover this, it would be awesome if you can add a test case!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was due to the use of Application.get_all_env, which didn't make any use of Honeybadger.get_env/1. I've added a new function, Honeybadger.get_all_env/0 that takes care of this automatically—and is tested.

{:ok, code, _headers, ref} when code >= 200 and code <= 399 ->
Logger.debug("[Honeybadger] API success: #{inspect(body_from_ref(ref))}")
{:ok, code, _headers, ref} when code >= 400 and code <= 504 ->
Logger.error("[Honeybadger] API failure: #{inspect(body_from_ref(ref))}")
{:error, reason} ->
Logger.error("[Honeybadger] connection error: #{inspect(reason)}")
end

{:noreply, state}
end

def handle_info(message, state) do
Logger.info("[Honeybadger] unexpected message: #{inspect(message)}")

{:noreply, state}
end

# Helpers

def enabled?(opts) do
env_name = get_env(opts, :environment_name)
excluded = get_env(opts, :exclude_envs)

not env_name in excluded
end

defp body_from_ref(ref) do
ref
|> :hackney.body()
|> elem(1)
end

defp build_headers(opts) do
[{"X-API-Key", get_env(opts, :api_key)}] ++ @headers
end

defp get_env(opts, key) do
Keyword.get(opts, key, Honeybadger.get_env(key))
end
end