From 733208c681bd3cdb1f6fea51ce6a838d73ec1fc5 Mon Sep 17 00:00:00 2001 From: Bartek Gorny Date: Tue, 28 Feb 2023 11:48:10 +0100 Subject: [PATCH 1/8] traffic_monitor --- rebar.config | 1 + rel/fed1.vars-toml.config | 1 + rel/files/mongooseim.toml | 14 ++ rel/files/tracer_connect.js | 5 + rel/mim1.vars-toml.config | 1 + rel/mim2.vars-toml.config | 1 + rel/mim3.vars-toml.config | 1 + rel/reg1.vars-toml.config | 1 + src/c2s/mongoose_c2s.erl | 6 + src/hooks/mongoose_hooks.erl | 7 + src/mongoose_debug.erl | 58 +++++ src/mongoose_traffic.erl | 110 +++++++++ src/mongoose_traffic_channel.erl | 246 ++++++++++++++++++++ web/traffic/Makefile | 2 + web/traffic/README.md | 60 +++++ web/traffic/elm.json | 24 ++ web/traffic/js/app.js | 111 +++++++++ web/traffic/session.css | 104 +++++++++ web/traffic/session.html | 18 ++ web/traffic/src/Traffic.elm | 376 +++++++++++++++++++++++++++++++ 20 files changed, 1147 insertions(+) create mode 100644 rel/files/tracer_connect.js create mode 100644 src/mongoose_debug.erl create mode 100644 src/mongoose_traffic.erl create mode 100644 src/mongoose_traffic_channel.erl create mode 100644 web/traffic/Makefile create mode 100644 web/traffic/README.md create mode 100644 web/traffic/elm.json create mode 100644 web/traffic/js/app.js create mode 100644 web/traffic/session.css create mode 100644 web/traffic/session.html create mode 100644 web/traffic/src/Traffic.elm diff --git a/rebar.config b/rebar.config index d0eaebbf838..a931cee1cda 100644 --- a/rebar.config +++ b/rebar.config @@ -145,6 +145,7 @@ {copy, "rel/files/scripts", "./"}, {copy, "rel/files/templates", "./"}, {copy, "rel/files/templates.ini", "etc/templates.ini"}, + {copy, "web", "web"}, {template, "rel/files/nodetool", "erts-\{\{erts_vsn\}\}/bin/nodetool"}, diff --git a/rel/fed1.vars-toml.config b/rel/fed1.vars-toml.config index 34c4b2420f3..b2c1409d203 100644 --- a/rel/fed1.vars-toml.config +++ b/rel/fed1.vars-toml.config @@ -12,6 +12,7 @@ {http_graphql_api_user_endpoint_port, 5566}. {http_api_endpoint_port, 5294}. {http_api_client_endpoint_port, 8095}. +{traffic_channel_port, 5115}. %% This node is for s2s testing. %% "localhost" host should NOT be defined. diff --git a/rel/files/mongooseim.toml b/rel/files/mongooseim.toml index e66f67b7a96..1fc17ff54cc 100644 --- a/rel/files/mongooseim.toml +++ b/rel/files/mongooseim.toml @@ -38,6 +38,18 @@ host = "_" path = "/ws-xmpp" +# [[listen.http]] +# port = {{traffic_channel_port}} +# transport.num_acceptors = 2 +# +# [[listen.http.handlers.mongoose_traffic_channel]] +# host = "_" +# path = "/ws-traffic" +# +# [[listen.http.handlers.mongoose_traffic]] +# host = "_" +# path = "/traffic/[...]" + [[listen.http]] port = {{{https_port}}} transport.num_acceptors = 10 @@ -199,6 +211,8 @@ {{/service_domain_db}} [modules.mod_adhoc] +# [modules.mongoose_traffic] + {{#mod_amp}} [modules.mod_amp] {{{mod_amp}}} diff --git a/rel/files/tracer_connect.js b/rel/files/tracer_connect.js new file mode 100644 index 00000000000..078136db30c --- /dev/null +++ b/rel/files/tracer_connect.js @@ -0,0 +1,5 @@ + +function open_websocket() { + socket = new WebSocket("ws://localhost:{{{ traffic_channel_port}}}/ws-traffic") + return socket +} diff --git a/rel/mim1.vars-toml.config b/rel/mim1.vars-toml.config index 6c909e77e5a..7c059d25086 100644 --- a/rel/mim1.vars-toml.config +++ b/rel/mim1.vars-toml.config @@ -16,6 +16,7 @@ {http_graphql_api_user_endpoint_port, 5561}. {http_api_endpoint_port, 8088}. {http_api_client_endpoint_port, 8089}. +{traffic_channel_port, 5111}. {hosts, "\"localhost\", \"anonymous.localhost\", \"localhost.bis\""}. {host_types, "\"test type\", \"dummy auth\", \"anonymous\""}. diff --git a/rel/mim2.vars-toml.config b/rel/mim2.vars-toml.config index ef2992d590a..3636d73a80b 100644 --- a/rel/mim2.vars-toml.config +++ b/rel/mim2.vars-toml.config @@ -14,6 +14,7 @@ {http_graphql_api_admin_endpoint_port, 5552}. {http_graphql_api_domain_admin_endpoint_port, 5542}. {http_graphql_api_user_endpoint_port, 5562}. +{traffic_channel_port, 5112}. {hosts, "\"localhost\", \"anonymous.localhost\", \"localhost.bis\""}. {host_types, "\"test type\", \"dummy auth\""}. diff --git a/rel/mim3.vars-toml.config b/rel/mim3.vars-toml.config index 7583766b781..56ecf83150c 100644 --- a/rel/mim3.vars-toml.config +++ b/rel/mim3.vars-toml.config @@ -13,6 +13,7 @@ {http_graphql_api_user_endpoint_port, 5563}. {http_api_endpoint_port, 8092}. {http_api_client_endpoint_port, 8193}. +{traffic_channel_port, 5113}. "./vars-toml.config". diff --git a/rel/reg1.vars-toml.config b/rel/reg1.vars-toml.config index da485e138c5..6209b9ceeea 100644 --- a/rel/reg1.vars-toml.config +++ b/rel/reg1.vars-toml.config @@ -13,6 +13,7 @@ {http_graphql_api_user_endpoint_port, 5564}. {http_api_endpoint_port, 8074}. {http_api_client_endpoint_port, 8075}. +{traffic_channel_port, 5114}. %% This node is for global distribution testing. %% reg is short for region. diff --git a/src/c2s/mongoose_c2s.erl b/src/c2s/mongoose_c2s.erl index f45532bf4a2..880ffa14a87 100644 --- a/src/c2s/mongoose_c2s.erl +++ b/src/c2s/mongoose_c2s.erl @@ -239,6 +239,10 @@ handle_socket_packet(StateData = #c2s_data{parser = Parser}, Packet) -> NextEvent = {next_event, internal, #xmlstreamerror{name = iolist_to_binary(Reason)}}, {keep_state, StateData, NextEvent}; {ok, NewParser, XmlElements} -> + lists:foreach(fun(Element) -> + mongoose_hooks:c2s_debug(no_acc, + {client_to_server, StateData#c2s_data.jid, Element}) + end, XmlElements), Size = iolist_size(Packet), NewStateData = StateData#c2s_data{parser = NewParser}, handle_socket_elements(NewStateData, XmlElements, Size) @@ -1009,9 +1013,11 @@ maybe_send_xml(StateData, Acc, ToSend) -> -spec do_send_element(data(), mongoose_acc:t(), exml:element()) -> mongoose_acc:t(). do_send_element(StateData = #c2s_data{host_type = undefined}, Acc, El) -> + mongoose_hooks:c2s_debug(Acc, {server_to_client, StateData#c2s_data.jid, El}), send_xml(StateData, El), Acc; do_send_element(StateData = #c2s_data{host_type = HostType}, Acc, #xmlel{} = El) -> + mongoose_hooks:c2s_debug(Acc, {server_to_client, StateData#c2s_data.jid, El}), Res = send_xml(StateData, El), Acc1 = mongoose_acc:set(c2s, send_result, Res, Acc), mongoose_hooks:xmpp_send_element(HostType, Acc1, El). diff --git a/src/hooks/mongoose_hooks.erl b/src/hooks/mongoose_hooks.erl index 9fe33142f79..bb1ff14394e 100644 --- a/src/hooks/mongoose_hooks.erl +++ b/src/hooks/mongoose_hooks.erl @@ -47,6 +47,7 @@ -export([get_pep_recipients/2, filter_pep_recipient/3, c2s_stream_features/3, + c2s_debug/2, check_bl_c2s/1, forbidden_session_hook/3, session_opening_allowed_for_user/2]). @@ -535,6 +536,12 @@ sasl2_start(HostType, Acc, Element) -> sasl2_success(HostType, Acc, Params) -> run_hook_for_host_type(sasl2_success, HostType, Acc, Params). +-spec c2s_debug(Acc, Arg) -> mongoose_acc:t() when + Acc :: mongoose_acc:t() | no_acc, + Arg :: {out, jid:jid() | undefined, exml:element()}| {in, exml:element()}. +c2s_debug(Acc, Arg) -> + run_global_hook(c2s_debug, Acc, #{arg => Arg}). + -spec check_bl_c2s(IP) -> Result when IP :: inet:ip_address(), Result :: boolean(). diff --git a/src/mongoose_debug.erl b/src/mongoose_debug.erl new file mode 100644 index 00000000000..85c7148ba0e --- /dev/null +++ b/src/mongoose_debug.erl @@ -0,0 +1,58 @@ +-module(mongoose_debug). + +%% The most simple use case possible is: +%% - add [modules.mongoose_debug] to mongooseim.toml +%% - from erlang shell, run recon_trace:calls([{mongoose_debug, traffic, '_'}], 100, [{scope, local}]). +%% - watch all the traffic coming in and out + +-behaviour(gen_mod). + +-include("mongoose.hrl"). +-include("jlib.hrl"). +-include_lib("exml/include/exml_stream.hrl"). + +%% API +-export([start/2, stop/1]). +-export([trace_traffic/3]). +-export([supported_features/0]). + +start(Host, _Opts) -> + gen_hook:add_handlers(hooks(Host)), + ok. + +stop(Host) -> + gen_hook:delete_handlers(hooks(Host)), + ok. + +hooks(_Host) -> + [{c2s_debug, global, fun ?MODULE:trace_traffic/3, #{}, 50}]. + + +trace_traffic(Acc, #{arg := {client_to_server, From, El}}, _) -> + Sfrom = binary_to_list(maybe_jid_to_binary(From)), + Sto = binary_to_list(get_attr(El, <<"to">>)), + St = exml:to_binary(El), + Marker = " C >>>> MiM ", + traffic(Sfrom, Marker, Sto, St), + {ok, Acc}; +trace_traffic(Acc, #{arg := {server_to_client, To, El}}, _) -> + Sto = binary_to_list(maybe_jid_to_binary(To)), + Sfrom = binary_to_list(get_attr(El, <<"from">>)), + St = exml:to_binary(El), + Marker = " C <<<< MiM ", + traffic(Sfrom, Marker, Sto, St), + {ok, Acc}. + +traffic(_Sender, _Marker, _Recipient, _Stanza) -> ok. + +supported_features() -> [dynamic_domains]. + +maybe_jid_to_binary(undefined) -> <<" ">>; +maybe_jid_to_binary(J) -> jid:to_binary(J). + +get_attr(#xmlstreamstart{attrs = AttrList}, AttrName) -> + proplists:get_value(AttrName, AttrList, <<" ">>); +get_attr(#xmlstreamend{}, _) -> + <<" ">>; +get_attr(El, AttrName) -> + exml_query:attr(El, AttrName, <<" ">>). diff --git a/src/mongoose_traffic.erl b/src/mongoose_traffic.erl new file mode 100644 index 00000000000..39de27af294 --- /dev/null +++ b/src/mongoose_traffic.erl @@ -0,0 +1,110 @@ +-module(mongoose_traffic). + +-behaviour(gen_mod). +-behaviour(gen_server). + +-include("mongoose.hrl"). +-include("jlib.hrl"). + +%% gen_mod API +-export([start/2, stop/1]). +-export([supported_features/0]). +%% hook handler +-export([trace_traffic/3]). +%% gen_server +-export([init/1, handle_call/3, handle_cast/2, handle_info/2]). +%% cowboy handler for serving main page +-export([init/2]). + +-define(SERVER, ?MODULE). + + +start(HostType, _Opts) -> + gen_hook:add_handlers(hooks(HostType)), + case whereis(?MODULE) of + undefined -> + Traffic = {mongoose_traffic, + {gen_server, start_link, [?MODULE, [], []]}, + permanent, 1000, supervisor, [?MODULE]}, + % there has to be another layer + % channel will set up its own traces, someone has to watch and distribute stanzas + ejabberd_sup:start_child(Traffic); + _ -> + ok + end, + ok. + +stop(Host) -> + gen_hook:delete_handlers(hooks(Host)), + supervisor:terminate_child(ejabberd_sup, ?MODULE), + supervisor:delete_child(ejabberd_sup, ?MODULE), + ok. + +hooks(_HostType) -> + [{c2s_debug, global, fun ?MODULE:trace_traffic/3, #{}, 50}]. + +supported_features() -> [dynamic_domains]. + +trace_traffic(Acc, #{arg := {client_to_server, From, El}}, _) -> + traffic(client_to_server, From, El), + {ok, Acc}; +trace_traffic(Acc, #{arg := {server_to_client, To, El}}, _) -> + traffic(server_to_client, To, El), + {ok, Acc}. + +traffic(Dir, Jid, El) -> + St = iolist_to_binary(fix_and_format(El)), + UserPid = self(), + gen_server:cast(?MODULE, {message, Dir, {UserPid, Jid}, St}), + ok. + + +init([]) -> + register(?MODULE, self()), + {ok, []}. + +handle_call({register, Pid}, _From, State) -> + monitor(process, Pid), + {reply, ok, [Pid | State]}; +handle_call({unregister, Pid}, _From, State) -> + {reply, ok, lists:delete(Pid, State)}; +handle_call(_, _, State) -> + {reply, ok, State}. + +handle_cast({message, _, _, _} = Msg, State) -> + lists:map(fun(Pid) -> Pid ! Msg end, State), + {noreply, State}. + +handle_info({'DOWN', _, _, Pid, _}, State) -> + {noreply, lists:delete(Pid, State)}. + +init(Req, State) -> + {ok, Cwd} = file:get_cwd(), + Base = Cwd ++ "/web/traffic", + File = case cowboy_req:path_info(Req) of + [] -> "session.html"; + P -> filename:join(P) + end, + Path = filename:join(Base, File), + Size = filelib:file_size(Path), + Req1 = cowboy_req:reply(200, + #{}, + {sendfile, 0, Size, Path}, Req), + {ok, Req1, State}. + +fix_and_format(El) when is_binary(El) -> + El; +fix_and_format({xmlstreamend, _}) -> + <<"">>; +fix_and_format({Tag, Name}) -> + exml:to_pretty_iolist({Tag, Name, []}); +fix_and_format({Tag, Name, Attrs}) -> + exml:to_pretty_iolist({Tag, Name, fix_attrs(Attrs)}); +fix_and_format({Tag, Name, Attrs, Children}) -> + exml:to_pretty_iolist({Tag, Name, fix_attrs(Attrs), Children}). + +fix_attrs(Attrs) -> + lists:filter(fun is_defined/1, Attrs). + +is_defined({_, undefined}) -> false; +is_defined({_, _}) -> true. diff --git a/src/mongoose_traffic_channel.erl b/src/mongoose_traffic_channel.erl new file mode 100644 index 00000000000..90b0c9073c8 --- /dev/null +++ b/src/mongoose_traffic_channel.erl @@ -0,0 +1,246 @@ +%%%=================================================================== +%%% @copyright (C) 2016, Erlang Solutions Ltd. +%%% @doc Module providing support for websockets in MongooseIM +%%% @end +%%%=================================================================== +-module(mongoose_traffic_channel). + +-behaviour(cowboy_websocket). + +%% cowboy_http_websocket_handler callbacks +-export([init/2, + websocket_init/1, + websocket_handle/2, + websocket_info/2, + terminate/3]). + +-include("mongoose.hrl"). +-include("jlib.hrl"). + +-define(LISTENER, ?MODULE). +-define(MAX_ITEMS, 500). +-define(MAX_TRACED, 100). + +-record(state, {traces = #{}, + tracing = false, + current = <<>>, + mappings = #{}, + start_times = #{}}). + +%%-------------------------------------------------------------------- +%% Common callbacks for all cowboy behaviours +%%-------------------------------------------------------------------- + +init(Req, Opts) -> + Peer = cowboy_req:peer(Req), + PeerCert = cowboy_req:cert(Req), + ?DEBUG("cowboy init: ~p~n", [{Req, Opts}]), + AllModOpts = [{peer, Peer}, {peercert, PeerCert} | Opts], + %% upgrade protocol + {cowboy_websocket, Req, AllModOpts, #{}}. + +terminate(_Reason, _Req, _State) -> + ok. + +%%-------------------------------------------------------------------- +%% cowboy_http_websocket_handler callbacks +%%-------------------------------------------------------------------- + +% Called for every new websocket connection. +websocket_init(Opts) -> + ?DEBUG("websocket_init: ~p~n", [Opts]), + gen_server:call(mongoose_traffic, {register, self()}), + {ok, #state{}}. + +% Called when a text message arrives. +websocket_handle({text, Msg}, State) -> + case handle(jiffy:decode(Msg), State) of + {Event, State1} -> + {reply, reply(Event), State1}; + {Event, Payload, State1} -> + {reply, reply(Event, Payload), State1} + end; + +websocket_handle({binary, Msg}, State) -> + ?DEBUG("Received binary: ~p", [Msg]), + {ok, State}; + +% With this callback we can handle other kind of +% messages, like binary. +websocket_handle(Any, State) -> + ?DEBUG("Received non-text: ~p", [Any]), + {ok, State}. + +% Other messages from the system are handled here. +websocket_info({message, _Dir, _J, _Stanza}, #state{tracing = false} = State) -> + {ok, State}; +websocket_info({message, Dir, {Pid, Jid}, Stanza} = Message, State) -> + Spid = pid_to_binary(Pid), + Now = now_seconds(), + {Traces1, Mappings, IsNewMapping} = record_item(Now, + Dir, + {Spid, Jid}, + Stanza, + State#state.traces, + State#state.mappings), + State1 = State#state{traces = Traces1}, + State2 = State1#state{mappings = Mappings}, + State3 = maybe_store_start_time(Spid, Now, State2), + case maps:size(Traces1) of + N when N > ?MAX_TRACED -> + force_stop_tracing(State1); + _ -> + maybe_send_to_user(Now, {IsNewMapping, Now}, Message, State3) + end; +websocket_info(stop, State) -> + {stop, State}; +websocket_info(Info, State) -> + ?DEBUG("unknown info: ~p", [Info]), + {ok, State}. + +force_stop_tracing(State) -> + State1 = State#state{tracing = false}, + M = reply(<<"error">>, #{<<"reason">> => <<"too_many_traced_procs">>}), + {reply, M, State1}. + +maybe_send_to_user(Now, IsNewMapping, {message, Dir, {Pid, Jid}, Stanza}, State) -> + Spid = pid_to_binary(Pid), + Announcement = maybe_announce_new(IsNewMapping, Spid, Jid), + Msg = maybe_send_current(Now, Dir, Spid, Stanza, State), + {reply, Announcement ++ Msg, State}. + +maybe_announce_new({true, StartTime}, Spid, Jid) -> + {BareJid, FullJid} = format_jid(Jid), + [reply(<<"new_trace">>, + #{<<"pid">> => Spid, + <<"start_time">> => StartTime, + <<"bare_jid">> => BareJid, + <<"full_jid">> => FullJid})]; +maybe_announce_new({false, _}, _, _) -> + []. + +maybe_send_current(Now, Dir, Spid, Stanza, State) -> + case is_current(Spid, State) of + true -> + Tm = Now - maps:get(Spid, State#state.start_times), + M = reply(<<"message">>, #{<<"dir">> => atom_to_binary(Dir, utf8), + <<"time">> => Tm, + <<"stanza">> => Stanza + }), + [M]; + false -> + [] + end. + + +handle({Json}, State) -> + M = maps:from_list(Json), + handle(maps:get(<<"event">>, M), maps:get(<<"payload">>, M), State). + +handle(<<"get_status">>, _, State) -> + return_status(State); +handle(<<"trace_flag">>, {Payload}, State) -> + #{<<"value">> := Flag} = maps:from_list(Payload), + return_status(State#state{tracing = Flag}); +handle(<<"get_trace">>, {Payload}, State) -> + #{<<"pid">> := Pid} = maps:from_list(Payload), + {<<"get_trace">>, + #{<<"pid">> => Pid, <<"trace">> => format_trace(maps:get(Pid, State#state.traces, []), + maps:get(Pid, State#state.start_times))}, + State#state{current = Pid}}; +handle(<<"clear_all">>, _, State) -> + {<<"cleared_all">>, + State#state{traces = #{}, current = <<>>, mappings = #{}, start_times = #{}}}; +handle(<<"heartbeat">>, _, State) -> + {<<"heartbeat_ok">>, + <<>>, + State}; +handle(Event, Payload, State) -> + ?LOG_WARNING(#{what => unknown_event, + text => <<"Traffic monitor sent something I don't understand">>, + event => Event, payload => Payload}), + {<<"error">>, <<"unknown event">>, State}. + + +return_status(State) -> + {<<"status">>, + #{<<"trace_flag">> => State#state.tracing}, + State}. + +reply(Event) -> + reply(Event, #{}). + +reply(Event, Payload) -> + {text, jiffy:encode(#{<<"event">> => Event, <<"payload">> => Payload})}. + + + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +is_current(J, #state{current = J}) -> true; +is_current(_, _) -> false. + +record_item(Time, Dir, {Spid, Jid}, Stanza, Traces, Mappings) -> + Tr = case maps:get(Spid, Traces, undefined) of + undefined -> + queue:new(); + Q -> Q + end, + Fjid = format_jid(Jid), + IsNew = is_new_mapping(maps:get(Spid, Mappings, undefined), Fjid), + Mappings1 = case IsNew of + true -> + maps:put(Spid, Fjid, Mappings); + false -> + Mappings + end, + Tr1 = queue:in({Time, Dir, Stanza}, Tr), + Tr2 = case queue:len(Tr1) of + ?MAX_ITEMS -> queue:out(Tr1); + _ -> Tr1 + end, + {maps:put(Spid, Tr2, Traces), Mappings1, IsNew}. + +format_trace([], _StartTime) -> + []; +format_trace(Trace, StartTime) -> + lists:map(fun({Time, Dir, Stanza}) -> + #{<<"dir">> => atom_to_binary(Dir, utf8), + <<"time">> => Time - StartTime, + <<"stanza">> => Stanza} + end, + lists:reverse(queue:to_list(Trace))). + +pid_to_binary(Pid) when is_pid(Pid) -> + [Spid] = io_lib:format("~p", [Pid]), + list_to_binary(Spid). + +format_jid(Bin) when is_binary(Bin) -> + format_jid(jid:from_binary(Bin)); +format_jid(undefined) -> + {<<>>, <<>>}; +format_jid(#jid{lresource = <<>>} = Jid) -> + {jid:to_binary(jid:to_lower(Jid)), <<>>}; +format_jid(#jid{} = Jid) -> + {<<>>, jid:to_binary(jid:to_lower(Jid))}. + +is_new_mapping(undefined, {_, _}) -> true; % showed up for the very first time +is_new_mapping({_, _}, {<<>>, <<>>}) -> false; % nothing new +is_new_mapping({<<>>, <<>>}, {_, _}) -> true; % nothing old, something new +is_new_mapping({<<>>, _}, {_, _}) -> false; % we already have full jid +is_new_mapping({_, <<>>}, {<<>>, _}) -> true; % we have bare, received full +is_new_mapping(_, {_, _}) -> false. + +now_seconds() -> + {Msec, Sec, Micro} = os:timestamp(), + Msec * 1000000 + Sec + (Micro / 1000000). + +maybe_store_start_time(Spid, Time, #state{start_times = StartTimes} = State) -> + case maps:get(Spid, StartTimes, undefined) of + undefined -> + State#state{start_times = maps:put(Spid, Time, StartTimes)}; + _ -> + State + end. diff --git a/web/traffic/Makefile b/web/traffic/Makefile new file mode 100644 index 00000000000..12825f1239d --- /dev/null +++ b/web/traffic/Makefile @@ -0,0 +1,2 @@ +all: + elm make src/Traffic.elm --output=js/traffic.js diff --git a/web/traffic/README.md b/web/traffic/README.md new file mode 100644 index 00000000000..2e3dff8b0d0 --- /dev/null +++ b/web/traffic/README.md @@ -0,0 +1,60 @@ +# MongooseIM traffic tracer + +This is made to address the need to watch the XMPP traffic flowing through the server while developing/debugging/testing code. +It is normally achievied by tracing with Recon, which is not very convenient and hardly readable, mostly because of all the other junk that is printed out alongside. +Thus, here is something more convenient. + +## How to enable + +Build your MongooseIM, then in mongooseim.cfg uncomment lines defining a listener using `mongoose_traffic` and `mongoose_traffic_channel` module (should be the top one), +andalso module `mongoose_traffic`. + +If you want https, add the following to your listener definition: +``` + tls.certfile = "mycert.pem" + tls.keyfile = "mykey.pem" + tls.password = "secret" + +``` + +## How to use + +In your web browser, open address `http://localhost:5111` (or some other port, depending on listener settings). + +Click the "Tracing" button to start. +When you run a "big test", the left part will start showing jids of users which communicate with the server. +Click one of them, and you'll see the stanzas send by this user and to this user. +New stanzas will be appended in real time. +The black digits to the left from the stanzas is this user's timeline, in seconds. + +You can use the "Clear all" button to flush the UI. + +Things to keep in mind: + +* if you enable tracing and then restart the server, client app will reconnect and re-enable tracing +* however, if you reload the page, tracing will be disabled +* there is a limit to jids you can trace at the same time (100), so if you run a number of tests while tracing all the time chances are tracing will stop, you may want to clear it from time to time +* under the hood, traces are indexed with pids, and jids are only for display; it is perfectly normal to see the same jid appear multiple times, if your test uses `escalus:story` (not `fresh_story`) + +## How it works + +There is a hook `c2s_debug` which is called every time `ejabberd_c2s` sends or receives anything. +`mongoose_traffic` handles these calls and forwards messages to a dedicated process. +Your websocket process registers with that process. +It handles all "debug" messages and stores them (if tracing is enabled), also handles all communication with you. + +Stanza lists are indexed with c2s process id, so that we can collect them even before we know the user's jid. +The pid is first used for display, then it is maped to bare jid (after auth), and finally to full jid. + +Stanzas are stored in a double-ended queue, limited to 1000 entries. + +When you select a jid, you retrieve its trace from the server and also mark it as your currently traced account. +Then you start receiving new stanzas in real time, until you click another jid. + +## How it is implemented + +On server side, it is a simple Cowboy websocket. + +Client app is written in Elm, compiled to js and installed in `web/traffic` dir together with a css, some js for communication and a static html page. +Rebuilding it is as simple as running `make` in this directory. + + diff --git a/web/traffic/elm.json b/web/traffic/elm.json new file mode 100644 index 00000000000..7cac8764c1d --- /dev/null +++ b/web/traffic/elm.json @@ -0,0 +1,24 @@ +{ + "type": "application", + "source-directories": [ + "src" + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/browser": "1.0.2", + "elm/core": "1.0.5", + "elm/html": "1.0.0", + "elm/json": "1.1.3", + "elm/time": "1.0.0" + }, + "indirect": { + "elm/url": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} diff --git a/web/traffic/js/app.js b/web/traffic/js/app.js new file mode 100644 index 00000000000..809abb02b24 --- /dev/null +++ b/web/traffic/js/app.js @@ -0,0 +1,111 @@ + +current_ws_addr = "ws://" + window.location.host + "/ws-traffic"; +connected = false +const SOCKET_STATES = {connecting: 0, open: 1, closing: 2, closed: 3} + +start_app() +initialise() + +function open_websocket() { + socket = new WebSocket(current_ws_addr); + return socket +} + +function push(event, payload) { + m = JSON.stringify({ event : event, payload : payload}) + if(event != "heartbeat") console.log(`--->: ${m}`); + socket.send(m) +} + +function sendHeartbeat(socket) { + if(!isConnected(socket)){ + initialise() + return } + if(socket.pendingHeartbeatRef){ + socket.pendingHeartbeatRef = null + console.log("heartbeat timeout. Attempting to re-establish connection") + clearInterval(socket.heartbeatTimer) + abnormalClose(socket, "heartbeat timeout") + initialise() + return + } + socket.pendingHeartbeatRef = true + push("heartbeat", {}) + } + +function isConnected(socket){ return connectionState(socket) === "open" } + +function abnormalClose(socket, reason){ + socket.closeWasClean = false + socket.close(1000, reason) +} + + +function connectionState(socket){ + switch(socket.readyState){ + case SOCKET_STATES.connecting: return "connecting" + case SOCKET_STATES.open: return "open" + case SOCKET_STATES.closing: return "closing" + default: return "closed" + } + } + + +function initialise() { + socket = open_websocket() + console.log(socket) + socket.onopen = function(e) { + console.log("[open] Connection established"); + socket.onclose = event => { + app.ports.incPort.send({"event":"connection_lost", "payload":{}}) + }; + socket.pendingHeartbeatRef = null + clearInterval(socket.heartbeatTimer) + socket.heartbeatTimer = setInterval(() => sendHeartbeat(socket), 2000) + if(connected) { + app.ports.incPort.send({"event":"reinitialise", "payload":{}}) + }else{ + app.ports.incPort.send({"event":"initialise", "payload":{}}) + connected = true + } + + }; + socket.onmessage = function(event) { + socket.pendingHeartbeatRef = null + e = JSON.parse(event.data) + if(e.event != "heartbeat_ok"){ + console.log(` <---: ${event.data}`); + handle_event(e) + } + }; + if(!connected) { + socket.onclose = event => { + app.ports.incPort.send({"event":"connection_failed", "payload":{}}) + }; + }; +} + + +function start_app() { + app = Elm.Traffic.init({ node: document.getElementById("session-elm-container"), + flags: current_ws_addr}) + app.ports.outPort.subscribe(function(data){ + if(data[0] == "change_connection") { + change_connection(data[1].addr) + }else { + push(data[0], data[1]); + } + }) +} + +function handle_event(e){ + app.ports.incPort.send(e) +} + +function change_connection(addr) { + if(addr != current_ws_addr){ + current_ws_addr = addr; + connected = false; + initialise(); + } +} diff --git a/web/traffic/session.css b/web/traffic/session.css new file mode 100644 index 00000000000..400b69df8e6 --- /dev/null +++ b/web/traffic/session.css @@ -0,0 +1,104 @@ +html, body, .all, main, .main {height: 100%;} + +.hidden {display: none} + +.all { + width: 90%; + margin: 0 auto; + font-family: sans-serif; + margin-top: 20px; + } +.top { + border-bottom: 1px solid gray; + padding-bottom: 20px; +} +.header { + font-size: x-large; + font-weight: bold; + margin-bottom: 10px; +} +.main {} + +.left { + float: left; + width: 40%; +} + +.right { + height: 100%; + float:left; + width: 60%; +} + +.enabled { + float:left; + margin-right: 100px; + margin-left: 10px; +} + +.enabled button {width: 80px;} + +button { + padding: 7px; + +} +.clearButton {} + +.enabled .true { + font-weight:bold; + background-color: cornflowerblue; +} + +.enabled .false {} + +.tracing .label {margin: 10px 0;} +.jids {font-size: smaller;} +.jids .jid {margin-bottom: 3px;overflow:hidden;} + +.right .current {height:20px; margin:10px;font-weight:bold;} +.stanzas .label {display: none;} +.stanzas {height: 90%} + +.stanzalist { + font-size: smaller; + border: 1px solid #bbb; + height:100%; + overflow: scroll; +} +.stanza { + margin-bottom:10px; + margin-left:5px; +} + +.current_pid {background-color: lightgrey;} + +.server_to_client {color: darkred} +.client_to_server {color: darkgreen} + +.connection_state { + float: right; +} + +.connection_state div.good { + font-size:smaller; + color: darkgreen; +} + +.connection_state div.problem { + font-size:smaller; + color: red; +} + +.connection_state input { + width: 350px; +} + +.stanzas .time { + float:left; + color:black; + font-size:smaller;} + +.stanzas .server_to_client .time {} +.stanzas .client_to_server .time {} +.stanzas .server_to_client .part {margin-left:200px;} +.stanzas .client_to_server .part {margin-left:30px;} \ No newline at end of file diff --git a/web/traffic/session.html b/web/traffic/session.html new file mode 100644 index 00000000000..0c976924d53 --- /dev/null +++ b/web/traffic/session.html @@ -0,0 +1,18 @@ + + + + + + + + Session + + +
+
+
+ + + + + diff --git a/web/traffic/src/Traffic.elm b/web/traffic/src/Traffic.elm new file mode 100644 index 00000000000..630fe3d8147 --- /dev/null +++ b/web/traffic/src/Traffic.elm @@ -0,0 +1,376 @@ +port module Traffic exposing (main) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Browser +import Json.Encode as Encode +import Json.Decode as Decode +import Dict +import Time +import Task + +main = Browser.element {init = init, view = view, update = update, subscriptions = subscriptions} + +-- TYPES + + +type ConnectionState = Open + | Closed + | Lost + | Failed + +type alias Jid = String +type alias Pid = String +type alias NewTrace = {pid : Pid, bare_jid : Jid, start_time : Float, full_jid : Jid} +type alias Stanza = { dir : String, time : Float, stanza : String} +type alias Mappings = Dict.Dict Pid NewTrace +type alias Model = { tracing : Bool, + traced_pids : List Pid, + current_pid : Pid, + stanzas : List Stanza, + mappings : Mappings, + announcement : Announcement, + timezone : Time.Zone, + ws_addr : WsAddr, + conn_state : ConnectionState} + +type alias DecodeResult a = Result Decode.Error a +type alias UpdateResult = (Model, Cmd Msg) + +type Announcement = Empty + | Error String + +type Msg = SetStatus Bool + | ClearAll + | SelectPid Pid + | RecEvent Encode.Value + | SetWsAddr WsAddr + | ChangeConnection + | Skip + | TimeZone Time.Zone + +type WsAddr = WsAddr String + +-- UPDATE + +init : String -> (Model, Cmd Msg) +init adr = ({ tracing = False, + traced_pids = [], + current_pid = "", + stanzas = [], + mappings = Dict.empty, + announcement = Empty, + timezone = Time.utc, + ws_addr = WsAddr adr, + conn_state = Closed}, + Task.perform TimeZone Time.here) + +update : Msg -> Model -> UpdateResult +update msg model = + do_update msg {model | announcement = Empty} + +do_update msg model = + case msg of + ClearAll -> (model, outPort(simpleEvent "clear_all")) + SetStatus st -> + (model, setTraceEvent st) + SelectPid pid -> ({model | current_pid = pid}, outPort(outEvent "get_trace" [("pid", Encode.string pid)])) + SetWsAddr wsaddr -> ({model | ws_addr = wsaddr}, Cmd.none) + ChangeConnection -> + case model.ws_addr of + WsAddr s -> + (model, outPort(outEvent "change_connection" [("addr", Encode.string s)])) + RecEvent v -> + case Decode.decodeValue (Decode.field "event" Decode.string) v of + Ok eventName -> + let z = Debug.log "v" eventName in + handleEvent eventName v model + Err error -> + let x = Debug.log "error" error in + (model, Cmd.none) + TimeZone tz -> ({model | timezone = tz}, Cmd.none) + Skip -> (model, Cmd.none) + + +-- INCOMING + +handleEvent : String -> Decode.Value -> Model -> UpdateResult +handleEvent ename v model = + case ename of + "status" -> handleStatus v model + "new_trace" -> handleNewTrace v model + "cleared_all" -> (clearAll model, Cmd.none) + "error" -> (model + |> unTrace + |> showErrorMessage v, + Cmd.none) + "get_trace" -> handleGetTrace v model + "message" -> handleMessage v model + "reinitialise" -> (clearAll ({model | conn_state = Open}), setTraceEvent model.tracing) -- server was probably restarted, we set our status + "initialise" -> ({model | conn_state = Open}, outPort(simpleEvent "get_status")) -- server default may change + "connection_lost" -> ({model | conn_state = Lost}, Cmd.none) + "connection_failed" -> ({model | conn_state = Failed}, Cmd.none) + _ -> (model, Cmd.none) + +clearAll : Model -> Model +clearAll model = {model | traced_pids = [], stanzas = [], current_pid = ""} + +setTraceEvent : Bool -> Cmd Msg +setTraceEvent st = outPort(outEvent "trace_flag" [("value", Encode.bool st)]) + + +handleStatus : Decode.Value -> Model -> UpdateResult +handleStatus v model = + (v, model) + |> handleDecodedValue (decodeField "trace_flag" Decode.bool) + handleStatusOk + + +handleDecodedValue : (Decode.Value -> DecodeResult a) -- decoder + -> (Model -> a -> UpdateResult) -- handler if ok + -> (Decode.Value, Model) + -> UpdateResult +handleDecodedValue decoder handler (v, model) = + case decoder v of + Ok res -> handler model res + Err error -> + let x = Debug.log "error" error in + (model, Cmd.none) + +handleStatusOk : Model -> Bool -> UpdateResult +handleStatusOk model trace_flag = ({model | tracing = trace_flag}, Cmd.none) + +unTrace model = {model | tracing = False} + +handleNewTrace : Decode.Value -> Model -> UpdateResult +handleNewTrace v model = + (v, model) + |> handleDecodedValue decodeNewTrace + handleNewTraceOk + +handleNewTraceOk : Model -> NewTrace -> UpdateResult +handleNewTraceOk model newtrace = + (model |> updateMapping newtrace |> updateTraces newtrace, + Cmd.none) + +updateMapping newtrace model = + {model | mappings = Dict.insert newtrace.pid newtrace model.mappings} + +updateTraces newtrace model = + case List.member newtrace.pid model.traced_pids of + True -> model + False -> {model | traced_pids = newtrace.pid :: model.traced_pids} + +handleGetTrace : Decode.Value -> Model -> UpdateResult +handleGetTrace v model = + (v, model) + |> handleDecodedValue (Decode.decodeValue + (Decode.field + "payload" + (Decode.field + "trace" + (Decode.list decodeStanza)))) + handleGetTraceOk + +handleGetTraceOk : Model -> List Stanza -> UpdateResult +handleGetTraceOk model stanzas = + ({model | stanzas = stanzas}, Cmd.none) + +handleMessage : Decode.Value -> Model -> UpdateResult +handleMessage v model = + (v, model) + |> handleDecodedValue (Decode.decodeValue (Decode.field "payload" decodeStanza)) + handleMessageOk + +handleMessageOk : Model -> Stanza -> UpdateResult +handleMessageOk model stanza = + ({model | stanzas = stanza :: model.stanzas}, Cmd.none) + + +-- SOME USEFUL DECODERS + +decodeStanza = Decode.map3 Stanza (Decode.field "dir" Decode.string) + (Decode.field "time" Decode.float) + (Decode.field "stanza" Decode.string) + +decodeField : String -> Decode.Decoder a -> Decode.Value -> DecodeResult a +decodeField fieldname decoder v = + Decode.decodeValue (Decode.field "payload" (Decode.field fieldname decoder)) v + + +showErrorMessage v model = + case decodeField "reason" Decode.string v of + Ok reason -> {model | announcement = Error reason} + _ -> model + +decodeNewTrace v = Decode.decodeValue (Decode.field "payload" newTraceDecoder) v + +newTraceDecoder = Decode.map4 NewTrace (Decode.field "pid" Decode.string ) + (Decode.field "bare_jid" Decode.string) + (Decode.field "start_time" Decode.float) + (Decode.field "full_jid" Decode.string) +-- COMMUNICATION TOOLS + +subscriptions : Model -> Sub Msg +subscriptions _ = Sub.batch [incPort RecEvent] + +port outPort : (Encode.Value, Encode.Value) -> Cmd msg +port incPort : (Encode.Value -> msg) -> Sub msg + +emptyPayload = Encode.object [] + +simpleEvent : String -> (Encode.Value, Encode.Value) +simpleEvent evt = (Encode.string evt, emptyPayload) + + +outEvent : String -> List (String, Encode.Value) -> (Encode.Value, Encode.Value) +outEvent evtname payload = + (Encode.string evtname, + Encode.object payload) + + +-- VIEW + +view : Model -> Html Msg +view model = + div [class "all"][ + div [class "top"][ + div [class "header"][text "MongooseIM traffic tracer"], + showEnableButton model.tracing, + button [class "clearButton", onClick ClearAll] [ text "clear all"], + showConnState model + ], + div [class "main"][ + div [class "left"][ + viewJids model.current_pid model.traced_pids model.mappings model.timezone + ], + div [class "right"][ + div [class "current"][text (displayJid model.current_pid model.mappings)], + viewAnnouncement model.announcement, + viewStanzas model.stanzas + ] + ] + ] + +viewAnnouncement ann = + case ann of + Empty -> div [class "hidden"][] + Error "too_many_traced_procs" -> div [class "problem"] [text "Too many pids traced, tracing disabled"] + Error reason -> div [class "problem"] [text reason] + +showWsAddr model = + case model.ws_addr of + WsAddr a -> input [type_ "text", value a, + onInput (\s -> SetWsAddr (WsAddr s)), + on "keydown" (Decode.map sendIfEnter keyCode)] [] + +sendIfEnter : Int-> Msg +sendIfEnter keycode = + case keycode of + 13 -> ChangeConnection + _ -> Skip + +showConnState model = + div [class ("connection_state")][ + showWsAddr model, + div [class (connStateClass model.conn_state)] + [text (connStateLabel model.conn_state)] + ] + +connStateClass state = + case state of + Open -> "good" + _ -> "problem" + +connStateLabel state = + case state of + Open -> "connection open" + Lost -> "trying to reconnect..." + Closed -> "no connection" + Failed -> "connection failed" + +viewJids : Pid -> List String -> Mappings -> Time.Zone -> Html Msg +viewJids curr_pid traced_pids mappings tz = + div [class "tracing"] [ + div [class "label"] [text "Traced sessions:"], + div [class "jids"] (List.map (showJid curr_pid mappings tz) (List.reverse traced_pids)) + ] + +showJid : Pid -> Mappings -> Time.Zone -> Pid -> Html Msg +showJid curr_pid mappings tz pid = + div [class "jid", class (mb_current curr_pid pid)] + [a [onClick (SelectPid pid)] + [text (displayJid pid mappings), + text (showStartTime pid mappings tz)]] + +mb_current curr_pid pid = + if curr_pid == pid then "current_pid" else "" + +displayJid : Pid -> Mappings -> Pid +displayJid pid mappings = + case Dict.get pid mappings of + Just info -> + case (info.bare_jid, info.full_jid) of + ("", f) -> f + (b, "") -> b + _ -> pid + Nothing -> + pid + +showStartTime pid mappings tz = + case Dict.get pid mappings of + Just info -> + let t = Time.millisToPosix ((truncate info.start_time) * 1000) in + ", " ++ + timeFormat (Time.toHour tz t) ++ ":" + ++ timeFormat (Time.toMinute tz t) ++ ":" + ++ timeFormat (Time.toSecond tz t) + --String.fromFloat info.start_time + Nothing -> + "" + +enableClass is_enabled = + case is_enabled of + True -> "true" + False -> "false" + +showEnableButton is_enabled = + div [class "enabled"][ + button [class (enableClass is_enabled), + onClick (SetStatus (not is_enabled))] + [ text "Tracing"] + ] + +viewStanzas stanzas = + div [class "stanzas"] [ + div [class "label"] [text "Stanzas"], + div [class "stanzalist"] (List.map showStanza (List.reverse stanzas)) + ] + + +showStanza stanza = + div [class ("stanza " ++ stanza.dir)] + (div [class "time"][text (formatTime stanza.time)] + :: (List.map showStanzaPart (String.split "\n" stanza.stanza))) + +showStanzaPart p = + div [class "part"][text p] + +formatTime tm = + tm |> String.fromFloat |> String.split "." |> formatParts + +formatParts parts = + case parts of + [i, f] -> + String.concat [ + i |> String.slice 0 2 |> String.padLeft 2 ' ', + ".", + f |> String.slice 0 2 |> String.padRight 2 '0' + ] + ["0"] -> + "0.00" + _ -> + "bueee" + +timeFormat i = String.fromInt i |> String.padLeft 2 '0' \ No newline at end of file From 0b00f3199ae6618fe913b842bd60dde4a517daee Mon Sep 17 00:00:00 2001 From: Bartek Gorny Date: Fri, 27 Oct 2023 14:28:44 +0200 Subject: [PATCH 2/8] Moved monitor out --- rebar.config | 6 +- web/traffic/Makefile | 2 - web/traffic/README.md | 60 ------ web/traffic/elm.json | 24 --- web/traffic/js/app.js | 111 ----------- web/traffic/session.css | 104 ---------- web/traffic/session.html | 18 -- web/traffic/src/Traffic.elm | 376 ------------------------------------ 8 files changed, 4 insertions(+), 697 deletions(-) delete mode 100644 web/traffic/Makefile delete mode 100644 web/traffic/README.md delete mode 100644 web/traffic/elm.json delete mode 100644 web/traffic/js/app.js delete mode 100644 web/traffic/session.css delete mode 100644 web/traffic/session.html delete mode 100644 web/traffic/src/Traffic.elm diff --git a/rebar.config b/rebar.config index a931cee1cda..e68a59a493c 100644 --- a/rebar.config +++ b/rebar.config @@ -116,7 +116,8 @@ {jwerl, "1.2.0"}, {cpool, "0.1.0"}, %% Do not upgrade cpool to version 0.1.1, it has bugs {nkpacket, {git, "https://github.com/esl/nkpacket.git", {branch, "mongooseim-ranch-compatibility"}}}, - {nksip, {git, "https://github.com/arcusfelis/nksip.git", {branch, "mu-fix-dialyzer"}}} + {nksip, {git, "https://github.com/arcusfelis/nksip.git", {branch, "mu-fix-dialyzer"}}}, + {traffic_monitor, {git, "https://github.com/bartekgorny/mongooseim_traffic_monitor.git", {branch, "master"}}} ]}. {relx, [{release, { mongooseim, {cmd, "cat VERSION | tr -d '\r\n'"} }, @@ -145,7 +146,8 @@ {copy, "rel/files/scripts", "./"}, {copy, "rel/files/templates", "./"}, {copy, "rel/files/templates.ini", "etc/templates.ini"}, - {copy, "web", "web"}, + {mkdir, "web"}, + {copy, "_build/default/lib/traffic_monitor", "web/traffic"}, {template, "rel/files/nodetool", "erts-\{\{erts_vsn\}\}/bin/nodetool"}, diff --git a/web/traffic/Makefile b/web/traffic/Makefile deleted file mode 100644 index 12825f1239d..00000000000 --- a/web/traffic/Makefile +++ /dev/null @@ -1,2 +0,0 @@ -all: - elm make src/Traffic.elm --output=js/traffic.js diff --git a/web/traffic/README.md b/web/traffic/README.md deleted file mode 100644 index 2e3dff8b0d0..00000000000 --- a/web/traffic/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# MongooseIM traffic tracer - -This is made to address the need to watch the XMPP traffic flowing through the server while developing/debugging/testing code. -It is normally achievied by tracing with Recon, which is not very convenient and hardly readable, mostly because of all the other junk that is printed out alongside. -Thus, here is something more convenient. - -## How to enable - -Build your MongooseIM, then in mongooseim.cfg uncomment lines defining a listener using `mongoose_traffic` and `mongoose_traffic_channel` module (should be the top one), -andalso module `mongoose_traffic`. - -If you want https, add the following to your listener definition: -``` - tls.certfile = "mycert.pem" - tls.keyfile = "mykey.pem" - tls.password = "secret" - +``` - -## How to use - -In your web browser, open address `http://localhost:5111` (or some other port, depending on listener settings). - -Click the "Tracing" button to start. -When you run a "big test", the left part will start showing jids of users which communicate with the server. -Click one of them, and you'll see the stanzas send by this user and to this user. -New stanzas will be appended in real time. -The black digits to the left from the stanzas is this user's timeline, in seconds. - -You can use the "Clear all" button to flush the UI. - -Things to keep in mind: - -* if you enable tracing and then restart the server, client app will reconnect and re-enable tracing -* however, if you reload the page, tracing will be disabled -* there is a limit to jids you can trace at the same time (100), so if you run a number of tests while tracing all the time chances are tracing will stop, you may want to clear it from time to time -* under the hood, traces are indexed with pids, and jids are only for display; it is perfectly normal to see the same jid appear multiple times, if your test uses `escalus:story` (not `fresh_story`) - -## How it works - -There is a hook `c2s_debug` which is called every time `ejabberd_c2s` sends or receives anything. -`mongoose_traffic` handles these calls and forwards messages to a dedicated process. -Your websocket process registers with that process. -It handles all "debug" messages and stores them (if tracing is enabled), also handles all communication with you. - -Stanza lists are indexed with c2s process id, so that we can collect them even before we know the user's jid. -The pid is first used for display, then it is maped to bare jid (after auth), and finally to full jid. - -Stanzas are stored in a double-ended queue, limited to 1000 entries. - -When you select a jid, you retrieve its trace from the server and also mark it as your currently traced account. -Then you start receiving new stanzas in real time, until you click another jid. - -## How it is implemented - -On server side, it is a simple Cowboy websocket. - -Client app is written in Elm, compiled to js and installed in `web/traffic` dir together with a css, some js for communication and a static html page. -Rebuilding it is as simple as running `make` in this directory. - - diff --git a/web/traffic/elm.json b/web/traffic/elm.json deleted file mode 100644 index 7cac8764c1d..00000000000 --- a/web/traffic/elm.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "type": "application", - "source-directories": [ - "src" - ], - "elm-version": "0.19.1", - "dependencies": { - "direct": { - "elm/browser": "1.0.2", - "elm/core": "1.0.5", - "elm/html": "1.0.0", - "elm/json": "1.1.3", - "elm/time": "1.0.0" - }, - "indirect": { - "elm/url": "1.0.0", - "elm/virtual-dom": "1.0.2" - } - }, - "test-dependencies": { - "direct": {}, - "indirect": {} - } -} diff --git a/web/traffic/js/app.js b/web/traffic/js/app.js deleted file mode 100644 index 809abb02b24..00000000000 --- a/web/traffic/js/app.js +++ /dev/null @@ -1,111 +0,0 @@ - -current_ws_addr = "ws://" + window.location.host + "/ws-traffic"; -connected = false -const SOCKET_STATES = {connecting: 0, open: 1, closing: 2, closed: 3} - -start_app() -initialise() - -function open_websocket() { - socket = new WebSocket(current_ws_addr); - return socket -} - -function push(event, payload) { - m = JSON.stringify({ event : event, payload : payload}) - if(event != "heartbeat") console.log(`--->: ${m}`); - socket.send(m) -} - -function sendHeartbeat(socket) { - if(!isConnected(socket)){ - initialise() - return } - if(socket.pendingHeartbeatRef){ - socket.pendingHeartbeatRef = null - console.log("heartbeat timeout. Attempting to re-establish connection") - clearInterval(socket.heartbeatTimer) - abnormalClose(socket, "heartbeat timeout") - initialise() - return - } - socket.pendingHeartbeatRef = true - push("heartbeat", {}) - } - -function isConnected(socket){ return connectionState(socket) === "open" } - -function abnormalClose(socket, reason){ - socket.closeWasClean = false - socket.close(1000, reason) -} - - -function connectionState(socket){ - switch(socket.readyState){ - case SOCKET_STATES.connecting: return "connecting" - case SOCKET_STATES.open: return "open" - case SOCKET_STATES.closing: return "closing" - default: return "closed" - } - } - - -function initialise() { - socket = open_websocket() - console.log(socket) - socket.onopen = function(e) { - console.log("[open] Connection established"); - socket.onclose = event => { - app.ports.incPort.send({"event":"connection_lost", "payload":{}}) - }; - socket.pendingHeartbeatRef = null - clearInterval(socket.heartbeatTimer) - socket.heartbeatTimer = setInterval(() => sendHeartbeat(socket), 2000) - if(connected) { - app.ports.incPort.send({"event":"reinitialise", "payload":{}}) - }else{ - app.ports.incPort.send({"event":"initialise", "payload":{}}) - connected = true - } - - }; - socket.onmessage = function(event) { - socket.pendingHeartbeatRef = null - e = JSON.parse(event.data) - if(e.event != "heartbeat_ok"){ - console.log(` <---: ${event.data}`); - handle_event(e) - } - }; - if(!connected) { - socket.onclose = event => { - app.ports.incPort.send({"event":"connection_failed", "payload":{}}) - }; - }; -} - - -function start_app() { - app = Elm.Traffic.init({ node: document.getElementById("session-elm-container"), - flags: current_ws_addr}) - app.ports.outPort.subscribe(function(data){ - if(data[0] == "change_connection") { - change_connection(data[1].addr) - }else { - push(data[0], data[1]); - } - }) -} - -function handle_event(e){ - app.ports.incPort.send(e) -} - -function change_connection(addr) { - if(addr != current_ws_addr){ - current_ws_addr = addr; - connected = false; - initialise(); - } -} diff --git a/web/traffic/session.css b/web/traffic/session.css deleted file mode 100644 index 400b69df8e6..00000000000 --- a/web/traffic/session.css +++ /dev/null @@ -1,104 +0,0 @@ -html, body, .all, main, .main {height: 100%;} - -.hidden {display: none} - -.all { - width: 90%; - margin: 0 auto; - font-family: sans-serif; - margin-top: 20px; - } -.top { - border-bottom: 1px solid gray; - padding-bottom: 20px; -} -.header { - font-size: x-large; - font-weight: bold; - margin-bottom: 10px; -} -.main {} - -.left { - float: left; - width: 40%; -} - -.right { - height: 100%; - float:left; - width: 60%; -} - -.enabled { - float:left; - margin-right: 100px; - margin-left: 10px; -} - -.enabled button {width: 80px;} - -button { - padding: 7px; - -} -.clearButton {} - -.enabled .true { - font-weight:bold; - background-color: cornflowerblue; -} - -.enabled .false {} - -.tracing .label {margin: 10px 0;} -.jids {font-size: smaller;} -.jids .jid {margin-bottom: 3px;overflow:hidden;} - -.right .current {height:20px; margin:10px;font-weight:bold;} -.stanzas .label {display: none;} -.stanzas {height: 90%} - -.stanzalist { - font-size: smaller; - border: 1px solid #bbb; - height:100%; - overflow: scroll; -} -.stanza { - margin-bottom:10px; - margin-left:5px; -} - -.current_pid {background-color: lightgrey;} - -.server_to_client {color: darkred} -.client_to_server {color: darkgreen} - -.connection_state { - float: right; -} - -.connection_state div.good { - font-size:smaller; - color: darkgreen; -} - -.connection_state div.problem { - font-size:smaller; - color: red; -} - -.connection_state input { - width: 350px; -} - -.stanzas .time { - float:left; - color:black; - font-size:smaller;} - -.stanzas .server_to_client .time {} -.stanzas .client_to_server .time {} -.stanzas .server_to_client .part {margin-left:200px;} -.stanzas .client_to_server .part {margin-left:30px;} \ No newline at end of file diff --git a/web/traffic/session.html b/web/traffic/session.html deleted file mode 100644 index 0c976924d53..00000000000 --- a/web/traffic/session.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - Session - - -
-
-
- - - - - diff --git a/web/traffic/src/Traffic.elm b/web/traffic/src/Traffic.elm deleted file mode 100644 index 630fe3d8147..00000000000 --- a/web/traffic/src/Traffic.elm +++ /dev/null @@ -1,376 +0,0 @@ -port module Traffic exposing (main) - -import Html exposing (..) -import Html.Attributes exposing (..) -import Html.Events exposing (..) -import Browser -import Json.Encode as Encode -import Json.Decode as Decode -import Dict -import Time -import Task - -main = Browser.element {init = init, view = view, update = update, subscriptions = subscriptions} - --- TYPES - - -type ConnectionState = Open - | Closed - | Lost - | Failed - -type alias Jid = String -type alias Pid = String -type alias NewTrace = {pid : Pid, bare_jid : Jid, start_time : Float, full_jid : Jid} -type alias Stanza = { dir : String, time : Float, stanza : String} -type alias Mappings = Dict.Dict Pid NewTrace -type alias Model = { tracing : Bool, - traced_pids : List Pid, - current_pid : Pid, - stanzas : List Stanza, - mappings : Mappings, - announcement : Announcement, - timezone : Time.Zone, - ws_addr : WsAddr, - conn_state : ConnectionState} - -type alias DecodeResult a = Result Decode.Error a -type alias UpdateResult = (Model, Cmd Msg) - -type Announcement = Empty - | Error String - -type Msg = SetStatus Bool - | ClearAll - | SelectPid Pid - | RecEvent Encode.Value - | SetWsAddr WsAddr - | ChangeConnection - | Skip - | TimeZone Time.Zone - -type WsAddr = WsAddr String - --- UPDATE - -init : String -> (Model, Cmd Msg) -init adr = ({ tracing = False, - traced_pids = [], - current_pid = "", - stanzas = [], - mappings = Dict.empty, - announcement = Empty, - timezone = Time.utc, - ws_addr = WsAddr adr, - conn_state = Closed}, - Task.perform TimeZone Time.here) - -update : Msg -> Model -> UpdateResult -update msg model = - do_update msg {model | announcement = Empty} - -do_update msg model = - case msg of - ClearAll -> (model, outPort(simpleEvent "clear_all")) - SetStatus st -> - (model, setTraceEvent st) - SelectPid pid -> ({model | current_pid = pid}, outPort(outEvent "get_trace" [("pid", Encode.string pid)])) - SetWsAddr wsaddr -> ({model | ws_addr = wsaddr}, Cmd.none) - ChangeConnection -> - case model.ws_addr of - WsAddr s -> - (model, outPort(outEvent "change_connection" [("addr", Encode.string s)])) - RecEvent v -> - case Decode.decodeValue (Decode.field "event" Decode.string) v of - Ok eventName -> - let z = Debug.log "v" eventName in - handleEvent eventName v model - Err error -> - let x = Debug.log "error" error in - (model, Cmd.none) - TimeZone tz -> ({model | timezone = tz}, Cmd.none) - Skip -> (model, Cmd.none) - - --- INCOMING - -handleEvent : String -> Decode.Value -> Model -> UpdateResult -handleEvent ename v model = - case ename of - "status" -> handleStatus v model - "new_trace" -> handleNewTrace v model - "cleared_all" -> (clearAll model, Cmd.none) - "error" -> (model - |> unTrace - |> showErrorMessage v, - Cmd.none) - "get_trace" -> handleGetTrace v model - "message" -> handleMessage v model - "reinitialise" -> (clearAll ({model | conn_state = Open}), setTraceEvent model.tracing) -- server was probably restarted, we set our status - "initialise" -> ({model | conn_state = Open}, outPort(simpleEvent "get_status")) -- server default may change - "connection_lost" -> ({model | conn_state = Lost}, Cmd.none) - "connection_failed" -> ({model | conn_state = Failed}, Cmd.none) - _ -> (model, Cmd.none) - -clearAll : Model -> Model -clearAll model = {model | traced_pids = [], stanzas = [], current_pid = ""} - -setTraceEvent : Bool -> Cmd Msg -setTraceEvent st = outPort(outEvent "trace_flag" [("value", Encode.bool st)]) - - -handleStatus : Decode.Value -> Model -> UpdateResult -handleStatus v model = - (v, model) - |> handleDecodedValue (decodeField "trace_flag" Decode.bool) - handleStatusOk - - -handleDecodedValue : (Decode.Value -> DecodeResult a) -- decoder - -> (Model -> a -> UpdateResult) -- handler if ok - -> (Decode.Value, Model) - -> UpdateResult -handleDecodedValue decoder handler (v, model) = - case decoder v of - Ok res -> handler model res - Err error -> - let x = Debug.log "error" error in - (model, Cmd.none) - -handleStatusOk : Model -> Bool -> UpdateResult -handleStatusOk model trace_flag = ({model | tracing = trace_flag}, Cmd.none) - -unTrace model = {model | tracing = False} - -handleNewTrace : Decode.Value -> Model -> UpdateResult -handleNewTrace v model = - (v, model) - |> handleDecodedValue decodeNewTrace - handleNewTraceOk - -handleNewTraceOk : Model -> NewTrace -> UpdateResult -handleNewTraceOk model newtrace = - (model |> updateMapping newtrace |> updateTraces newtrace, - Cmd.none) - -updateMapping newtrace model = - {model | mappings = Dict.insert newtrace.pid newtrace model.mappings} - -updateTraces newtrace model = - case List.member newtrace.pid model.traced_pids of - True -> model - False -> {model | traced_pids = newtrace.pid :: model.traced_pids} - -handleGetTrace : Decode.Value -> Model -> UpdateResult -handleGetTrace v model = - (v, model) - |> handleDecodedValue (Decode.decodeValue - (Decode.field - "payload" - (Decode.field - "trace" - (Decode.list decodeStanza)))) - handleGetTraceOk - -handleGetTraceOk : Model -> List Stanza -> UpdateResult -handleGetTraceOk model stanzas = - ({model | stanzas = stanzas}, Cmd.none) - -handleMessage : Decode.Value -> Model -> UpdateResult -handleMessage v model = - (v, model) - |> handleDecodedValue (Decode.decodeValue (Decode.field "payload" decodeStanza)) - handleMessageOk - -handleMessageOk : Model -> Stanza -> UpdateResult -handleMessageOk model stanza = - ({model | stanzas = stanza :: model.stanzas}, Cmd.none) - - --- SOME USEFUL DECODERS - -decodeStanza = Decode.map3 Stanza (Decode.field "dir" Decode.string) - (Decode.field "time" Decode.float) - (Decode.field "stanza" Decode.string) - -decodeField : String -> Decode.Decoder a -> Decode.Value -> DecodeResult a -decodeField fieldname decoder v = - Decode.decodeValue (Decode.field "payload" (Decode.field fieldname decoder)) v - - -showErrorMessage v model = - case decodeField "reason" Decode.string v of - Ok reason -> {model | announcement = Error reason} - _ -> model - -decodeNewTrace v = Decode.decodeValue (Decode.field "payload" newTraceDecoder) v - -newTraceDecoder = Decode.map4 NewTrace (Decode.field "pid" Decode.string ) - (Decode.field "bare_jid" Decode.string) - (Decode.field "start_time" Decode.float) - (Decode.field "full_jid" Decode.string) --- COMMUNICATION TOOLS - -subscriptions : Model -> Sub Msg -subscriptions _ = Sub.batch [incPort RecEvent] - -port outPort : (Encode.Value, Encode.Value) -> Cmd msg -port incPort : (Encode.Value -> msg) -> Sub msg - -emptyPayload = Encode.object [] - -simpleEvent : String -> (Encode.Value, Encode.Value) -simpleEvent evt = (Encode.string evt, emptyPayload) - - -outEvent : String -> List (String, Encode.Value) -> (Encode.Value, Encode.Value) -outEvent evtname payload = - (Encode.string evtname, - Encode.object payload) - - --- VIEW - -view : Model -> Html Msg -view model = - div [class "all"][ - div [class "top"][ - div [class "header"][text "MongooseIM traffic tracer"], - showEnableButton model.tracing, - button [class "clearButton", onClick ClearAll] [ text "clear all"], - showConnState model - ], - div [class "main"][ - div [class "left"][ - viewJids model.current_pid model.traced_pids model.mappings model.timezone - ], - div [class "right"][ - div [class "current"][text (displayJid model.current_pid model.mappings)], - viewAnnouncement model.announcement, - viewStanzas model.stanzas - ] - ] - ] - -viewAnnouncement ann = - case ann of - Empty -> div [class "hidden"][] - Error "too_many_traced_procs" -> div [class "problem"] [text "Too many pids traced, tracing disabled"] - Error reason -> div [class "problem"] [text reason] - -showWsAddr model = - case model.ws_addr of - WsAddr a -> input [type_ "text", value a, - onInput (\s -> SetWsAddr (WsAddr s)), - on "keydown" (Decode.map sendIfEnter keyCode)] [] - -sendIfEnter : Int-> Msg -sendIfEnter keycode = - case keycode of - 13 -> ChangeConnection - _ -> Skip - -showConnState model = - div [class ("connection_state")][ - showWsAddr model, - div [class (connStateClass model.conn_state)] - [text (connStateLabel model.conn_state)] - ] - -connStateClass state = - case state of - Open -> "good" - _ -> "problem" - -connStateLabel state = - case state of - Open -> "connection open" - Lost -> "trying to reconnect..." - Closed -> "no connection" - Failed -> "connection failed" - -viewJids : Pid -> List String -> Mappings -> Time.Zone -> Html Msg -viewJids curr_pid traced_pids mappings tz = - div [class "tracing"] [ - div [class "label"] [text "Traced sessions:"], - div [class "jids"] (List.map (showJid curr_pid mappings tz) (List.reverse traced_pids)) - ] - -showJid : Pid -> Mappings -> Time.Zone -> Pid -> Html Msg -showJid curr_pid mappings tz pid = - div [class "jid", class (mb_current curr_pid pid)] - [a [onClick (SelectPid pid)] - [text (displayJid pid mappings), - text (showStartTime pid mappings tz)]] - -mb_current curr_pid pid = - if curr_pid == pid then "current_pid" else "" - -displayJid : Pid -> Mappings -> Pid -displayJid pid mappings = - case Dict.get pid mappings of - Just info -> - case (info.bare_jid, info.full_jid) of - ("", f) -> f - (b, "") -> b - _ -> pid - Nothing -> - pid - -showStartTime pid mappings tz = - case Dict.get pid mappings of - Just info -> - let t = Time.millisToPosix ((truncate info.start_time) * 1000) in - ", " ++ - timeFormat (Time.toHour tz t) ++ ":" - ++ timeFormat (Time.toMinute tz t) ++ ":" - ++ timeFormat (Time.toSecond tz t) - --String.fromFloat info.start_time - Nothing -> - "" - -enableClass is_enabled = - case is_enabled of - True -> "true" - False -> "false" - -showEnableButton is_enabled = - div [class "enabled"][ - button [class (enableClass is_enabled), - onClick (SetStatus (not is_enabled))] - [ text "Tracing"] - ] - -viewStanzas stanzas = - div [class "stanzas"] [ - div [class "label"] [text "Stanzas"], - div [class "stanzalist"] (List.map showStanza (List.reverse stanzas)) - ] - - -showStanza stanza = - div [class ("stanza " ++ stanza.dir)] - (div [class "time"][text (formatTime stanza.time)] - :: (List.map showStanzaPart (String.split "\n" stanza.stanza))) - -showStanzaPart p = - div [class "part"][text p] - -formatTime tm = - tm |> String.fromFloat |> String.split "." |> formatParts - -formatParts parts = - case parts of - [i, f] -> - String.concat [ - i |> String.slice 0 2 |> String.padLeft 2 ' ', - ".", - f |> String.slice 0 2 |> String.padRight 2 '0' - ] - ["0"] -> - "0.00" - _ -> - "bueee" - -timeFormat i = String.fromInt i |> String.padLeft 2 '0' \ No newline at end of file From 675ad96d358fb11d85991c4e52c3012563b230db Mon Sep 17 00:00:00 2001 From: Bartek Gorny Date: Fri, 27 Oct 2023 14:38:50 +0200 Subject: [PATCH 3/8] doc --- doc/operation-and-maintenance/Logging-&-monitoring.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/operation-and-maintenance/Logging-&-monitoring.md b/doc/operation-and-maintenance/Logging-&-monitoring.md index 2105d327200..a4022823ff4 100644 --- a/doc/operation-and-maintenance/Logging-&-monitoring.md +++ b/doc/operation-and-maintenance/Logging-&-monitoring.md @@ -146,3 +146,9 @@ Parts of the names are indexed from `0`. Time-based metrics in MongooseIM are given in **microseconds**, so to display human-readable values in graph's legend, the Y-axis unit has to be edited on the `Axes` tab. [Logger]: https://erlang.org/doc/apps/kernel/logger_chapter.html#handlers + +## Traffic monitor + +For debugging purposes, especially while developing MIM additional modules or client applications, +you may want to use browser-based traffic monitor. It is downloaded as a dependency in installed +in /web/traffic subdirectory. Consult README for details. \ No newline at end of file From 3cb6a5461be9054cf0d89c2c3e0faec42d6c4169 Mon Sep 17 00:00:00 2001 From: Bartek Gorny Date: Mon, 6 Nov 2023 16:17:19 +0100 Subject: [PATCH 4/8] removed old file --- rel/files/tracer_connect.js | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 rel/files/tracer_connect.js diff --git a/rel/files/tracer_connect.js b/rel/files/tracer_connect.js deleted file mode 100644 index 078136db30c..00000000000 --- a/rel/files/tracer_connect.js +++ /dev/null @@ -1,5 +0,0 @@ - -function open_websocket() { - socket = new WebSocket("ws://localhost:{{{ traffic_channel_port}}}/ws-traffic") - return socket -} From 4cff28dbd61671c5976487a3db26aa9d82336cc4 Mon Sep 17 00:00:00 2001 From: Bartek Gorny Date: Wed, 8 Nov 2023 14:12:10 +0100 Subject: [PATCH 5/8] dialyzer and coding improvements --- rebar.lock | 4 ++ src/hooks/mongoose_hooks.erl | 2 +- src/mongoose_debug.erl | 9 ++++ src/mongoose_traffic.erl | 19 ++++++-- src/mongoose_traffic_channel.erl | 82 ++++++++++++++++++++------------ 5 files changed, 80 insertions(+), 36 deletions(-) diff --git a/rebar.lock b/rebar.lock index 1cb77836836..61fbe8068b0 100644 --- a/rebar.lock +++ b/rebar.lock @@ -123,6 +123,10 @@ {<<"thoas">>,{pkg,<<"thoas">>,<<"1.0.0">>},2}, {<<"tirerl">>,{pkg,<<"tirerl">>,<<"1.2.0">>},0}, {<<"tomerl">>,{pkg,<<"tomerl">>,<<"0.5.0">>},0}, + {<<"traffic_monitor">>, + {git,"https://github.com/bartekgorny/mongooseim_traffic_monitor.git", + {ref,"f80a45dad104fd72eed7096c788962dab7139fea"}}, + 0}, {<<"trails">>,{pkg,<<"trails">>,<<"2.3.0">>},0}, {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.0">>},1}, {<<"uuid">>,{pkg,<<"uuid_erl">>,<<"2.0.5">>},0}, diff --git a/src/hooks/mongoose_hooks.erl b/src/hooks/mongoose_hooks.erl index bb1ff14394e..000459a9334 100644 --- a/src/hooks/mongoose_hooks.erl +++ b/src/hooks/mongoose_hooks.erl @@ -538,7 +538,7 @@ sasl2_success(HostType, Acc, Params) -> -spec c2s_debug(Acc, Arg) -> mongoose_acc:t() when Acc :: mongoose_acc:t() | no_acc, - Arg :: {out, jid:jid() | undefined, exml:element()}| {in, exml:element()}. + Arg :: mongoose_debug:debug_entry(). c2s_debug(Acc, Arg) -> run_global_hook(c2s_debug, Acc, #{arg => Arg}). diff --git a/src/mongoose_debug.erl b/src/mongoose_debug.erl index 85c7148ba0e..200bdece83f 100644 --- a/src/mongoose_debug.erl +++ b/src/mongoose_debug.erl @@ -11,15 +11,21 @@ -include("jlib.hrl"). -include_lib("exml/include/exml_stream.hrl"). +-type debug_entry() :: {client_to_server, jid:jid() | undefined, exml:element()}| {server_to_client, jid:jid(), exml:element()}. +-type direction() :: client_to_server | server_to_client. +-export_type([debug_entry/0, direction/0]). + %% API -export([start/2, stop/1]). -export([trace_traffic/3]). -export([supported_features/0]). +-spec start(mongooseim:host_type(), gen_mod:module_opts()) -> ok. start(Host, _Opts) -> gen_hook:add_handlers(hooks(Host)), ok. +-spec stop(mongooseim:host_type()) -> ok. stop(Host) -> gen_hook:delete_handlers(hooks(Host)), ok. @@ -28,6 +34,8 @@ hooks(_Host) -> [{c2s_debug, global, fun ?MODULE:trace_traffic/3, #{}, 50}]. +-spec trace_traffic(mongoose_acc:t(), #{arg => debug_entry()}, term()) -> + {ok, mongoose_acc:t()}. trace_traffic(Acc, #{arg := {client_to_server, From, El}}, _) -> Sfrom = binary_to_list(maybe_jid_to_binary(From)), Sto = binary_to_list(get_attr(El, <<"to">>)), @@ -45,6 +53,7 @@ trace_traffic(Acc, #{arg := {server_to_client, To, El}}, _) -> traffic(_Sender, _Marker, _Recipient, _Stanza) -> ok. +-spec supported_features() -> [atom()]. supported_features() -> [dynamic_domains]. maybe_jid_to_binary(undefined) -> <<" ">>; diff --git a/src/mongoose_traffic.erl b/src/mongoose_traffic.erl index 39de27af294..e7610738f3e 100644 --- a/src/mongoose_traffic.erl +++ b/src/mongoose_traffic.erl @@ -17,8 +17,9 @@ -export([init/2]). -define(SERVER, ?MODULE). +-type state() :: [pid()]. - +-spec start(mongooseim:host_type(), gen_mod:module_opts()) -> ok. start(HostType, _Opts) -> gen_hook:add_handlers(hooks(HostType)), case whereis(?MODULE) of @@ -34,6 +35,7 @@ start(HostType, _Opts) -> end, ok. +-spec stop(mongooseim:host_type()) -> ok. stop(Host) -> gen_hook:delete_handlers(hooks(Host)), supervisor:terminate_child(ejabberd_sup, ?MODULE), @@ -43,8 +45,11 @@ stop(Host) -> hooks(_HostType) -> [{c2s_debug, global, fun ?MODULE:trace_traffic/3, #{}, 50}]. +-spec supported_features() -> [atom()]. supported_features() -> [dynamic_domains]. +-spec trace_traffic(mongoose_acc:t(), #{arg => mongoose_debug:debug_entry()}, term()) -> + {ok, mongoose_acc:t()}. trace_traffic(Acc, #{arg := {client_to_server, From, El}}, _) -> traffic(client_to_server, From, El), {ok, Acc}; @@ -52,13 +57,15 @@ trace_traffic(Acc, #{arg := {server_to_client, To, El}}, _) -> traffic(server_to_client, To, El), {ok, Acc}. +-spec traffic(mongoose_debug:direction(), jid:jid(), exml:element()) -> + ok. traffic(Dir, Jid, El) -> St = iolist_to_binary(fix_and_format(El)), - UserPid = self(), - gen_server:cast(?MODULE, {message, Dir, {UserPid, Jid}, St}), + UserSessionPid = self(), + gen_server:cast(?MODULE, {message, Dir, UserSessionPid, Jid, St}), ok. - +-spec init(term()) -> {ok, state()}. init([]) -> register(?MODULE, self()), {ok, []}. @@ -71,13 +78,15 @@ handle_call({unregister, Pid}, _From, State) -> handle_call(_, _, State) -> {reply, ok, State}. -handle_cast({message, _, _, _} = Msg, State) -> +handle_cast({message, _, _, _, _} = Msg, State) -> lists:map(fun(Pid) -> Pid ! Msg end, State), {noreply, State}. handle_info({'DOWN', _, _, Pid, _}, State) -> {noreply, lists:delete(Pid, State)}. +-spec init(cowboy_req:req(), term()) -> + {ok, cowboy_req:req(), term()}. init(Req, State) -> {ok, Cwd} = file:get_cwd(), Base = Cwd ++ "/web/traffic", diff --git a/src/mongoose_traffic_channel.erl b/src/mongoose_traffic_channel.erl index 90b0c9073c8..70d6e67e0c3 100644 --- a/src/mongoose_traffic_channel.erl +++ b/src/mongoose_traffic_channel.erl @@ -21,16 +21,27 @@ -define(MAX_ITEMS, 500). -define(MAX_TRACED, 100). +-type str_pid() :: binary(). % representation of traced user's session pid +-type str_stanza() :: binary(). + -record(state, {traces = #{}, tracing = false, current = <<>>, mappings = #{}, start_times = #{}}). +-type state() :: #state{traces :: #{str_pid() => queue:queue()}, + tracing :: boolean(), + current :: str_pid(), + mappings :: #{str_pid() => jid:jid()}, + start_times :: #{str_pid() => float()}}. + %%-------------------------------------------------------------------- %% Common callbacks for all cowboy behaviours %%-------------------------------------------------------------------- +-spec init(cowboy_req:req(), proplists:proplist()) -> + {cowboy_websocket, cowboy_req:req(), proplists:proplist(), map()}. init(Req, Opts) -> Peer = cowboy_req:peer(Req), PeerCert = cowboy_req:cert(Req), @@ -72,14 +83,21 @@ websocket_handle(Any, State) -> {ok, State}. % Other messages from the system are handled here. -websocket_info({message, _Dir, _J, _Stanza}, #state{tracing = false} = State) -> +-spec websocket_info({message, + mongoose_debug:direction(), + pid(), + jid:jid(), + str_stanza()}, + state()) -> + {ok | stop, state()}. +websocket_info({message, _Dir, _P, _J, _Stanza}, #state{tracing = false} = State) -> {ok, State}; -websocket_info({message, Dir, {Pid, Jid}, Stanza} = Message, State) -> +websocket_info({message, Dir, Pid, Jid, Stanza} = Message, State) -> Spid = pid_to_binary(Pid), Now = now_seconds(), {Traces1, Mappings, IsNewMapping} = record_item(Now, Dir, - {Spid, Jid}, + Spid, Jid, Stanza, State#state.traces, State#state.mappings), @@ -90,7 +108,7 @@ websocket_info({message, Dir, {Pid, Jid}, Stanza} = Message, State) -> N when N > ?MAX_TRACED -> force_stop_tracing(State1); _ -> - maybe_send_to_user(Now, {IsNewMapping, Now}, Message, State3) + maybe_send_to_user(Now, IsNewMapping, Message, State3) end; websocket_info(stop, State) -> {stop, State}; @@ -103,20 +121,24 @@ force_stop_tracing(State) -> M = reply(<<"error">>, #{<<"reason">> => <<"too_many_traced_procs">>}), {reply, M, State1}. -maybe_send_to_user(Now, IsNewMapping, {message, Dir, {Pid, Jid}, Stanza}, State) -> +maybe_send_to_user(Now, IsNewMapping, {message, Dir, Pid, Jid, Stanza}, State) -> Spid = pid_to_binary(Pid), - Announcement = maybe_announce_new(IsNewMapping, Spid, Jid), + Announcement = maybe_announce_new(IsNewMapping, Now, Spid, Jid), Msg = maybe_send_current(Now, Dir, Spid, Stanza, State), {reply, Announcement ++ Msg, State}. -maybe_announce_new({true, StartTime}, Spid, Jid) -> - {BareJid, FullJid} = format_jid(Jid), +maybe_announce_new(true, StartTime, Spid, Jid) -> + {BareJid, FullJid} = case classify_jid(Jid) of + empty -> {<<>>, <<>>}; + bare -> {<<>>, jid:to_bare_binary(Jid)}; + full -> {jid:to_binary(Jid), <<>>} + end, [reply(<<"new_trace">>, #{<<"pid">> => Spid, <<"start_time">> => StartTime, <<"bare_jid">> => BareJid, <<"full_jid">> => FullJid})]; -maybe_announce_new({false, _}, _, _) -> +maybe_announce_new(false, _, _, _) -> []. maybe_send_current(Now, Dir, Spid, Stanza, State) -> @@ -179,20 +201,20 @@ reply(Event, Payload) -> %%% Internal functions %%%=================================================================== +-spec is_current(str_pid(), state()) -> boolean(). is_current(J, #state{current = J}) -> true; is_current(_, _) -> false. -record_item(Time, Dir, {Spid, Jid}, Stanza, Traces, Mappings) -> +record_item(Time, Dir, Spid, Jid, Stanza, Traces, Mappings) -> Tr = case maps:get(Spid, Traces, undefined) of undefined -> queue:new(); Q -> Q end, - Fjid = format_jid(Jid), - IsNew = is_new_mapping(maps:get(Spid, Mappings, undefined), Fjid), + IsNew = is_new_mapping(maps:get(Spid, Mappings, undefined), Jid), Mappings1 = case IsNew of true -> - maps:put(Spid, Fjid, Mappings); + maps:put(Spid, Jid, Mappings); false -> Mappings end, @@ -213,29 +235,29 @@ format_trace(Trace, StartTime) -> end, lists:reverse(queue:to_list(Trace))). +-spec pid_to_binary(pid()) -> str_pid(). pid_to_binary(Pid) when is_pid(Pid) -> [Spid] = io_lib:format("~p", [Pid]), list_to_binary(Spid). -format_jid(Bin) when is_binary(Bin) -> - format_jid(jid:from_binary(Bin)); -format_jid(undefined) -> - {<<>>, <<>>}; -format_jid(#jid{lresource = <<>>} = Jid) -> - {jid:to_binary(jid:to_lower(Jid)), <<>>}; -format_jid(#jid{} = Jid) -> - {<<>>, jid:to_binary(jid:to_lower(Jid))}. - -is_new_mapping(undefined, {_, _}) -> true; % showed up for the very first time -is_new_mapping({_, _}, {<<>>, <<>>}) -> false; % nothing new -is_new_mapping({<<>>, <<>>}, {_, _}) -> true; % nothing old, something new -is_new_mapping({<<>>, _}, {_, _}) -> false; % we already have full jid -is_new_mapping({_, <<>>}, {<<>>, _}) -> true; % we have bare, received full -is_new_mapping(_, {_, _}) -> false. +-spec classify_jid(binary() | jid:jid()) -> empty | bare | full. +classify_jid(Bin) when is_binary(Bin) -> classify_jid(jid:from_binary(Bin)); +classify_jid(undefined) -> empty; +classify_jid(#jid{lresource = <<>>}) -> bare; +classify_jid(#jid{}) -> full. + +% we map pids to jids, initially we don't know the jid, then we +% know bare jid, and then full jid +% we need to know when it changes so that send an update to client +-spec is_new_mapping(jid:jid(), jid:jid()) -> boolean(). +is_new_mapping(Old, New) -> + case {classify_jid(Old), classify_jid(New)} of + {S, S} -> false; + _ -> true + end. now_seconds() -> - {Msec, Sec, Micro} = os:timestamp(), - Msec * 1000000 + Sec + (Micro / 1000000). + os:system_time(microsecond) / 1000000. maybe_store_start_time(Spid, Time, #state{start_times = StartTimes} = State) -> case maps:get(Spid, StartTimes, undefined) of From 3791154c24fafe3ac039bcdb9ec111cd64b5457d Mon Sep 17 00:00:00 2001 From: Bartek Gorny Date: Fri, 24 Nov 2023 21:43:26 +0100 Subject: [PATCH 6/8] tests --- big_tests/tests/traffic_monitor_SUITE.erl | 135 ++++++++++++++++++++++ src/mongoose_traffic.erl | 30 ++--- src/mongoose_traffic_channel.erl | 10 +- test/mongoose_traffic_SUITE.erl | 92 +++++++++++++++ 4 files changed, 249 insertions(+), 18 deletions(-) create mode 100644 big_tests/tests/traffic_monitor_SUITE.erl create mode 100644 test/mongoose_traffic_SUITE.erl diff --git a/big_tests/tests/traffic_monitor_SUITE.erl b/big_tests/tests/traffic_monitor_SUITE.erl new file mode 100644 index 00000000000..5d17f284616 --- /dev/null +++ b/big_tests/tests/traffic_monitor_SUITE.erl @@ -0,0 +1,135 @@ +-module(traffic_monitor_SUITE). +-compile([export_all, nowarn_export_all]). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("escalus/include/escalus_xmlns.hrl"). +-include_lib("exml/include/exml.hrl"). +-include_lib("jid/include/jid.hrl"). + +%%-------------------------------------------------------------------- +%% Suite configuration +%%-------------------------------------------------------------------- + + +all() -> + [ + {group, regular} + ]. + +groups() -> + [{regular, [], [get_page, tracing_from_websocket]}]. + +suite() -> + escalus:suite(). + +init_per_suite(Config) -> + mongoose_helper:inject_module(?MODULE), + escalus:init_per_suite(Config). + +end_per_suite(Config) -> + escalus:end_per_suite(Config). + +init_per_group(GroupName, Config) when GroupName =:= regular; GroupName =:= async_pools -> + HostType = domain_helper:host_type(), + SecHostType = domain_helper:secondary_host_type(), + Config1 = dynamic_modules:save_modules(HostType, Config), + Config2 = dynamic_modules:save_modules(SecHostType, Config1), + ok = dynamic_modules:ensure_modules(HostType, [{mongoose_traffic, #{}}]), + init_listeners(), + Config2. + +init_per_testcase(get_page, Config) -> + {ok, Client} = fusco:start("http://localhost:5111", []), + init_per_testcase(generic, [{http_client, Client} | Config]); +init_per_testcase(tracing_from_websocket, Config) -> + {ok, Wsc} = gun:open("localhost", 5111, #{transport => tcp, protocols => [http], retry => 1}), + StreamRef = gun:ws_upgrade(Wsc, "/ws-traffic", [], #{}), + receive + {gun_upgrade, Wsc, StreamRef, [<<"websocket">>], _} -> ok + after 1000 -> + ct:fail("gun did not fire") + end, + init_per_testcase(generic, [{ws_client, {Wsc, StreamRef}} | Config]); +init_per_testcase(CaseName, Config) -> + escalus:init_per_testcase(CaseName, Config). + +end_per_testcase(get_page, Config) -> + C = proplists:get_value(http_client, Config), + fusco:disconnect(C), + end_per_testcase(generic, Config); +end_per_testcase(tracing_from_websocket, Config) -> + {C, _} = proplists:get_value(ws_client, Config), + gun:close(C), + end_per_testcase(generic, Config); +end_per_testcase(CaseName, Config) -> + escalus:end_per_testcase(CaseName, Config). + +end_per_group(GroupName, Config) when GroupName =:= regular -> + escalus_fresh:clean(), + dynamic_modules:restore_modules(Config). + +get_page(Config) -> + % just to make sure listener works + C = proplists:get_value(http_client, Config), + {ok, Res} = fusco:request(C, <<"/traffic">>, <<"GET">>, [], [], 5000), + {{<<"200">>, <<"OK">>}, _, _, _, _} = Res, + ok. + +tracing_from_websocket(Config) -> + C = proplists:get_value(ws_client, Config), + send(C, <<"get_status">>, #{}), + receive_status(false), + send(C, <<"trace_flag">>, #{<<"value">> => true}), + receive_status(true), + send(C, <<"get_status">>, #{}), + receive_status(true), + escalus:fresh_story(Config, [{carol, 1}], fun(Alice) -> + {<<"new_trace">>, #{<<"bare_jid">> := <<>>}} = receive_msg(), + {<<"new_trace">>, #{<<"full_jid">> := <<>>, <<"pid">> := BPid}} = receive_msg(), + send(C, <<"get_trace">>, #{<<"pid">> => BPid}), + {<<"get_trace">>, Trace} = receive_msg(), + [_ | _] = maps:get(<<"trace">>, Trace), + Server = escalus_client:server(Alice), + escalus:send(Alice, escalus_stanza:to(escalus_stanza:iq_get(?NS_DISCO_INFO, []), Server)), + {<<"message">>, _} = receive_msg(), + ok + end), + ok. + +init_listeners() -> + Opts = #{connection_type => undefined, + handlers => + [#{host => '_',module => mongoose_traffic, path => "/traffic/[...]"}, + #{host => '_',module => mongoose_traffic_channel, path => "/ws-traffic"}], + ip_address => "0", + ip_tuple => {0,0,0,0}, + ip_version => 4, + module => ejabberd_cowboy, + port => 5111, + proto => tcp, + protocol => #{compress => false}, + transport => #{max_connections => 1024,num_acceptors => 2}}, + distributed_helper:rpc(distributed_helper:mim(), ejabberd_cowboy, + start_listener, [Opts]), + ok. + +receive_status(Expected) -> + {Evt, Data} = receive_msg(), + ?assertEqual(<<"status">>, Evt), + ?assertEqual(Expected, maps:get(<<"trace_flag">>, Data, missing)). + +receive_msg() -> + receive + {gun_ws, _, _, {text, Bin}} -> + {Data} = jiffy:decode(Bin), + Event = proplists:get_value(<<"event">>, Data), + {Payload} = proplists:get_value(<<"payload">>, Data), + {Event, maps:from_list(Payload)} + after 100 -> + ct:fail("message not received") + end. + +send({C, Stream}, Event, Payload) -> + Data = #{event => Event, payload => Payload}, + gun:ws_send(C, Stream, {text, jiffy:encode(Data)}). \ No newline at end of file diff --git a/src/mongoose_traffic.erl b/src/mongoose_traffic.erl index e7610738f3e..fd01dc652f6 100644 --- a/src/mongoose_traffic.erl +++ b/src/mongoose_traffic.erl @@ -20,20 +20,24 @@ -type state() :: [pid()]. -spec start(mongooseim:host_type(), gen_mod:module_opts()) -> ok. -start(HostType, _Opts) -> +start(HostType, Opts) -> gen_hook:add_handlers(hooks(HostType)), - case whereis(?MODULE) of - undefined -> - Traffic = {mongoose_traffic, - {gen_server, start_link, [?MODULE, [], []]}, - permanent, 1000, supervisor, [?MODULE]}, - % there has to be another layer - % channel will set up its own traces, someone has to watch and distribute stanzas - ejabberd_sup:start_child(Traffic); - _ -> - ok - end, - ok. + case maps:get(standalone, Opts, false) of + true -> + gen_server:start_link(?MODULE, [], []); + false -> + case whereis(?MODULE) of + undefined -> + Traffic = {mongoose_traffic, + {gen_server, start_link, [?MODULE, [], []]}, + permanent, 1000, supervisor, [?MODULE]}, + % there has to be another layer + % channel will set up its own traces, someone has to watch and distribute stanzas + ejabberd_sup:start_child(Traffic); + _ -> + ok + end + end. -spec stop(mongooseim:host_type()) -> ok. stop(Host) -> diff --git a/src/mongoose_traffic_channel.erl b/src/mongoose_traffic_channel.erl index 70d6e67e0c3..1d6e00b9651 100644 --- a/src/mongoose_traffic_channel.erl +++ b/src/mongoose_traffic_channel.erl @@ -45,7 +45,7 @@ init(Req, Opts) -> Peer = cowboy_req:peer(Req), PeerCert = cowboy_req:cert(Req), - ?DEBUG("cowboy init: ~p~n", [{Req, Opts}]), + ?LOG_DEBUG("cowboy init: ~p~n", [{Req, Opts}]), AllModOpts = [{peer, Peer}, {peercert, PeerCert} | Opts], %% upgrade protocol {cowboy_websocket, Req, AllModOpts, #{}}. @@ -59,7 +59,7 @@ terminate(_Reason, _Req, _State) -> % Called for every new websocket connection. websocket_init(Opts) -> - ?DEBUG("websocket_init: ~p~n", [Opts]), + ?LOG_DEBUG("websocket_init: ~p~n", [Opts]), gen_server:call(mongoose_traffic, {register, self()}), {ok, #state{}}. @@ -73,13 +73,13 @@ websocket_handle({text, Msg}, State) -> end; websocket_handle({binary, Msg}, State) -> - ?DEBUG("Received binary: ~p", [Msg]), + ?LOG_DEBUG("Received binary: ~p", [Msg]), {ok, State}; % With this callback we can handle other kind of % messages, like binary. websocket_handle(Any, State) -> - ?DEBUG("Received non-text: ~p", [Any]), + ?LOG_DEBUG("Received non-text: ~p", [Any]), {ok, State}. % Other messages from the system are handled here. @@ -113,7 +113,7 @@ websocket_info({message, Dir, Pid, Jid, Stanza} = Message, State) -> websocket_info(stop, State) -> {stop, State}; websocket_info(Info, State) -> - ?DEBUG("unknown info: ~p", [Info]), + ?LOG_DEBUG("unknown info: ~p", [Info]), {ok, State}. force_stop_tracing(State) -> diff --git a/test/mongoose_traffic_SUITE.erl b/test/mongoose_traffic_SUITE.erl new file mode 100644 index 00000000000..e463c3d7bf2 --- /dev/null +++ b/test/mongoose_traffic_SUITE.erl @@ -0,0 +1,92 @@ +-module(mongoose_traffic_SUITE). +-compile([export_all, nowarn_export_all]). + +-include_lib("kernel/include/logger.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include("jlib.hrl"). + +all() -> + [mongoose_debug, mongoose_traffic]. + +init_per_suite(Config) -> + application:ensure_all_started(exometer_core), + mongoose_config:set_opts(#{all_metrics_are_global => false}), + {ok, _} = application:ensure_all_started(jid), + Config. + +end_per_suite(Config) -> + mongoose_config:erase_opts(), + application:stop(exometer_core), + Config. + +init_per_testcase(_, Config) -> + mongooseim_helper:start_link_loaded_hooks(), + Config. + +end_per_testcase(_, Config) -> + meck:unload(), + Config. + + +%%---------------------------------------------------------------- +%% test cases +%%---------------------------------------------------------------- + +mongoose_debug(_) -> + Me = self(), + Fmtr = fun(I) -> Me ! I, <<"ok">> end, + code:ensure_loaded(mongoose_debug), + % this is how mongoose_debug is meant to be used + 1 = recon_trace:calls({mongoose_debug, traffic, '_'}, 10, [{scope, local}, {formatter, Fmtr}]), + mongoose_debug:start(localhost, []), + call_hooks(), + ?assertMatch({trace, _ ,call, {mongoose_debug, + traffic, + ["a@localhost/c"," C >>>> MiM "," ", _]}}, + receive_msg()), + ok. + +mongoose_traffic(_) -> + mongoose_traffic:start(localhost, #{standalone => true}), + gen_server:call(mongoose_traffic, {register, self()}), + call_hooks(), + ?assertMatch({message,client_to_server, _, #jid{}, _}, + receive_msg()), + gen_server:call(mongoose_traffic, {unregister, self()}), + call_hooks(), + no_new_msg(), + ok. + +call_hooks() -> + Acc = mongoose_acc:new(#{location => ?LOCATION, lserver => localhost}), + From = jid:from_binary(<<"a@localhost/c">>), + El = #xmlel{name = <<"testelement">>}, + mongoose_hooks:c2s_debug(Acc, {client_to_server, From, El}), + ok. + +receive_msg() -> + receive + M -> M + after 100 -> + ct:fail("message not received", []) + end. + +no_new_msg() -> + receive + M -> ct:fail("unexpected message received: ~p", [M]) + after 100 -> + ok + end. + +get_handlers_for_all_hooks() -> + maps:to_list(persistent_term:get(gen_hook, #{})). + +flush() -> + receive + M -> + ct:pal("received: ~p", [M]), + flush() + after 100 -> + ct:pal("asdf over: ~p", [over]), + ok + end. From 2e1598cfeefdf23a52a2e4df963a0d2b6911021e Mon Sep 17 00:00:00 2001 From: Bartek Gorny Date: Mon, 27 Nov 2023 11:50:19 +0100 Subject: [PATCH 7/8] xref --- src/mongoose_traffic.erl | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mongoose_traffic.erl b/src/mongoose_traffic.erl index fd01dc652f6..c601fb7e487 100644 --- a/src/mongoose_traffic.erl +++ b/src/mongoose_traffic.erl @@ -15,6 +15,7 @@ -export([init/1, handle_call/3, handle_cast/2, handle_info/2]). %% cowboy handler for serving main page -export([init/2]). +-ignore_xref([init/2]). -define(SERVER, ?MODULE). -type state() :: [pid()]. From f9cee46c6b6275e93a7845f18f455fe63cfc7db7 Mon Sep 17 00:00:00 2001 From: Bartek Gorny Date: Mon, 8 Apr 2024 16:09:42 +0200 Subject: [PATCH 8/8] minor --- test/mongoose_traffic_SUITE.erl | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/mongoose_traffic_SUITE.erl b/test/mongoose_traffic_SUITE.erl index e463c3d7bf2..3c8159f0825 100644 --- a/test/mongoose_traffic_SUITE.erl +++ b/test/mongoose_traffic_SUITE.erl @@ -39,7 +39,7 @@ mongoose_debug(_) -> % this is how mongoose_debug is meant to be used 1 = recon_trace:calls({mongoose_debug, traffic, '_'}, 10, [{scope, local}, {formatter, Fmtr}]), mongoose_debug:start(localhost, []), - call_hooks(), + call_hooks_in(), ?assertMatch({trace, _ ,call, {mongoose_debug, traffic, ["a@localhost/c"," C >>>> MiM "," ", _]}}, @@ -49,21 +49,29 @@ mongoose_debug(_) -> mongoose_traffic(_) -> mongoose_traffic:start(localhost, #{standalone => true}), gen_server:call(mongoose_traffic, {register, self()}), - call_hooks(), + call_hooks_in(), ?assertMatch({message,client_to_server, _, #jid{}, _}, receive_msg()), gen_server:call(mongoose_traffic, {unregister, self()}), - call_hooks(), + call_hooks_in(), + call_hooks_out(), no_new_msg(), ok. -call_hooks() -> +call_hooks_in() -> Acc = mongoose_acc:new(#{location => ?LOCATION, lserver => localhost}), From = jid:from_binary(<<"a@localhost/c">>), El = #xmlel{name = <<"testelement">>}, mongoose_hooks:c2s_debug(Acc, {client_to_server, From, El}), ok. +call_hooks_out() -> + Acc = mongoose_acc:new(#{location => ?LOCATION, lserver => localhost}), + From = jid:from_binary(<<"a@localhost/c">>), + El = #xmlel{name = <<"testelement">>}, + mongoose_hooks:c2s_debug(Acc, {server_to_client, From, El}), + ok. + receive_msg() -> receive M -> M