%%%------------------------------------------------------------------- %% @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 %% @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() -> <> = 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]).