Current section

Files

Jump to
logger_webhook_h src logger_webhook_h.erl
Raw

src/logger_webhook_h.erl

%%%-------------------------------------------------------------------
%% @doc Webhook logger
%%
%% This logging handler sends reports to a webhook with a provided URL
%%
%% It uses multipart/form-data, so make sure the receiver supports this.
%% It's been tested to work fine with Discord.
%%
%% It requires certifi: https://hex.pm/packages/certifi
%%
%% Shell examples:
%% ```
%% % Sends each report as a report.txt file
%% logger:add_handler(error_webhook, logger_webhook_h, #{
%% config => #{ url => "https://discord.com/api/webhooks/12345/fanchyschmancyid" },
%% level => warning,
%% formatter => {logger_formatter, #{ single_line => false }}
%% }).
%%
%% % Sends each report as a chat message in markdown code blocks
%% logger:add_handler(error_webhook, logger_webhook_h, #{
%% config => #{
%% url => "https://discord.com/api/webhooks/12345/fanchyschmancyid",
%% delivery => content
%% },
%% level => warning,
%% formatter => {logger_formatter, #{
%% template => ["```\n",time," ",level,":\n",msg,"\n```"],
%% single_line => false
%% }}
%% }).
%% '''
%% sys.config example:
%% ```
%% [
%% {kernel, [
%% {logger, [
%% {handler, error_webhook, logger_webhook_h,
%% #{ config => #{ url => "https://discord.com/api/webhooks/12345/fanchyschmancyid" },
%% level => warning,
%% formatter => {logger_formatter, #{ single_line => false }}
%% ]}
%% ]}
%% ].
%% '''
%%
%% @author nixx quality <nixx@is-fantabulo.us>
%% @copyright Public Domain
%% @end
%%%-------------------------------------------------------------------
-module(logger_webhook_h).
-export([
adding_handler/1,
changing_config/3,
log/2
]).
verify_config(Config) ->
try
DefinedUrl = maps:get(url, Config),
true = is_list(DefinedUrl),
Method = maps:get(method, Config, post),
% these methods have the {Url, Headers, ContentType, Body} form of Request
% anything else will cause a runtime error
% https://github.com/erlang/otp/blob/868baa8859f1118cfb7c03c6f131e54f4dd3c1fe/lib/inets/src/http_client/httpc.erl#L164
case Method of
post -> ok;
patch -> ok;
put -> ok;
delete -> ok
end,
Delivery = maps:get(delivery, Config, file),
case Delivery of
file -> ok;
content -> ok
end
of
_ -> true
catch
_:_ -> false
end.
adding_handler(HandlerConfig) ->
case verify_config(maps:get(config, HandlerConfig, #{})) of
true ->
{ok, HandlerConfig};
false ->
{error, bad_config}
end.
changing_config(set, _OldConfig, NewConfig) ->
case verify_config(maps:get(config, NewConfig, #{})) of
true -> {ok, NewConfig};
false -> {error, bad_config}
end;
changing_config(update, OldConfig, NewConfigParts) ->
NewConfig = maps:merge(OldConfig, NewConfigParts),
case verify_config(maps:get(config, NewConfig, #{})) of
true -> {ok, NewConfig};
false -> {error, bad_config}
end.
log(LogEvent, HandlerConfig) ->
InetsRunning = whereis(httpc_manager) /= undefined,
SSLRunning = whereis(tls_connection_sup) /= undefined,
case InetsRunning andalso SSLRunning of
true ->
io:format("sending request~n"),
send_request(LogEvent, HandlerConfig);
false ->
ok
end.
% references:
% https://discord.com/developers/docs/resources/webhook#execute-webhook
% https://gist.github.com/ArthurClemens/dbd70f9b7a4342810d923670a9db0f39
% https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST
send_request(LogEvent, #{ config := Config, formatter := {FM, FC} } = _HandlerConfig) ->
Method = maps:get(method, Config, post),
URL = maps:get(url, Config),
Message = FM:format(LogEvent, FC),
Boundary = generate_uuid(),
LineSeparator = <<"\r\n">>,
ContentDisposition = case maps:get(delivery, Config, file) of
file -> "name=\"file\"; filename=\"report.txt\"";
content -> "name=\"content\""
end,
RequestBody = iolist_to_binary([
"--", Boundary, LineSeparator,
"Content-Disposition: form-data; ", ContentDisposition, LineSeparator,
LineSeparator,
Message, LineSeparator,
"--", Boundary, "--"
]),
ContentType = binary_to_list(iolist_to_binary(["multipart/form-data; boundary=", Boundary])),
ContentLength = integer_to_list(byte_size(RequestBody)),
Headers = [
{"Content-Length", ContentLength}
],
HTTPOptions = [{ssl, [
{verify, verify_peer},
{cacerts, certifi:cacerts()},
{depth, 99},
{customize_hostname_check, [
{match_fun, public_key:pkix_verify_hostname_match_fun(https)}
]}
]}],
Options = [{body_format, binary}],
{ok, {{_, Code, _}, _, _}} = httpc:request(Method, {URL, Headers, ContentType, RequestBody}, HTTPOptions, Options),
true = Code >= 200 andalso Code < 300.
% Thanks to https://github.com/afiskon/erlang-uuid-v4
generate_uuid() ->
<<A:32, B:16, C:16, D:16, E:48>> = crypto:strong_rand_bytes(16),
io_lib:format("~8.16.0b-~4.16.0b-4~3.16.0b-~4.16.0b-~12.16.0b",
[A, B, C band 16#0fff, D band 16#3fff bor 16#8000, E]).