Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 37 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

OpenTelemetry instrumentation for the [Nova](https://github.com/novaframework/nova) web framework.

Automatic HTTP request tracing and metrics with Prometheus export support.
Automatic HTTP request tracing and metrics following the [OTel HTTP semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/).

## Installation

Expand All @@ -28,24 +28,47 @@ Or with Prometheus export:
opentelemetry_nova:setup(#{prometheus => #{port => 9464}}).
```

## Metrics
## Span Attributes

Server spans are created for every HTTP request with the following attributes:

| Attribute | Source |
|-----------|--------|
| `http.request.method` | Request method |
| `http.response.status_code` | Response status code |
| `http.route` | Matched route template (e.g. `/users/:id`) |
| `url.path` | Request path |
| `url.scheme` | `http` or `https` |
| `server.address` | Host header |
| `server.port` | Server port |
| `client.address` | `X-Forwarded-For` / `Forwarded` header, or peer IP |
| `network.peer.address` | Peer IP address |
| `network.peer.port` | Peer port |
| `network.protocol.version` | HTTP version (`1.0`, `1.1`, `2`, `3`) |
| `user_agent.original` | User-Agent header |
| `error.type` | Status code as string on 5xx errors |
| `nova.app` | Nova application name |
| `nova.controller` | Controller module |
| `nova.action` | Action function |

Span names follow the `{METHOD} {route}` convention (e.g. `GET /users/:id`).

The following metrics are collected automatically:
## Metrics

| Metric | Type | Description |
|--------|------|-------------|
| `http.server.request.duration` | Histogram (seconds) | Request duration |
| `http.server.active_requests` | UpDown Counter | Currently active requests |
| `http.server.request.body.size` | Histogram (bytes) | Request body size |
| `http.server.response.body.size` | Histogram (bytes) | Response body size |
| Metric | Type | Attributes |
|--------|------|------------|
| `http.server.request.duration` | Histogram (seconds) | method, scheme, host, port, status, route, error.type |
| `http.server.active_requests` | UpDown Counter | method, scheme, host, port |
| `http.server.request.body.size` | Histogram (bytes) | method, scheme, host, port, status, route, error.type |
| `http.server.response.body.size` | Histogram (bytes) | method, scheme, host, port, status, route, error.type |

## Components

- **`otel_nova_stream_h`** - Cowboy stream handler for HTTP tracing and metrics
- **`otel_nova_plugin`** - Nova plugin for span enrichment with controller/action attributes
- **`otel_nova_prom_exporter`** - Prometheus exporter with delta-to-cumulative conversion
- **`otel_nova_prom_server`** - HTTP server for Prometheus scraping
- **`otel_nova_stream_h`** Cowboy stream handler for HTTP tracing and metrics
- **`otel_nova_plugin`** Nova plugin for span enrichment with route and controller info
- **`otel_nova_prom_exporter`** Prometheus exporter with delta-to-cumulative conversion
- **`otel_nova_prom_server`** HTTP server for Prometheus scraping

## License

Apache 2.0
MIT
19 changes: 18 additions & 1 deletion rebar.config
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{erl_opts, [debug_info]}.
{erl_opts, [debug_info, warnings_as_errors]}.

{project_plugins, [rebar3_ex_doc]}.

Expand All @@ -16,6 +16,23 @@
{cowboy, "~> 2.13"}
]}.

{xref_checks, [undefined_function_calls, undefined_functions,
locals_not_used, deprecated_function_calls,
deprecated_functions]}.

{dialyzer, [
{plt_extra_apps, [cowboy, cowlib, ranch, opentelemetry_experimental,
opentelemetry_api, opentelemetry_api_experimental]},
{plt_prefix, "rebar3"}
]}.

%% Patch upstream bug: opentelemetry_experimental's otel_metrics.hrl references
%% eqwalizer:dynamic/0 which is a Meta pseudo-type, not a real Erlang module.
%% In OTP 28+ dynamic() is a built-in type, so the eqwalizer: prefix is wrong.
{pre_hooks, [
{compile, "sed -i 's/eqwalizer:dynamic()/dynamic()/g' _build/default/lib/opentelemetry_experimental/include/otel_metrics.hrl 2>/dev/null || true"}
]}.

{ct_compile_opts, [{i, "test/support"}]}.
{ct_first_files, ["test/support/fake_controller.erl", "test/support/test_metric_exporter.erl"]}.

Expand Down
25 changes: 21 additions & 4 deletions rebar.lock
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
{"1.2.0",
[{<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.5.0">>},0},
[{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.14.2">>},0},
{<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.0">>},1},
{<<"opentelemetry">>,{pkg,<<"opentelemetry">>,<<"1.7.0">>},1},
{<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.5.0">>},0},
{<<"opentelemetry_api_experimental">>,
{pkg,<<"opentelemetry_api_experimental">>,<<"0.5.1">>},
0}]}.
0},
{<<"opentelemetry_experimental">>,
{pkg,<<"opentelemetry_experimental">>,<<"0.5.1">>},
0},
{<<"ranch">>,{pkg,<<"ranch">>,<<"2.2.0">>},1}]}.
[
{pkg_hash,[
{<<"cowboy">>, <<"4008BE1DF6ADE45E4F2A4E9E2D22B36D0B5ABA4E20B0A0D7049E28D124E34847">>},
{<<"cowlib">>, <<"54592074EBBBB92EE4746C8A8846E5605052F29309D3A873468D76CDF932076F">>},
{<<"opentelemetry">>, <<"20D0F12D3D1C398D3670FD44FD1A7C495DD748AB3E5B692A7906662E2FB1A38A">>},
{<<"opentelemetry_api">>, <<"1A676F3E3340CAB81C763E939A42E11A70C22863F645AA06AAFEFC689B5550CF">>},
{<<"opentelemetry_api_experimental">>, <<"1B5AFACFCBD0834390336C845BC8AE08C8CF0D69BBED72EE53D178798B93E074">>}]},
{<<"opentelemetry_api_experimental">>, <<"1B5AFACFCBD0834390336C845BC8AE08C8CF0D69BBED72EE53D178798B93E074">>},
{<<"opentelemetry_experimental">>, <<"27F60EA61B9E42F919C219D52DD17881057921150130B7EB9F15BC902F34F11D">>},
{<<"ranch">>, <<"25528F82BC8D7C6152C57666CA99EC716510FE0925CB188172F41CE93117B1B0">>}]},
{pkg_hash_ext,[
{<<"cowboy">>, <<"569081DA046E7B41B5DF36AA359BE71A0C8874E5B9CFF6F747073FC57BAF1AB9">>},
{<<"cowlib">>, <<"7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51">>},
{<<"opentelemetry">>, <<"A9173B058C4549BF824CBC2F1D2FA2ADC5CDEDC22AA3F0F826951187BBD53131">>},
{<<"opentelemetry_api">>, <<"F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA">>},
{<<"opentelemetry_api_experimental">>, <<"10297057EADA47267D4F832011BECEF07D25690E6BF91FEBCCFC4E740DBA1A6F">>}]}
{<<"opentelemetry_api_experimental">>, <<"10297057EADA47267D4F832011BECEF07D25690E6BF91FEBCCFC4E740DBA1A6F">>},
{<<"opentelemetry_experimental">>, <<"A1AD941294F1D3623C33E151FAA35613849A10CB468DBFC9AD16367F7DDF80BF">>},
{<<"ranch">>, <<"FA0B99A1780C80218A4197A59EA8D3BDAE32FBFF7E88527D7D8A4787EFF4F8E7">>}]}
].
31 changes: 23 additions & 8 deletions src/otel_nova_plugin.erl
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
-module(otel_nova_plugin).
-behaviour(nova_plugin).
%% Implements nova_plugin behaviour callbacks.
%% The -behaviour directive is omitted because nova is only a test dependency.

-export([pre_request/4, post_request/4, plugin_info/0]).

-include_lib("opentelemetry_api/include/otel_tracer.hrl").

pre_request(Req, Env, _Opts, State) ->
Method = cowboy_req:method(Req),
Route = reconstruct_route(Req),

?set_attribute('http.route', Route),
erlang:put(otel_nova_http_route, Route),
?update_name(<<Method/binary, " ", Route/binary>>),

case extract_callback_info(Env) of
{App, Controller, Action} ->
ControllerBin = atom_to_binary(Controller, utf8),
ActionBin = atom_to_binary(Action, utf8),
?set_attributes(#{
'nova.app' => atom_to_binary(App, utf8),
'nova.controller' => ControllerBin,
'nova.action' => ActionBin
}),
SpanName = <<Method/binary, " ", ControllerBin/binary, ":", ActionBin/binary>>,
?update_name(SpanName);
'nova.controller' => atom_to_binary(Controller, utf8),
'nova.action' => atom_to_binary(Action, utf8)
});
undefined ->
ok
end,
Expand All @@ -38,6 +40,19 @@ plugin_info() ->

%% Internal

reconstruct_route(#{bindings := Bindings, path := Path}) when map_size(Bindings) > 0 ->
Segments = binary:split(Path, <<"/">>, [global]),
InverseBindings = maps:fold(fun(K, V, Acc) ->
Acc#{V => K}
end, #{}, Bindings),
Replaced = [case maps:find(Seg, InverseBindings) of
{ok, Key} -> <<":", Key/binary>>;
error -> Seg
end || Seg <- Segments],
iolist_to_binary(lists:join(<<"/">>, Replaced));
reconstruct_route(#{path := Path}) ->
Path.

extract_callback_info(#{app := App, callback := Fun}) when is_function(Fun) ->
case {erlang:fun_info(Fun, module), erlang:fun_info(Fun, name)} of
{{module, Mod}, {name, Name}} ->
Expand Down
74 changes: 61 additions & 13 deletions src/otel_nova_stream_h.erl
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
-include_lib("opentelemetry_api/include/opentelemetry.hrl").
-include_lib("opentelemetry_api_experimental/include/otel_meter.hrl").

%% OTel macros expand to pattern matches that dialyzer flags as unreachable.
-dialyzer({no_match, [init/3, terminate/3, record_if_positive/3]}).

-record(state, {
next :: any(),
span_ctx :: opentelemetry:span_ctx() | undefined,
Expand All @@ -18,6 +21,8 @@
metric_attrs :: map()
}).

-define(OTEL_NOVA_HTTP_ROUTE, otel_nova_http_route).

-spec init(cowboy_stream:streamid(), cowboy_req:req(), cowboy:opts()) ->
{cowboy_stream:commands(), #state{}}.
init(StreamID, Req, Opts) ->
Expand Down Expand Up @@ -84,7 +89,8 @@ terminate(StreamID, Reason, #state{next = Next, span_ctx = SpanCtx, otel_ctx = O
ok;
Code ->
?set_attribute('http.response.status_code', Code),
maybe_set_error_status(Code)
maybe_set_error_status(Code),
maybe_set_error_type(Code)
end,

?end_span(),
Expand All @@ -93,9 +99,19 @@ terminate(StreamID, Reason, #state{next = Next, span_ctx = SpanCtx, otel_ctx = O
%% Record metrics
EndTime = erlang:monotonic_time(),
Duration = erlang:convert_time_unit(EndTime - ReqStart, native, millisecond) / 1000,
RouteAttrs = case erlang:erase(?OTEL_NOVA_HTTP_ROUTE) of
undefined -> #{};
Route -> #{'http.route' => Route}
end,
FinalAttrs0 = maps:merge(MetricAttrs, RouteAttrs),
FinalAttrs = case StatusCode of
undefined -> MetricAttrs;
SC -> MetricAttrs#{'http.response.status_code' => SC}
undefined -> FinalAttrs0;
SC ->
ErrorAttrs = case SC >= 500 of
true -> #{'error.type' => integer_to_binary(SC)};
false -> #{}
end,
maps:merge(FinalAttrs0#{'http.response.status_code' => SC}, ErrorAttrs)
end,
?histogram_record('http.server.request.duration', Duration, FinalAttrs),
record_if_positive('http.server.request.body.size', ReqBodyLen, FinalAttrs),
Expand All @@ -119,21 +135,25 @@ request_attributes(Req) ->
Host = cowboy_req:host(Req),
Port = cowboy_req:port(Req),
{PeerAddr, PeerPort} = cowboy_req:peer(Req),
PeerBin = peer_to_binary(PeerAddr),

Attrs = #{
Attrs0 = #{
'http.request.method' => Method,
'url.path' => Path,
'url.scheme' => Scheme,
'server.address' => Host,
'server.port' => Port,
'network.peer.address' => peer_to_binary(PeerAddr),
'network.peer.port' => PeerPort
'network.peer.address' => PeerBin,
'network.peer.port' => PeerPort,
'network.protocol.version' => protocol_version(Req),
'client.address' => client_address(Req, PeerBin)
},

case cowboy_req:header(<<"user-agent">>, Req) of
undefined -> Attrs;
UA -> Attrs#{'user_agent.original' => UA}
end.
Attrs1 = case cowboy_req:header(<<"user-agent">>, Req) of
undefined -> Attrs0;
UA -> Attrs0#{'user_agent.original' => UA}
end,
Attrs1.

metric_attributes(Req) ->
#{
Expand All @@ -143,16 +163,44 @@ metric_attributes(Req) ->
'server.port' => cowboy_req:port(Req)
}.

peer_to_binary(Addr) when is_tuple(Addr) ->
list_to_binary(inet:ntoa(Addr));
peer_to_binary(Addr) ->
Addr.
list_to_binary(inet:ntoa(Addr)).

maybe_set_error_status(Code) when Code >= 500 ->
?set_status(?OTEL_STATUS_ERROR, <<"Server error">>);
maybe_set_error_status(_) ->
ok.

maybe_set_error_type(Code) when Code >= 500 ->
?set_attribute('error.type', integer_to_binary(Code));
maybe_set_error_type(_) ->
ok.

protocol_version(#{version := 'HTTP/1.0'}) -> <<"1.0">>;
protocol_version(#{version := 'HTTP/1.1'}) -> <<"1.1">>;
protocol_version(#{version := 'HTTP/2'}) -> <<"2">>;
protocol_version(#{version := 'HTTP/3'}) -> <<"3">>;
protocol_version(_) -> <<"1.1">>.

client_address(Req, PeerBin) ->
case cowboy_req:header(<<"x-forwarded-for">>, Req) of
undefined ->
case cowboy_req:header(<<"forwarded">>, Req) of
undefined -> PeerBin;
Forwarded -> parse_forwarded_for(Forwarded, PeerBin)
end;
XFF ->
[First | _] = binary:split(XFF, <<",">>),
string:trim(First)
end.

parse_forwarded_for(Forwarded, Default) ->
case re:run(Forwarded, <<"for=\"?([^;,\"]+)\"?">>,
[{capture, [1], binary}, caseless]) of
{match, [Addr]} -> string:trim(Addr);
nomatch -> Default
end.

record_if_positive(Name, Value, Attrs) when Value > 0 ->
?histogram_record(Name, Value, Attrs);
record_if_positive(_, _, _) ->
Expand Down
Loading
Loading