%%%------------------------------------------------------------------- %%% @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(LOG_DIR, "endpoint_log"). -define(LOG_FILE, "unmatched_publish.log"). -define(DEFAULT_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 = iot_log: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() -> Path = log_path(), ok = filelib:ensure_dir(Path), case disk_log:open([ {name, ?LOG_NAME}, {file, Path}, {type, wrap}, {format, external}, {size, {?DEFAULT_MAX_BYTES, ?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_path() -> file:filename_all(). log_path() -> filename:join([root_dir(), ?LOG_DIR, ?LOG_FILE]). -spec root_dir() -> file:filename_all(). root_dir() -> case application:get_env(iot, endpoints) of {ok, Endpoints} -> proplists:get_value(root_dir, Endpoints, ?DEFAULT_ROOT_DIR); undefined -> ?DEFAULT_ROOT_DIR end. -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() -> {{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", [Year, Month, Day, Hour, Minute, Second] )). -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}.