Current section
Files
Jump to
Current section
Files
src/quic_qlog.erl
%%%-------------------------------------------------------------------
%%% @doc QLOG Tracing for QUIC (draft-ietf-quic-qlog-quic-events)
%%%
%%% Provides JSON-SEQ format trace files for debug visibility with
%%% Wireshark/qvis compatibility.
%%%
%%% Usage: pass `qlog' option to `quic:connect/4':
%%% `#{qlog => #{enabled => true, dir => "/tmp/qlog"}}'
%%% @end
%%%-------------------------------------------------------------------
-module(quic_qlog).
-include("quic_qlog.hrl").
%% API
-export([
new/3,
close/1,
is_enabled/1
]).
%% Event emission
-export([
packet_sent/2,
packet_received/2,
frames_processed/2,
connection_started/1,
connection_state_updated/3,
connection_closed/3,
packets_acked/3,
packet_lost/2,
metrics_updated/2
]).
%% Writer process
-export([
start_writer/2,
writer_loop/1
]).
%%====================================================================
%% Types
%%====================================================================
-type qlog_opts() :: #{
enabled => boolean(),
dir => file:filename(),
events => all | [atom()]
}.
-export_type([qlog_opts/0]).
%%====================================================================
%% Writer State Record
%%====================================================================
-record(writer_state, {
fd :: file:io_device(),
buffer = [] :: [iodata()],
buffer_size = 0 :: non_neg_integer(),
flush_timer :: reference() | undefined
}).
%%====================================================================
%% API
%%====================================================================
%% @doc Create a new QLOG context from connection options.
%% Returns undefined if qlog is disabled.
-spec new(Opts :: map(), ODCID :: binary(), VantagePoint :: client | server) ->
#qlog_ctx{} | undefined.
new(Opts, ODCID, VantagePoint) ->
%% Check connection options first, then application env
QlogOpts = get_qlog_opts(Opts),
case maps:get(enabled, QlogOpts, false) of
false ->
undefined;
true ->
Dir = maps:get(dir, QlogOpts, "/tmp/qlog"),
Events = maps:get(events, QlogOpts, all),
RefTime = erlang:system_time(millisecond),
%% Create directory if it doesn't exist
ok = filelib:ensure_dir(filename:join(Dir, "dummy")),
%% Generate filename: {odcid_hex}_{vantage}_{timestamp}.qlog
Filename = generate_filename(Dir, ODCID, VantagePoint, RefTime),
%% Create JSON-SEQ header
Header = encode_header(ODCID, VantagePoint, RefTime),
%% Start writer process
{ok, WriterPid} = start_writer(Filename, Header),
#qlog_ctx{
enabled = true,
writer = WriterPid,
odcid = ODCID,
reference_time = RefTime,
vantage_point = VantagePoint,
events = Events,
dir = Dir
}
end.
%% @doc Close the QLOG context and flush remaining data.
-spec close(#qlog_ctx{} | undefined) -> ok.
close(undefined) ->
ok;
close(#qlog_ctx{enabled = false}) ->
ok;
close(#qlog_ctx{writer = WriterPid}) when is_pid(WriterPid) ->
WriterPid ! {close, self()},
receive
{closed, WriterPid} -> ok
after 5000 ->
%% Force kill if writer doesn't respond
exit(WriterPid, kill),
ok
end;
close(_) ->
ok.
%% @doc Check if QLOG is enabled for this context.
-spec is_enabled(#qlog_ctx{} | undefined) -> boolean().
is_enabled(undefined) -> false;
is_enabled(#qlog_ctx{enabled = Enabled}) -> Enabled.
%%====================================================================
%% Event Emission
%%====================================================================
%% @doc Log a packet_sent event.
-spec packet_sent(#qlog_ctx{} | undefined, map()) -> ok.
packet_sent(undefined, _) ->
ok;
packet_sent(#qlog_ctx{enabled = false}, _) ->
ok;
packet_sent(Ctx, Info) ->
case event_enabled(Ctx, ?QLOG_PACKET_SENT) of
false ->
ok;
true ->
Event = #{
name => <<"quic:packet_sent">>,
data => encode_packet_info(Info)
},
emit_event(Ctx, Event)
end.
%% @doc Log a packet_received event.
-spec packet_received(#qlog_ctx{} | undefined, map()) -> ok.
packet_received(undefined, _) ->
ok;
packet_received(#qlog_ctx{enabled = false}, _) ->
ok;
packet_received(Ctx, Info) ->
case event_enabled(Ctx, ?QLOG_PACKET_RECEIVED) of
false ->
ok;
true ->
Event = #{
name => <<"quic:packet_received">>,
data => encode_packet_info(Info)
},
emit_event(Ctx, Event)
end.
%% @doc Log frames_processed event.
-spec frames_processed(#qlog_ctx{} | undefined, [term()]) -> ok.
frames_processed(undefined, _) ->
ok;
frames_processed(#qlog_ctx{enabled = false}, _) ->
ok;
frames_processed(Ctx, Frames) ->
case event_enabled(Ctx, ?QLOG_FRAMES_PROCESSED) of
false ->
ok;
true ->
Event = #{
name => <<"quic:frames_processed">>,
data => #{frames => encode_frames(Frames)}
},
emit_event(Ctx, Event)
end.
%% @doc Log connection_started event.
-spec connection_started(#qlog_ctx{} | undefined) -> ok.
connection_started(undefined) ->
ok;
connection_started(#qlog_ctx{enabled = false}) ->
ok;
connection_started(#qlog_ctx{odcid = ODCID, vantage_point = VP} = Ctx) ->
case event_enabled(Ctx, ?QLOG_CONNECTION_STARTED) of
false ->
ok;
true ->
Event = #{
name => <<"quic:connection_started">>,
data => #{
odcid => hex_encode(ODCID),
vantage_point => VP
}
},
emit_event(Ctx, Event)
end.
%% @doc Log connection_state_updated event.
-spec connection_state_updated(#qlog_ctx{} | undefined, atom(), atom()) -> ok.
connection_state_updated(undefined, _, _) ->
ok;
connection_state_updated(#qlog_ctx{enabled = false}, _, _) ->
ok;
connection_state_updated(Ctx, OldState, NewState) ->
case event_enabled(Ctx, ?QLOG_CONNECTION_STATE_UPDATED) of
false ->
ok;
true ->
Event = #{
name => <<"quic:connection_state_updated">>,
data => #{
old => OldState,
new => NewState
}
},
emit_event(Ctx, Event)
end.
%% @doc Log connection_closed event.
-spec connection_closed(#qlog_ctx{} | undefined, integer() | atom(), binary() | undefined) -> ok.
connection_closed(undefined, _, _) ->
ok;
connection_closed(#qlog_ctx{enabled = false}, _, _) ->
ok;
connection_closed(Ctx, ErrorCode, Reason) ->
case event_enabled(Ctx, ?QLOG_CONNECTION_CLOSED) of
false ->
ok;
true ->
Data = #{error_code => ErrorCode},
Data1 =
case Reason of
undefined -> Data;
<<>> -> Data;
_ -> Data#{reason => Reason}
end,
Event = #{
name => <<"quic:connection_closed">>,
data => Data1
},
emit_event(Ctx, Event)
end.
%% @doc Log packets_acked event.
-spec packets_acked(#qlog_ctx{} | undefined, [non_neg_integer()], map()) -> ok.
packets_acked(undefined, _, _) ->
ok;
packets_acked(#qlog_ctx{enabled = false}, _, _) ->
ok;
packets_acked(Ctx, PacketNumbers, Info) ->
case event_enabled(Ctx, ?QLOG_PACKETS_ACKED) of
false ->
ok;
true ->
Data = #{
packet_numbers => PacketNumbers
},
Data1 =
case maps:get(rtt_sample, Info, undefined) of
undefined -> Data;
RTT -> Data#{rtt_sample => RTT}
end,
Event = #{
name => <<"quic:packets_acked">>,
data => Data1
},
emit_event(Ctx, Event)
end.
%% @doc Log packet_lost event.
-spec packet_lost(#qlog_ctx{} | undefined, map()) -> ok.
packet_lost(undefined, _) ->
ok;
packet_lost(#qlog_ctx{enabled = false}, _) ->
ok;
packet_lost(Ctx, Info) ->
case event_enabled(Ctx, ?QLOG_PACKET_LOST) of
false ->
ok;
true ->
Event = #{
name => <<"quic:packet_lost">>,
data => #{
packet_number => maps:get(packet_number, Info),
reason => maps:get(reason, Info, unknown)
}
},
emit_event(Ctx, Event)
end.
%% @doc Log metrics_updated event.
-spec metrics_updated(#qlog_ctx{} | undefined, map()) -> ok.
metrics_updated(undefined, _) ->
ok;
metrics_updated(#qlog_ctx{enabled = false}, _) ->
ok;
metrics_updated(Ctx, Metrics) ->
case event_enabled(Ctx, ?QLOG_METRICS_UPDATED) of
false ->
ok;
true ->
Event = #{
name => <<"quic:metrics_updated">>,
data => Metrics
},
emit_event(Ctx, Event)
end.
%%====================================================================
%% Internal Functions
%%====================================================================
%% @private Get qlog options from connection opts or application env.
get_qlog_opts(Opts) ->
case maps:get(qlog, Opts, undefined) of
undefined ->
%% Check application environment
application:get_env(quic, qlog, #{enabled => false});
QlogOpts when is_map(QlogOpts) ->
QlogOpts
end.
%% @private Check if an event type should be logged.
event_enabled(#qlog_ctx{events = all}, _Event) -> true;
event_enabled(#qlog_ctx{events = Events}, Event) -> lists:member(Event, Events).
%% @private Generate the qlog filename.
generate_filename(Dir, ODCID, VantagePoint, Timestamp) ->
ODCIDHex = hex_encode(ODCID),
VP = atom_to_binary(VantagePoint),
Filename =
<<ODCIDHex/binary, "_", VP/binary, "_", (integer_to_binary(Timestamp))/binary, ".qlog">>,
filename:join(Dir, binary_to_list(Filename)).
%% @private Encode the JSON-SEQ header.
encode_header(ODCID, VantagePoint, RefTime) ->
Header = #{
qlog_format => ?QLOG_FORMAT,
qlog_version => ?QLOG_VERSION,
title => <<"erlang_quic">>,
trace => #{
vantage_point => #{type => VantagePoint},
common_fields => #{
protocol_type => [<<"QUIC">>],
group_id => hex_encode(ODCID),
reference_time => RefTime
}
}
},
encode_json(Header).
%% @private Emit an event to the writer process.
emit_event(#qlog_ctx{writer = WriterPid, reference_time = RefTime}, Event) ->
Now = erlang:system_time(millisecond),
RelTime = Now - RefTime,
EventWithTime = Event#{time => RelTime},
Json = encode_json(EventWithTime),
WriterPid ! {event, Json},
ok.
%% @private Encode a packet info map for qlog.
encode_packet_info(Info) ->
Base = #{},
Base1 =
case maps:get(packet_type, Info, undefined) of
undefined -> Base;
Type -> Base#{packet_type => Type}
end,
Base2 =
case maps:get(packet_number, Info, undefined) of
undefined -> Base1;
PN -> Base1#{packet_number => PN}
end,
Base3 =
case maps:get(length, Info, undefined) of
undefined -> Base2;
Len -> Base2#{length => Len}
end,
case maps:get(frames, Info, undefined) of
undefined -> Base3;
Frames -> Base3#{frames => encode_frames(Frames)}
end.
%% @private Encode a list of frames for qlog.
encode_frames(Frames) ->
[encode_frame(F) || F <- Frames].
%% @private Encode a single frame for qlog.
encode_frame(padding) ->
#{frame_type => <<"padding">>};
encode_frame(ping) ->
#{frame_type => <<"ping">>};
encode_frame({crypto, Offset, Data}) ->
#{frame_type => <<"crypto">>, offset => Offset, length => byte_size(Data)};
encode_frame({ack, Ranges, AckDelay, _ECN}) ->
#{frame_type => <<"ack">>, ack_ranges => encode_ack_ranges(Ranges), ack_delay => AckDelay};
encode_frame({stream, StreamId, Offset, Data, Fin}) ->
#{
frame_type => <<"stream">>,
stream_id => StreamId,
offset => Offset,
length => iolist_size(Data),
fin => Fin
};
encode_frame({max_data, MaxData}) ->
#{frame_type => <<"max_data">>, maximum => MaxData};
encode_frame({max_stream_data, StreamId, MaxData}) ->
#{frame_type => <<"max_stream_data">>, stream_id => StreamId, maximum => MaxData};
encode_frame({max_streams, bidi, Max}) ->
#{frame_type => <<"max_streams">>, stream_type => <<"bidirectional">>, maximum => Max};
encode_frame({max_streams, uni, Max}) ->
#{frame_type => <<"max_streams">>, stream_type => <<"unidirectional">>, maximum => Max};
encode_frame({reset_stream, StreamId, ErrorCode, FinalSize}) ->
#{
frame_type => <<"reset_stream">>,
stream_id => StreamId,
error_code => ErrorCode,
final_size => FinalSize
};
encode_frame({stop_sending, StreamId, ErrorCode}) ->
#{frame_type => <<"stop_sending">>, stream_id => StreamId, error_code => ErrorCode};
encode_frame({connection_close, ErrorCode, FrameType, Reason}) ->
#{
frame_type => <<"connection_close">>,
error_code => ErrorCode,
trigger_frame_type => FrameType,
reason_phrase => Reason
};
encode_frame({new_connection_id, SeqNum, RetirePrior, CID, _Token}) ->
#{
frame_type => <<"new_connection_id">>,
sequence_number => SeqNum,
retire_prior_to => RetirePrior,
connection_id => hex_encode(CID)
};
encode_frame({retire_connection_id, SeqNum}) ->
#{frame_type => <<"retire_connection_id">>, sequence_number => SeqNum};
encode_frame({path_challenge, Data}) ->
#{frame_type => <<"path_challenge">>, data => hex_encode(Data)};
encode_frame({path_response, Data}) ->
#{frame_type => <<"path_response">>, data => hex_encode(Data)};
encode_frame(handshake_done) ->
#{frame_type => <<"handshake_done">>};
encode_frame({new_token, Token}) ->
#{frame_type => <<"new_token">>, length => byte_size(Token)};
encode_frame({datagram, Data}) ->
#{frame_type => <<"datagram">>, length => iolist_size(Data)};
encode_frame(Frame) when is_tuple(Frame) ->
#{frame_type => <<"unknown">>, raw => element(1, Frame)};
encode_frame(_) ->
#{frame_type => <<"unknown">>}.
%% @private Encode ACK ranges for qlog.
encode_ack_ranges(Ranges) ->
[[Start, End] || {Start, End} <- Ranges].
%% @private Hex encode a binary.
hex_encode(Bin) when is_binary(Bin) ->
<<<<(hex_digit(N div 16)), (hex_digit(N rem 16))>> || <<N>> <= Bin>>.
hex_digit(N) when N < 10 -> $0 + N;
hex_digit(N) -> $a + N - 10.
%% @private Simple JSON encoder (no external dependencies).
encode_json(Map) when is_map(Map) ->
Pairs = maps:to_list(Map),
["{", lists:join(",", [encode_json_pair(K, V) || {K, V} <- Pairs]), "}"];
encode_json(List) when is_list(List) ->
["[", lists:join(",", [encode_json(E) || E <- List]), "]"];
encode_json(Bin) when is_binary(Bin) ->
["\"", escape_json_string(Bin), "\""];
encode_json(Atom) when is_atom(Atom) ->
["\"", atom_to_binary(Atom), "\""];
encode_json(Int) when is_integer(Int) ->
integer_to_binary(Int);
encode_json(Float) when is_float(Float) ->
float_to_binary(Float, [{decimals, 3}, compact]);
encode_json(true) ->
<<"true">>;
encode_json(false) ->
<<"false">>;
encode_json(null) ->
<<"null">>.
encode_json_pair(Key, Value) ->
[encode_json(Key), ":", encode_json(Value)].
%% @private Escape special JSON characters in a string.
escape_json_string(Bin) ->
escape_json_string(Bin, <<>>).
escape_json_string(<<>>, Acc) ->
Acc;
escape_json_string(<<$", Rest/binary>>, Acc) ->
escape_json_string(Rest, <<Acc/binary, "\\\"">>);
escape_json_string(<<$\\, Rest/binary>>, Acc) ->
escape_json_string(Rest, <<Acc/binary, "\\\\">>);
escape_json_string(<<$\n, Rest/binary>>, Acc) ->
escape_json_string(Rest, <<Acc/binary, "\\n">>);
escape_json_string(<<$\r, Rest/binary>>, Acc) ->
escape_json_string(Rest, <<Acc/binary, "\\r">>);
escape_json_string(<<$\t, Rest/binary>>, Acc) ->
escape_json_string(Rest, <<Acc/binary, "\\t">>);
escape_json_string(<<C, Rest/binary>>, Acc) when C < 32 ->
%% Control characters as \uXXXX
Hex = io_lib:format("\\u~4.16.0B", [C]),
escape_json_string(Rest, <<Acc/binary, (iolist_to_binary(Hex))/binary>>);
escape_json_string(<<C, Rest/binary>>, Acc) ->
escape_json_string(Rest, <<Acc/binary, C>>).
%%====================================================================
%% Writer Process
%%====================================================================
%% @doc Start the async writer process.
-spec start_writer(file:filename(), iodata()) -> {ok, pid()}.
start_writer(Filename, Header) ->
Parent = self(),
Pid = spawn_link(fun() ->
case file:open(Filename, [write, raw, delayed_write]) of
{ok, Fd} ->
%% Write header as first line
ok = file:write(Fd, [Header, "\n"]),
Parent ! {writer_ready, self()},
writer_loop(#writer_state{fd = Fd});
{error, Reason} ->
Parent ! {writer_error, self(), Reason},
exit(Reason)
end
end),
receive
{writer_ready, Pid} -> {ok, Pid};
{writer_error, Pid, Reason} -> {error, Reason}
after 5000 ->
exit(Pid, kill),
{error, timeout}
end.
%% @doc Writer process main loop.
writer_loop(#writer_state{fd = Fd, buffer = Buffer, buffer_size = Size} = State) ->
receive
{event, Json} ->
NewBuffer = [Buffer, Json, "\n"],
NewSize = Size + iolist_size(Json) + 1,
NewState = State#writer_state{buffer = NewBuffer, buffer_size = NewSize},
%% Check if we should flush
case NewSize >= ?QLOG_MAX_BUFFER_SIZE of
true ->
flush_buffer(NewState);
false ->
%% Start flush timer if not already running
NewState2 = maybe_start_flush_timer(NewState),
writer_loop(NewState2)
end;
flush ->
flush_buffer(State);
{close, From} ->
%% Flush remaining data and close
case Buffer of
[] -> ok;
_ -> file:write(Fd, Buffer)
end,
file:close(Fd),
From ! {closed, self()},
ok
after ?QLOG_FLUSH_INTERVAL_MS ->
%% Periodic flush
flush_buffer(State)
end.
%% @private Flush the buffer to disk.
flush_buffer(#writer_state{buffer = [], flush_timer = TimerRef} = State) ->
cancel_timer(TimerRef),
writer_loop(State#writer_state{flush_timer = undefined});
flush_buffer(#writer_state{fd = Fd, buffer = Buffer, flush_timer = TimerRef} = State) ->
cancel_timer(TimerRef),
file:write(Fd, Buffer),
writer_loop(State#writer_state{buffer = [], buffer_size = 0, flush_timer = undefined}).
%% @private Start flush timer if not already running.
maybe_start_flush_timer(#writer_state{flush_timer = undefined} = State) ->
TimerRef = erlang:send_after(?QLOG_FLUSH_INTERVAL_MS, self(), flush),
State#writer_state{flush_timer = TimerRef};
maybe_start_flush_timer(State) ->
State.
%% @private Cancel a timer if set.
cancel_timer(undefined) -> ok;
cancel_timer(Ref) -> erlang:cancel_timer(Ref).