Skip to content

Commit 43c9be7

Browse files
Taureclaude
andcommitted
feat: add missing OTel HTTP semantic convention attributes and metrics
Add http.route, error.type, network.protocol.version, and client.address span attributes. Include http.response.status_code, http.route, and error.type in metric attributes. Fix span naming to use standard {METHOD} {route} format. Add proper xref and dialyzer configuration with warnings_as_errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eeda23f commit 43c9be7

File tree

5 files changed

+285
-29
lines changed

5 files changed

+285
-29
lines changed

rebar.config

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{erl_opts, [debug_info]}.
1+
{erl_opts, [debug_info, warnings_as_errors]}.
22

33
{project_plugins, [rebar3_ex_doc]}.
44

@@ -16,6 +16,23 @@
1616
{cowboy, "~> 2.13"}
1717
]}.
1818

19+
{xref_checks, [undefined_function_calls, undefined_functions,
20+
locals_not_used, deprecated_function_calls,
21+
deprecated_functions]}.
22+
23+
{dialyzer, [
24+
{plt_extra_apps, [cowboy, cowlib, ranch, opentelemetry_experimental,
25+
opentelemetry_api, opentelemetry_api_experimental]},
26+
{plt_prefix, "rebar3"}
27+
]}.
28+
29+
%% Patch upstream bug: opentelemetry_experimental's otel_metrics.hrl references
30+
%% eqwalizer:dynamic/0 which is a Meta pseudo-type, not a real Erlang module.
31+
%% In OTP 28+ dynamic() is a built-in type, so the eqwalizer: prefix is wrong.
32+
{pre_hooks, [
33+
{compile, "sed -i 's/eqwalizer:dynamic()/dynamic()/g' _build/default/lib/opentelemetry_experimental/include/otel_metrics.hrl 2>/dev/null || true"}
34+
]}.
35+
1936
{ct_compile_opts, [{i, "test/support"}]}.
2037
{ct_first_files, ["test/support/fake_controller.erl", "test/support/test_metric_exporter.erl"]}.
2138

rebar.lock

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
11
{"1.2.0",
2-
[{<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.5.0">>},0},
2+
[{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.14.2">>},0},
3+
{<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.0">>},1},
4+
{<<"opentelemetry">>,{pkg,<<"opentelemetry">>,<<"1.7.0">>},1},
5+
{<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.5.0">>},0},
36
{<<"opentelemetry_api_experimental">>,
47
{pkg,<<"opentelemetry_api_experimental">>,<<"0.5.1">>},
5-
0}]}.
8+
0},
9+
{<<"opentelemetry_experimental">>,
10+
{pkg,<<"opentelemetry_experimental">>,<<"0.5.1">>},
11+
0},
12+
{<<"ranch">>,{pkg,<<"ranch">>,<<"2.2.0">>},1}]}.
613
[
714
{pkg_hash,[
15+
{<<"cowboy">>, <<"4008BE1DF6ADE45E4F2A4E9E2D22B36D0B5ABA4E20B0A0D7049E28D124E34847">>},
16+
{<<"cowlib">>, <<"54592074EBBBB92EE4746C8A8846E5605052F29309D3A873468D76CDF932076F">>},
17+
{<<"opentelemetry">>, <<"20D0F12D3D1C398D3670FD44FD1A7C495DD748AB3E5B692A7906662E2FB1A38A">>},
818
{<<"opentelemetry_api">>, <<"1A676F3E3340CAB81C763E939A42E11A70C22863F645AA06AAFEFC689B5550CF">>},
9-
{<<"opentelemetry_api_experimental">>, <<"1B5AFACFCBD0834390336C845BC8AE08C8CF0D69BBED72EE53D178798B93E074">>}]},
19+
{<<"opentelemetry_api_experimental">>, <<"1B5AFACFCBD0834390336C845BC8AE08C8CF0D69BBED72EE53D178798B93E074">>},
20+
{<<"opentelemetry_experimental">>, <<"27F60EA61B9E42F919C219D52DD17881057921150130B7EB9F15BC902F34F11D">>},
21+
{<<"ranch">>, <<"25528F82BC8D7C6152C57666CA99EC716510FE0925CB188172F41CE93117B1B0">>}]},
1022
{pkg_hash_ext,[
23+
{<<"cowboy">>, <<"569081DA046E7B41B5DF36AA359BE71A0C8874E5B9CFF6F747073FC57BAF1AB9">>},
24+
{<<"cowlib">>, <<"7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51">>},
25+
{<<"opentelemetry">>, <<"A9173B058C4549BF824CBC2F1D2FA2ADC5CDEDC22AA3F0F826951187BBD53131">>},
1126
{<<"opentelemetry_api">>, <<"F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA">>},
12-
{<<"opentelemetry_api_experimental">>, <<"10297057EADA47267D4F832011BECEF07D25690E6BF91FEBCCFC4E740DBA1A6F">>}]}
27+
{<<"opentelemetry_api_experimental">>, <<"10297057EADA47267D4F832011BECEF07D25690E6BF91FEBCCFC4E740DBA1A6F">>},
28+
{<<"opentelemetry_experimental">>, <<"A1AD941294F1D3623C33E151FAA35613849A10CB468DBFC9AD16367F7DDF80BF">>},
29+
{<<"ranch">>, <<"FA0B99A1780C80218A4197A59EA8D3BDAE32FBFF7E88527D7D8A4787EFF4F8E7">>}]}
1330
].

src/otel_nova_plugin.erl

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
11
-module(otel_nova_plugin).
2-
-behaviour(nova_plugin).
2+
%% Implements nova_plugin behaviour callbacks.
3+
%% The -behaviour directive is omitted because nova is only a test dependency.
34

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

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

89
pre_request(Req, Env, _Opts, State) ->
910
Method = cowboy_req:method(Req),
11+
Route = reconstruct_route(Req),
12+
13+
?set_attribute('http.route', Route),
14+
erlang:put(otel_nova_http_route, Route),
15+
?update_name(<<Method/binary, " ", Route/binary>>),
1016

1117
case extract_callback_info(Env) of
1218
{App, Controller, Action} ->
13-
ControllerBin = atom_to_binary(Controller, utf8),
14-
ActionBin = atom_to_binary(Action, utf8),
1519
?set_attributes(#{
1620
'nova.app' => atom_to_binary(App, utf8),
17-
'nova.controller' => ControllerBin,
18-
'nova.action' => ActionBin
19-
}),
20-
SpanName = <<Method/binary, " ", ControllerBin/binary, ":", ActionBin/binary>>,
21-
?update_name(SpanName);
21+
'nova.controller' => atom_to_binary(Controller, utf8),
22+
'nova.action' => atom_to_binary(Action, utf8)
23+
});
2224
undefined ->
2325
ok
2426
end,
@@ -38,6 +40,19 @@ plugin_info() ->
3840

3941
%% Internal
4042

43+
reconstruct_route(#{bindings := Bindings, path := Path}) when map_size(Bindings) > 0 ->
44+
Segments = binary:split(Path, <<"/">>, [global]),
45+
InverseBindings = maps:fold(fun(K, V, Acc) ->
46+
Acc#{V => K}
47+
end, #{}, Bindings),
48+
Replaced = [case maps:find(Seg, InverseBindings) of
49+
{ok, Key} -> <<":", Key/binary>>;
50+
error -> Seg
51+
end || Seg <- Segments],
52+
iolist_to_binary(lists:join(<<"/">>, Replaced));
53+
reconstruct_route(#{path := Path}) ->
54+
Path.
55+
4156
extract_callback_info(#{app := App, callback := Fun}) when is_function(Fun) ->
4257
case {erlang:fun_info(Fun, module), erlang:fun_info(Fun, name)} of
4358
{{module, Mod}, {name, Name}} ->

src/otel_nova_stream_h.erl

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
-include_lib("opentelemetry_api/include/opentelemetry.hrl").
88
-include_lib("opentelemetry_api_experimental/include/otel_meter.hrl").
99

10+
%% OTel macros expand to pattern matches that dialyzer flags as unreachable.
11+
-dialyzer({no_match, [init/3, terminate/3, record_if_positive/3]}).
12+
1013
-record(state, {
1114
next :: any(),
1215
span_ctx :: opentelemetry:span_ctx() | undefined,
@@ -18,6 +21,8 @@
1821
metric_attrs :: map()
1922
}).
2023

24+
-define(OTEL_NOVA_HTTP_ROUTE, otel_nova_http_route).
25+
2126
-spec init(cowboy_stream:streamid(), cowboy_req:req(), cowboy:opts()) ->
2227
{cowboy_stream:commands(), #state{}}.
2328
init(StreamID, Req, Opts) ->
@@ -84,7 +89,8 @@ terminate(StreamID, Reason, #state{next = Next, span_ctx = SpanCtx, otel_ctx = O
8489
ok;
8590
Code ->
8691
?set_attribute('http.response.status_code', Code),
87-
maybe_set_error_status(Code)
92+
maybe_set_error_status(Code),
93+
maybe_set_error_type(Code)
8894
end,
8995

9096
?end_span(),
@@ -93,9 +99,19 @@ terminate(StreamID, Reason, #state{next = Next, span_ctx = SpanCtx, otel_ctx = O
9399
%% Record metrics
94100
EndTime = erlang:monotonic_time(),
95101
Duration = erlang:convert_time_unit(EndTime - ReqStart, native, millisecond) / 1000,
102+
RouteAttrs = case erlang:erase(?OTEL_NOVA_HTTP_ROUTE) of
103+
undefined -> #{};
104+
Route -> #{'http.route' => Route}
105+
end,
106+
FinalAttrs0 = maps:merge(MetricAttrs, RouteAttrs),
96107
FinalAttrs = case StatusCode of
97-
undefined -> MetricAttrs;
98-
SC -> MetricAttrs#{'http.response.status_code' => SC}
108+
undefined -> FinalAttrs0;
109+
SC ->
110+
ErrorAttrs = case SC >= 500 of
111+
true -> #{'error.type' => integer_to_binary(SC)};
112+
false -> #{}
113+
end,
114+
maps:merge(FinalAttrs0#{'http.response.status_code' => SC}, ErrorAttrs)
99115
end,
100116
?histogram_record('http.server.request.duration', Duration, FinalAttrs),
101117
record_if_positive('http.server.request.body.size', ReqBodyLen, FinalAttrs),
@@ -119,21 +135,25 @@ request_attributes(Req) ->
119135
Host = cowboy_req:host(Req),
120136
Port = cowboy_req:port(Req),
121137
{PeerAddr, PeerPort} = cowboy_req:peer(Req),
138+
PeerBin = peer_to_binary(PeerAddr),
122139

123-
Attrs = #{
140+
Attrs0 = #{
124141
'http.request.method' => Method,
125142
'url.path' => Path,
126143
'url.scheme' => Scheme,
127144
'server.address' => Host,
128145
'server.port' => Port,
129-
'network.peer.address' => peer_to_binary(PeerAddr),
130-
'network.peer.port' => PeerPort
146+
'network.peer.address' => PeerBin,
147+
'network.peer.port' => PeerPort,
148+
'network.protocol.version' => protocol_version(Req),
149+
'client.address' => client_address(Req, PeerBin)
131150
},
132151

133-
case cowboy_req:header(<<"user-agent">>, Req) of
134-
undefined -> Attrs;
135-
UA -> Attrs#{'user_agent.original' => UA}
136-
end.
152+
Attrs1 = case cowboy_req:header(<<"user-agent">>, Req) of
153+
undefined -> Attrs0;
154+
UA -> Attrs0#{'user_agent.original' => UA}
155+
end,
156+
Attrs1.
137157

138158
metric_attributes(Req) ->
139159
#{
@@ -143,16 +163,44 @@ metric_attributes(Req) ->
143163
'server.port' => cowboy_req:port(Req)
144164
}.
145165

146-
peer_to_binary(Addr) when is_tuple(Addr) ->
147-
list_to_binary(inet:ntoa(Addr));
148166
peer_to_binary(Addr) ->
149-
Addr.
167+
list_to_binary(inet:ntoa(Addr)).
150168

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

174+
maybe_set_error_type(Code) when Code >= 500 ->
175+
?set_attribute('error.type', integer_to_binary(Code));
176+
maybe_set_error_type(_) ->
177+
ok.
178+
179+
protocol_version(#{version := 'HTTP/1.0'}) -> <<"1.0">>;
180+
protocol_version(#{version := 'HTTP/1.1'}) -> <<"1.1">>;
181+
protocol_version(#{version := 'HTTP/2'}) -> <<"2">>;
182+
protocol_version(#{version := 'HTTP/3'}) -> <<"3">>;
183+
protocol_version(_) -> <<"1.1">>.
184+
185+
client_address(Req, PeerBin) ->
186+
case cowboy_req:header(<<"x-forwarded-for">>, Req) of
187+
undefined ->
188+
case cowboy_req:header(<<"forwarded">>, Req) of
189+
undefined -> PeerBin;
190+
Forwarded -> parse_forwarded_for(Forwarded, PeerBin)
191+
end;
192+
XFF ->
193+
[First | _] = binary:split(XFF, <<",">>),
194+
string:trim(First)
195+
end.
196+
197+
parse_forwarded_for(Forwarded, Default) ->
198+
case re:run(Forwarded, <<"for=\"?([^;,\"]+)\"?">>,
199+
[{capture, [1], binary}, caseless]) of
200+
{match, [Addr]} -> string:trim(Addr);
201+
nomatch -> Default
202+
end.
203+
156204
record_if_positive(Name, Value, Attrs) when Value > 0 ->
157205
?histogram_record(Name, Value, Attrs);
158206
record_if_positive(_, _, _) ->

0 commit comments

Comments
 (0)