Skip to main content

src/nquic_qlog_file.erl

-module(nquic_qlog_file).

-moduledoc """
File backend for `nquic_qlog`. Writes draft-ietf-quic-qlog NDJSON
to a per-connection file.

First line is a JSON object describing the trace
(`qlog_format = "JSON-SEQ"`, schema version, connection ID, vantage
point). Each subsequent line is a single JSON event.

Path conventions: callers either pass an absolute path (file is
truncated and reopened) or a directory (a unique file is created
named after the connection's hex-encoded DCID).

This module deliberately uses raw, delayed-write `file` ports for
the steady-state event stream. Each event is one `io_lib` call and
one `file:write` (delayed by 256 KiB or 100 ms). Closing the file
flushes the buffer. The format is line-oriented so an interrupted
trace still loads in qvis.
""".

-behaviour(nquic_qlog).

-export([event/2, init/2, terminate/2]).

-define(SCHEMA_VERSION, <<"draft-12">>).

-record(state, {
    fd :: file:io_device(),
    path :: file:filename_all(),
    cid_hex :: binary(),
    start_us :: integer()
}).

%%%-----------------------------------------------------------------------------
%% NQUIC_QLOG CALLBACKS
%%%-----------------------------------------------------------------------------
-spec event(#state{}, {nquic_qlog:event_name(), nquic_qlog:event_data()}) -> {ok, #state{}}.
event(#state{fd = Fd, start_us = Start} = State, {Name, Data}) ->
    Now = erlang:system_time(microsecond),
    Time = Now - Start,
    _ = file:write(Fd, encode_event(Time, Name, Data)),
    {ok, State}.

-spec init(nquic:connection_id(), map()) -> {ok, #state{}} | {error, term()}.
init(CID, #{path := Path0}) ->
    CidHex = hex(CID),
    Path = resolve_path(Path0, CidHex),
    case file:open(Path, [write, raw, binary, delayed_write]) of
        {ok, Fd} ->
            StartUs = erlang:system_time(microsecond),
            _ = file:write(Fd, header(CidHex, StartUs)),
            {ok, #state{fd = Fd, path = Path, cid_hex = CidHex, start_us = StartUs}};
        {error, _} = Err ->
            Err
    end.

-spec terminate(#state{}, term()) -> ok.
terminate(#state{fd = Fd}, _Reason) ->
    _ = file:close(Fd),
    ok.

%%%-----------------------------------------------------------------------------
%% INTERNAL
%%%-----------------------------------------------------------------------------
-spec encode_event(integer(), atom(), map()) -> iolist().
encode_event(Time, Name, Data) ->
    [
        <<"{\"time\":">>,
        integer_to_binary(Time),
        <<",\"name\":\"">>,
        atom_to_binary(Name, utf8),
        <<"\",\"data\":">>,
        render_map(Data),
        <<"}\n">>
    ].

-spec escape(binary()) -> binary().
escape(B) ->
    binary:replace(
        binary:replace(B, <<$\\>>, <<"\\\\">>, [global]),
        <<$">>,
        <<"\\\"">>,
        [global]
    ).

-spec header(binary(), integer()) -> iolist().
header(CidHex, StartUs) ->
    StartBin = integer_to_binary(StartUs),
    [
        <<"{\"qlog_format\":\"JSON-SEQ\",\"qlog_version\":\"">>,
        ?SCHEMA_VERSION,
        <<"\",\"trace\":{\"vantage_point\":{\"name\":\"nquic\"},">>,
        <<"\"common_fields\":{\"reference_time\":">>,
        StartBin,
        <<",\"time_units\":\"us\",\"ODCID\":\"">>,
        CidHex,
        <<"\"}}}\n">>
    ].

-spec hex(binary()) -> binary().
hex(B) ->
    binary:encode_hex(B, lowercase).

-spec render_map(map()) -> iolist() | binary().
render_map(M) when map_size(M) =:= 0 ->
    <<"{}">>;
render_map(M) ->
    Pairs = [render_pair(K, V) || {K, V} <- maps:to_list(M)],
    [<<"{">>, lists:join(<<",">>, Pairs), <<"}">>].

-spec render_pair(atom() | binary(), term()) -> iolist().
render_pair(K, V) when is_atom(K) ->
    render_pair(atom_to_binary(K, utf8), V);
render_pair(K, V) ->
    [<<$">>, K, <<"\":">>, render_value(V)].

-spec render_value(term()) -> iolist() | binary().
render_value(V) when is_atom(V) ->
    [<<$">>, atom_to_binary(V, utf8), <<$">>];
render_value(V) when is_integer(V) ->
    integer_to_binary(V);
render_value(V) when is_binary(V) ->
    [<<$">>, escape(V), <<$">>].

-spec resolve_path(file:filename_all(), binary()) -> file:filename_all() | atom().
resolve_path(Path, CidHex) ->
    case filelib:is_dir(Path) of
        true -> filename:join(Path, <<CidHex/binary, ".qlog">>);
        false -> Path
    end.