Skip to main content

src/barrel_trace.erl

%%%-------------------------------------------------------------------
%%% @doc Distributed tracing helpers for barrel_docdb
%%%
%%% Provides OpenTelemetry-compatible tracing with semantic conventions
%%% for database operations following the OpenTelemetry Database Spans
%%% specification.
%%%
%%% Semantic conventions:
%%% - db.system.name: "barrel"
%%% - db.namespace: database name
%%% - db.collection.name: collection/table name
%%% - db.operation.name: operation type (get, put, delete, query)
%%% - db.query.text: query text (sanitized)
%%% - db.response.returned_rows: number of rows returned
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(barrel_trace).

%% Span creation with DB semantics
-export([
    with_db_span/3,
    with_db_span/4,
    with_http_span/3,
    with_http_span/4
]).

%% Attribute helpers
-export([
    db_attributes/1,
    db_attributes/2,
    http_attributes/2,
    http_attributes/3
]).

%% Context propagation for HTTP
-export([
    inject_headers/1,
    extract_headers/1,
    attach_context/1,
    with_extracted_context/2
]).

%% Recording check for expensive operations
-export([
    is_recording/0
]).

%% Error recording
-export([
    record_error/1,
    record_error/2
]).

%% Span access
-export([
    set_attribute/2,
    set_attributes/1,
    add_event/1,
    add_event/2
]).

%% Constants
-define(DB_SYSTEM, <<"barrel">>).

%%====================================================================
%% Span Creation
%%====================================================================

%% @doc Execute a function within a database span.
%% Creates a span with OpenTelemetry database semantic conventions.
%%
%% Example:
%% ```
%% barrel_trace:with_db_span(put, Db, fun() ->
%%     do_put(Db, DocId, Doc)
%% end).
%% '''
-spec with_db_span(atom(), binary() | undefined, fun(() -> Result)) -> Result
    when Result :: term().
with_db_span(Op, Db, Fun) ->
    with_db_span(Op, Db, #{}, Fun).

%% @doc Execute a function within a database span with extra attributes.
-spec with_db_span(atom(), binary() | undefined, map(), fun(() -> Result)) -> Result
    when Result :: term().
with_db_span(Op, Db, ExtraAttrs, Fun) ->
    SpanName = span_name(Op, Db),
    BaseAttrs = db_attributes(Db, Op),
    Attrs = maps:merge(BaseAttrs, ExtraAttrs),
    Opts = #{
        kind => internal,
        attributes => Attrs
    },
    instrument_tracer:with_span(SpanName, Opts, Fun).

%% @doc Execute a function within an HTTP server span.
%% Creates a span for incoming HTTP requests.
-spec with_http_span(binary(), binary(), fun(() -> Result)) -> Result
    when Result :: term().
with_http_span(Method, Path, Fun) ->
    with_http_span(Method, Path, #{}, Fun).

%% @doc Execute a function within an HTTP server span with extra attributes.
-spec with_http_span(binary(), binary(), map(), fun(() -> Result)) -> Result
    when Result :: term().
with_http_span(Method, Path, ExtraAttrs, Fun) ->
    SpanName = http_span_name(Method, Path),
    BaseAttrs = http_attributes(Method, Path),
    Attrs = maps:merge(BaseAttrs, ExtraAttrs),
    Opts = #{
        kind => server,
        attributes => Attrs
    },
    instrument_tracer:with_span(SpanName, Opts, Fun).

%%====================================================================
%% Attribute Helpers
%%====================================================================

%% @doc Generate database attributes for a span.
-spec db_attributes(binary() | undefined) -> map().
db_attributes(Db) ->
    db_attributes(Db, undefined).

%% @doc Generate database attributes with operation.
-spec db_attributes(binary() | undefined, atom() | undefined) -> map().
db_attributes(Db, Op) ->
    Attrs0 = #{<<"db.system.name">> => ?DB_SYSTEM},
    Attrs1 = case Db of
        undefined -> Attrs0;
        _ -> Attrs0#{<<"db.namespace">> => Db}
    end,
    case Op of
        undefined -> Attrs1;
        _ -> Attrs1#{<<"db.operation.name">> => atom_to_binary(Op, utf8)}
    end.

%% @doc Generate HTTP attributes for a span.
-spec http_attributes(binary(), binary()) -> map().
http_attributes(Method, Path) ->
    #{
        <<"http.request.method">> => Method,
        <<"url.path">> => Path
    }.

%% @doc Generate HTTP attributes with status code.
-spec http_attributes(binary(), binary(), integer()) -> map().
http_attributes(Method, Path, StatusCode) ->
    Attrs = http_attributes(Method, Path),
    Attrs#{<<"http.response.status_code">> => StatusCode}.

%%====================================================================
%% Context Propagation
%%====================================================================

%% @doc Inject trace context into HTTP headers.
%% Returns a list of {HeaderName, HeaderValue} tuples to add to outgoing requests.
-spec inject_headers(list()) -> list().
inject_headers(Headers) when is_list(Headers) ->
    Ctx = instrument_context:current(),
    TraceHeaders = instrument_propagation:inject_headers(Ctx),
    TraceHeaders ++ Headers.

%% @doc Extract trace context from HTTP headers.
%% Returns the extracted context (attached to current process).
%% @deprecated Use with_extracted_context/2 for proper context cleanup.
-spec extract_headers(list()) -> ok.
extract_headers(Headers) when is_list(Headers) ->
    Ctx = instrument_propagation:extract_headers(Headers),
    instrument_context:attach(Ctx),
    ok.

%% @doc Attach an extracted context to the current process.
%% @deprecated Use with_extracted_context/2 for proper context cleanup.
-spec attach_context(map()) -> ok.
attach_context(Ctx) when is_map(Ctx) ->
    instrument_context:attach(Ctx),
    ok.

%% @doc Execute function with trace context extracted from HTTP headers.
%% This properly scopes the context and ensures cleanup via detach.
-spec with_extracted_context(list(), fun(() -> Result)) -> Result
    when Result :: term().
with_extracted_context(Headers, Fun) when is_list(Headers), is_function(Fun, 0) ->
    Ctx = instrument_propagation:extract_headers(Headers),
    Token = instrument_context:attach(Ctx),
    try
        Fun()
    after
        instrument_context:detach(Token)
    end.

%% @doc Check if the current span is recording.
%% Use this to guard expensive attribute computation.
-spec is_recording() -> boolean().
is_recording() ->
    instrument_tracer:is_recording().

%%====================================================================
%% Error Recording
%%====================================================================

%% @doc Record an error on the current span.
-spec record_error(term()) -> ok.
record_error(Error) ->
    record_error(Error, #{}).

%% @doc Record an error with additional attributes.
-spec record_error(term(), map()) -> ok.
record_error(Error, Attrs) ->
    instrument_tracer:record_exception(Error, Attrs),
    ErrorMsg = format_error(Error),
    instrument_tracer:set_status(error, ErrorMsg),
    ok.

%%====================================================================
%% Span Modification
%%====================================================================

%% @doc Set a single attribute on the current span.
-spec set_attribute(term(), term()) -> ok.
set_attribute(Key, Value) ->
    instrument_tracer:set_attribute(Key, Value).

%% @doc Set multiple attributes on the current span.
-spec set_attributes(map()) -> ok.
set_attributes(Attrs) when is_map(Attrs) ->
    instrument_tracer:set_attributes(Attrs).

%% @doc Add an event to the current span.
-spec add_event(binary()) -> ok.
add_event(Name) ->
    instrument_tracer:add_event(Name).

%% @doc Add an event with attributes to the current span.
-spec add_event(binary(), map()) -> ok.
add_event(Name, Attrs) ->
    instrument_tracer:add_event(Name, Attrs).

%%====================================================================
%% Internal Functions
%%====================================================================

%% @doc Generate span name following OpenTelemetry conventions.
%% Format: {db.operation.name} {db.namespace} or {db.system.name}
span_name(Op, undefined) ->
    OpBin = atom_to_binary(Op, utf8),
    <<"barrel ", OpBin/binary>>;
span_name(Op, Db) ->
    OpBin = atom_to_binary(Op, utf8),
    <<OpBin/binary, " ", Db/binary>>.

%% @doc Generate HTTP span name.
%% Format: {http.method} {url.path}
http_span_name(Method, Path) ->
    <<Method/binary, " ", Path/binary>>.

%% @doc Format an error for the span status message.
format_error(Error) when is_binary(Error) ->
    Error;
format_error(Error) when is_atom(Error) ->
    atom_to_binary(Error, utf8);
format_error({error, Reason}) ->
    format_error(Reason);
format_error(Error) ->
    iolist_to_binary(io_lib:format("~p", [Error])).