src/grisp_connect_log.erl

%% @doc Utility module to handle logs.
%% @end
-module(grisp_connect_log).

-behaviour(logger_formatter).

-include_lib("kernel/include/logger.hrl").

% API functions
-export([get/1]).
-export([sync/1]).

% Behaviour logger_formatter callback functions
-export([check_config/1]).
-export([format/2]).

%--- Macros --------------------------------------------------------------------

% FixMe:
% Sending over ~30_000 bytes over WS breaks rtems I/O driver.
% We want avoid to return chunks that are bigger then that.
-define(MAX_CHUNK_BYTES, 30_000).

%--- Types ---------------------------------------------------------------------

-type get_options() :: #{
    max_batch_size => non_neg_integer(),
    max_byte_size => non_neg_integer()
}.

-type sync_options() :: #{
    seq := non_neg_integer(),
    dropped => non_neg_integer()
}.


%--- API Functions -------------------------------------------------------------

-spec get(Opts :: get_options()) ->
    #{dropped := non_neg_integer(), events := list()}.
get(Opts) ->
    {ok, DefaultSize} = application:get_env(grisp_connect, logs_batch_size),
    BatchSize = maps:get(max_batch_size, Opts, DefaultSize),
    ByteSize = min(maps:get(max_byte_size, Opts, ?MAX_CHUNK_BYTES), ?MAX_CHUNK_BYTES),
    {Events, Dropped} = grisp_connect_logger_bin:chunk(BatchSize, ByteSize),
    #{events => [[Seq, jsonify(E)] || {Seq, E} <- Events],
      dropped => Dropped}.

-spec sync(Opts :: sync_options()) -> ok.
sync(#{seq := Seq, dropped := Dropped}) ->
    grisp_connect_logger_bin:sync(Seq, Dropped).


%--- Beahviour logger_formatter Callback Functions -----------------------------

check_config(Config = #{description_only := Bool})
  when is_boolean(Bool) ->
    logger_formatter:check_config(maps:remove(description_only, Config));
check_config(#{description_only := V}) ->
    {error, {invalid_formatter_config, ?MODULE, {description_only, V}}};
check_config(Config) ->
    logger_formatter:check_config(Config).

format(LogEvent = #{msg := {report, #{description := Desc}}},
       Config = #{description_only := true}) when is_binary(Desc) ->
    logger_formatter:format(LogEvent#{msg := {string, Desc}},
                            maps:remove(description_only, Config));
format(LogEvent, Config) ->
    logger_formatter:format(LogEvent, maps:remove(description_only, Config)).


%--- Internal Functions --------------------------------------------------------

jsonify(Event) ->
    jsonify_meta(jsonify_msg(binary_to_term(base64:decode(Event)))).

jsonify_msg(#{msg := {string, String}} = Event) ->
    maps:put(msg, unicode:characters_to_binary(String), Event);
jsonify_msg(#{msg := {report, Report}} = Event) ->
    case is_json_compatible(Report) of
        true ->
            maps:put(msg, Report, Event);
        false ->
            String = unicode:characters_to_binary(
                       io_lib:format("~tp", [Report])),
            Meta = maps:get(meta, Event, #{}),
            Event2 = Event#{meta => Meta#{incompatible_term => true}},
            maps:put(msg, String, Event2)
    end;
jsonify_msg(#{msg := {FormatString, Term}} = Event) ->
    %FIXME: scan format and ensure unicode encoding
    String = unicode:characters_to_binary(io_lib:format(FormatString, Term)),
    maps:put(msg, String, Event).

jsonify_meta(#{meta := Meta} = Event) ->
    MFA = case maps:is_key(mfa, Meta) of
              true ->
                  {M, F, A} = maps:get(mfa, Meta),
                  [M, F, A];
              false ->
                  null
          end,
    File = case maps:is_key(file, Meta) of
               true  -> unicode:characters_to_binary(maps:get(file, Meta));
               false -> null
           end,
    Default = #{mfa => MFA, file => File},
    Optional = maps:without(maps:keys(Default), Meta),
    FilterFun = fun(Key, Value) -> jsx:is_term(#{Key => Value}) end,
    maps:put(meta, maps:merge(maps:filter(FilterFun, Optional), Default), Event).

is_json_compatible(Term) ->
    try jsx:is_term(Term)
    catch error:_ ->
        false
    end.