%%%------------------------------------------------------------------- %%% @doc Endpoint diagnostic log. %%% %%% 当前用于记录 publish 时没有匹配到任何订阅者的数据,避免这类信息进入 %%% endpoint_subscription 的热路径同步磁盘写入。 %%%------------------------------------------------------------------- -module(endpoint_log). -behaviour(gen_server). %% API -export([start_link/0, unmatched_publish/2]). %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). -define(SERVER, ?MODULE). -define(LOG_NAME, endpoint_unmatched_publish_log). -define(DEFAULT_PATH_TEMPLATE, "${endpoint_root}/endpoint_log/unmatched_publish.log"). -define(DEFAULT_ENDPOINT_ROOT_DIR, "log"). -define(DEFAULT_MAX_BYTES, 10485760). -define(DEFAULT_MAX_FILES, 10). -define(MAX_LOG_CONTENT_BYTES, 32 * 1024). -record(state, { enabled = false :: boolean(), log_name = ?LOG_NAME :: atom() }). %%%=================================================================== %%% API %%%=================================================================== -spec start_link() -> {ok, pid()} | ignore | {error, term()}. start_link() -> gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). -spec unmatched_publish(RouteKey :: binary(), Content :: binary()) -> ok. unmatched_publish(RouteKey, Content) when is_binary(RouteKey), is_binary(Content) -> case erlang:whereis(?SERVER) of undefined -> ok; Pid when is_pid(Pid) -> gen_server:cast(Pid, {unmatched_publish, RouteKey, Content}), ok end. %%%=================================================================== %%% gen_server callbacks %%%=================================================================== -spec init(term()) -> {ok, #state{}}. init([]) -> ok = endpoint_util:set_metadata(), case open_log() of ok -> {ok, #state{enabled = true}}; {error, Reason} -> logger:warning("[endpoint_log] open disk_log failed, reason: ~p", [Reason]), {ok, #state{enabled = false}} end. -spec handle_call(term(), gen_server:from(), #state{}) -> {reply, ok, #state{}}. handle_call(_Request, _From, State) -> {reply, ok, State}. -spec handle_cast(term(), #state{}) -> {noreply, #state{}}. handle_cast({unmatched_publish, RouteKey, Content}, State = #state{enabled = true, log_name = LogName}) -> Entry = encode_unmatched_publish(RouteKey, Content), case disk_log:blog(LogName, Entry) of ok -> {noreply, State}; {error, Reason} -> logger:warning("[endpoint_log] write unmatched publish log failed, reason: ~p", [Reason]), {noreply, State#state{enabled = false}} end; handle_cast({unmatched_publish, _RouteKey, _Content}, State) -> {noreply, State}; handle_cast(_Request, State) -> {noreply, State}. -spec handle_info(term(), #state{}) -> {noreply, #state{}}. handle_info(Info, State) -> logger:debug("[endpoint_log] unknown info: ~p", [Info]), {noreply, State}. -spec terminate(term(), #state{}) -> ok. terminate(_Reason, #state{enabled = true, log_name = LogName}) -> _ = disk_log:close(LogName), ok; terminate(_Reason, _State) -> ok. -spec code_change(term() | {down, term()}, #state{}, term()) -> {ok, #state{}} | {error, term()}. code_change(_OldVsn, State = #state{}, _Extra) -> {ok, State}. %%%=================================================================== %%% Internal functions %%%=================================================================== -spec open_log() -> ok | {error, term()}. open_log() -> Config = log_config(), Path = log_path(Config), ok = filelib:ensure_dir(Path), case disk_log:open([ {name, ?LOG_NAME}, {file, Path}, {type, wrap}, {format, external}, {size, {config_pos_integer(max_bytes, Config, ?DEFAULT_MAX_BYTES), config_pos_integer(max_files, Config, ?DEFAULT_MAX_FILES)}}, {linkto, self()}, {repair, true} ]) of {ok, ?LOG_NAME} -> ok; {repaired, ?LOG_NAME, _Recovered, _BadBytes} -> ok; {error, Reason} -> {error, Reason} end. -spec log_config() -> proplists:proplist(). log_config() -> case application:get_env(endpoint, endpoint_log) of {ok, Config} when is_list(Config) -> Config; _ -> [] end. -spec log_path(proplists:proplist()) -> file:filename_all(). log_path(Config) -> Template = proplists:get_value(path, Config, ?DEFAULT_PATH_TEMPLATE), expand_path_template(Template). -spec endpoint_root_dir() -> file:filename_all(). endpoint_root_dir() -> case application:get_env(endpoint, endpoints) of {ok, Endpoints} -> proplists:get_value(root_dir, Endpoints, ?DEFAULT_ENDPOINT_ROOT_DIR); undefined -> ?DEFAULT_ENDPOINT_ROOT_DIR end. -spec config_pos_integer(atom(), proplists:proplist(), pos_integer()) -> pos_integer(). config_pos_integer(Key, Config, Default) -> case proplists:get_value(Key, Config, Default) of Value when is_integer(Value), Value > 0 -> Value; _ -> Default end. -spec expand_path_template(file:filename_all()) -> file:filename_all(). expand_path_template(Template) -> TemplateBin = iolist_to_binary(Template), Vars = path_vars(), Expanded = lists:foldl(fun({Name, Value}, Acc) -> replace_path_var(Name, Value, Acc) end, TemplateBin, Vars), binary_to_list(Expanded). -spec path_vars() -> [{binary(), binary()}]. path_vars() -> {{Year, Month, Day}, {Hour, _Minute, _Second}} = calendar:local_time(), [ {<<"endpoint_root">>, iolist_to_binary(endpoint_root_dir())}, {<<"date">>, format_date(Year, Month, Day)}, {<<"year">>, integer_to_binary(Year)}, {<<"month">>, two_digits(Month)}, {<<"day">>, two_digits(Day)}, {<<"hour">>, two_digits(Hour)}, {<<"node">>, atom_to_binary(node(), utf8)} ]. -spec replace_path_var(binary(), binary(), binary()) -> binary(). replace_path_var(Name, Value, Template) -> Template0 = binary:replace(Template, <<"${", Name/binary, "}">>, Value, [global]), binary:replace(Template0, <<"{", Name/binary, "}">>, Value, [global]). -spec encode_unmatched_publish(binary(), binary()) -> binary(). encode_unmatched_publish(RouteKey, Content) -> {LoggedContent, Truncated} = maybe_truncate(Content), iolist_to_binary([ format_local_time(), <<"\t">>, <<"unmatched_publish">>, <<"\t">>, RouteKey, <<"\t">>, integer_to_binary(byte_size(Content)), <<"\t">>, boolean_to_binary(Truncated), <<"\t">>, base64:encode(LoggedContent), <<"\n">> ]). -spec boolean_to_binary(boolean()) -> binary(). boolean_to_binary(true) -> <<"true">>; boolean_to_binary(false) -> <<"false">>. -spec format_local_time() -> binary(). format_local_time() -> MilliSeconds = erlang:system_time(millisecond) rem 1000, {{Year, Month, Day}, {Hour, Minute, Second}} = calendar:local_time(), iolist_to_binary(io_lib:format( "~4..0B-~2..0B-~2..0B ~2..0B:~2..0B:~2..0B.~3..0B", [Year, Month, Day, Hour, Minute, Second, MilliSeconds] )). -spec format_date(integer(), integer(), integer()) -> binary(). format_date(Year, Month, Day) -> iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Year, Month, Day])). -spec two_digits(integer()) -> binary(). two_digits(Value) -> iolist_to_binary(io_lib:format("~2..0B", [Value])). -spec maybe_truncate(binary()) -> {binary(), boolean()}. maybe_truncate(Content) when byte_size(Content) =< ?MAX_LOG_CONTENT_BYTES -> {Content, false}; maybe_truncate(Content) -> MaxBytes = ?MAX_LOG_CONTENT_BYTES, <> = Content, {LoggedContent, true}.