Skip to content

Commit 2943f4a

Browse files
authored
feat: native metrics with Prometheus support (#772, #560) (#814)
Replace external metrics library with hackney-native metrics abstraction: - Add hackney_metrics_backend behaviour for pluggable backends - Add hackney_metrics_dummy backend (no-op, default) - Add hackney_metrics_prometheus backend (opt-in) - Rewrite hackney_metrics as API facade routing to backends - Update hackney_manager to use new metrics API (request metrics) - Update hackney_pool to use new metrics API (pool metrics) - Remove external metrics library dependency Metric types fixed (issue #560): - Pool free_count/in_use_count now use gauges (not histograms) - Request active count now uses gauge (not counter) New metric names (Prometheus-compatible): - hackney_requests_total (counter, labels: host) - hackney_requests_active (gauge, labels: host) - hackney_requests_finished_total (counter, labels: host) - hackney_request_duration_seconds (histogram, labels: host) - hackney_pool_free_count (gauge, labels: pool) - hackney_pool_in_use_count (gauge, labels: pool) - hackney_pool_checkouts_total (counter, labels: pool) Configuration: - Default: hackney_metrics_dummy (no-op, zero overhead) - To enable Prometheus: {hackney, [{metrics_backend, prometheus}]} Closes #772, closes #560
1 parent 1aa5945 commit 2943f4a

12 files changed

Lines changed: 418 additions & 124 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ rebar3.crashdump
1515
doc/
1616
priv/*.so
1717
priv/*.dll
18+
rebar.lock

rebar.config

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@
1414
]}.
1515

1616
{xref_checks, [undefined_function_calls]}.
17+
%% Ignore xref warnings for optional prometheus dependency
18+
{xref_ignores, [
19+
{prometheus_counter, inc, 3},
20+
{prometheus_counter, declare, 1},
21+
{prometheus_gauge, set, 3},
22+
{prometheus_gauge, inc, 2},
23+
{prometheus_gauge, dec, 2},
24+
{prometheus_gauge, declare, 1},
25+
{prometheus_histogram, observe, 3},
26+
{prometheus_histogram, declare, 1}
27+
]}.
1728

1829
{cover_enabled, true}.
1930
{eunit_opts, [verbose]}.
@@ -37,7 +48,6 @@
3748
{idna, "~>6.1.0"},
3849
{mimerl, "~>1.4"},
3950
{certifi, "~>2.15.0"},
40-
{metrics, "~>1.0.0"},
4151
{parse_trans, "3.4.1"},
4252
{ssl_verify_fun, "~>1.1.0"},
4353
{unicode_util_compat, "~>0.7.1"}
@@ -85,6 +95,8 @@
8595
error_handling%,
8696
%unknown
8797
]},
98+
%% Exclude prometheus backend - prometheus is an optional dependency
99+
{exclude_mods, [hackney_metrics_prometheus]},
88100
{base_plt_apps, [erts, stdlib, kernel, crypto, runtime_tools]},
89101
{plt_apps, top_level_deps},
90102
{plt_extra_apps, []},

rebar.lock

Lines changed: 0 additions & 26 deletions
This file was deleted.

src/hackney.app.src

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
mimerl,
1717
certifi,
1818
ssl_verify_fun,
19-
metrics,
2019
unicode_util_compat]},
2120
{included_applications, []},
2221
{mod, { hackney_app, []}},

src/hackney_manager.erl

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212

1313
%% Metrics API
1414
-export([start_request/1,
15-
finish_request/2,
16-
get_metrics_engine/0]).
15+
finish_request/2]).
1716

1817
%% Backward compatibility API
1918
-export([get_state/1, async_response_pid/1]).
@@ -23,9 +22,7 @@
2322
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
2423
terminate/2, code_change/3]).
2524

26-
-record(state, {
27-
metrics_engine
28-
}).
25+
-record(state, {}).
2926

3027
%%====================================================================
3128
%% API
@@ -41,16 +38,11 @@ start_request(Host) ->
4138
finish_request(Host, StartTime) ->
4239
gen_server:cast(?MODULE, {finish_request, Host, StartTime}).
4340

44-
%% @doc Get the current metrics engine.
45-
-spec get_metrics_engine() -> module().
46-
get_metrics_engine() ->
47-
hackney_metrics:get_engine().
48-
4941
%% @doc Check the state of a connection (backward compatibility).
5042
%% In the old architecture, this tracked request state.
5143
%% In the new architecture, we simply check if the connection process is alive.
52-
%% Returns `req_not_found' if the process is dead, or the connection state.
53-
-spec get_state(pid() | term()) -> req_not_found | term().
44+
%% Returns `req_not_found' if the process is dead, or the connection state name.
45+
-spec get_state(pid() | term()) -> req_not_found | atom().
5446
get_state(ConnPid) when is_pid(ConnPid) ->
5547
case is_process_alive(ConnPid) of
5648
false -> req_not_found;
@@ -63,21 +55,15 @@ get_state(ConnPid) when is_pid(ConnPid) ->
6355
get_state(_) ->
6456
req_not_found.
6557

66-
%% @doc Check if a connection is in async mode (backward compatibility).
67-
%% In the old architecture, this returned the async response process PID.
68-
%% In the new architecture, we check if the connection process is in async mode.
69-
%% Returns `{error, req_not_async}' if not in async mode.
70-
-spec async_response_pid(pid() | term()) -> {ok, pid()} | {error, req_not_async}.
71-
async_response_pid(ConnPid) when is_pid(ConnPid) ->
72-
case is_process_alive(ConnPid) of
73-
false -> {error, req_not_async};
74-
true ->
75-
case hackney_conn:get_state(ConnPid) of
76-
{ok, State} when State =:= receiving; State =:= streaming ->
77-
{ok, ConnPid};
78-
_ ->
79-
{error, req_not_async}
80-
end
58+
%% @doc Get the async response pid (backward compatibility).
59+
%% In the new architecture, all streaming connections are considered "async".
60+
-spec async_response_pid(pid()) -> {ok, pid()} | {error, req_not_found | req_not_async}.
61+
async_response_pid(Ref) when is_pid(Ref) ->
62+
case get_state(Ref) of
63+
req_not_found -> {error, req_not_found};
64+
streaming -> {ok, Ref};
65+
streaming_once -> {ok, Ref};
66+
_ -> {error, req_not_async}
8167
end;
8268
async_response_pid(_) ->
8369
{error, req_not_async}.
@@ -90,28 +76,27 @@ start_link() ->
9076
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
9177

9278
init([]) ->
93-
%% Initialize metrics
94-
Engine = hackney_metrics:get_engine(),
95-
_ = metrics:new(Engine, counter, [hackney, nb_requests]),
96-
_ = metrics:new(Engine, counter, [hackney, total_requests]),
97-
_ = metrics:new(Engine, counter, [hackney, finished_requests]),
98-
{ok, #state{metrics_engine = Engine}}.
79+
{ok, #state{}}.
9980

10081
handle_call(_Request, _From, State) ->
10182
{reply, ok, State}.
10283

103-
handle_cast({start_request, Host}, #state{metrics_engine = Engine} = State) ->
104-
_ = metrics:increment_counter(Engine, [hackney, Host, nb_requests]),
105-
_ = metrics:increment_counter(Engine, [hackney, nb_requests]),
106-
_ = metrics:increment_counter(Engine, [hackney, total_requests]),
84+
handle_cast({start_request, Host}, State) ->
85+
HostBin = to_binary(Host),
86+
Labels = #{host => HostBin},
87+
_ = hackney_metrics:counter_inc(hackney_requests_total, Labels),
88+
_ = hackney_metrics:gauge_inc(hackney_requests_active, Labels),
10789
{noreply, State};
10890

109-
handle_cast({finish_request, Host, StartTime}, #state{metrics_engine = Engine} = State) ->
110-
RequestTime = timer:now_diff(os:timestamp(), StartTime) / 1000,
111-
_ = metrics:update_histogram(Engine, [hackney, Host, request_time], RequestTime),
112-
_ = metrics:decrement_counter(Engine, [hackney, Host, nb_requests]),
113-
_ = metrics:decrement_counter(Engine, [hackney, nb_requests]),
114-
_ = metrics:increment_counter(Engine, [hackney, finished_requests]),
91+
handle_cast({finish_request, Host, StartTime}, State) ->
92+
HostBin = to_binary(Host),
93+
Labels = #{host => HostBin},
94+
%% Calculate duration in seconds (Prometheus convention)
95+
DurationMicros = timer:now_diff(os:timestamp(), StartTime),
96+
DurationSeconds = DurationMicros / 1000000,
97+
_ = hackney_metrics:histogram_observe(hackney_request_duration_seconds, Labels, DurationSeconds),
98+
_ = hackney_metrics:gauge_dec(hackney_requests_active, Labels),
99+
_ = hackney_metrics:counter_inc(hackney_requests_finished_total, Labels),
115100
{noreply, State};
116101

117102
handle_cast(_Msg, State) ->
@@ -125,3 +110,11 @@ terminate(_Reason, _State) ->
125110

126111
code_change(_OldVsn, State, _Extra) ->
127112
{ok, State}.
113+
114+
%%====================================================================
115+
%% Internal functions
116+
%%====================================================================
117+
118+
to_binary(Host) when is_binary(Host) -> Host;
119+
to_binary(Host) when is_list(Host) -> list_to_binary(Host);
120+
to_binary(Host) when is_atom(Host) -> atom_to_binary(Host, utf8).

src/hackney_metrics.erl

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
%%% This file is part of hackney released under the Apache 2 license.
44
%%% See the NOTICE for more information.
55
%%%
6-
%%% Copyright (c) 2012-2018 Benoît Chesneau <benoitc@e-engura.org>
6+
%%% Copyright (c) 2012-2026 Benoît Chesneau <benoitc@e-engura.org>
77
%%%
88

99
-module(hackney_metrics).
@@ -12,16 +12,122 @@
1212
%% API
1313
-export([
1414
init/0,
15-
get_engine/0
15+
get_backend/0
1616
]).
1717

18+
%% Counter operations
19+
-export([
20+
counter_inc/2,
21+
counter_inc/3
22+
]).
23+
24+
%% Gauge operations
25+
-export([
26+
gauge_set/3,
27+
gauge_inc/2,
28+
gauge_dec/2
29+
]).
30+
31+
%% Histogram operations
32+
-export([
33+
histogram_observe/3
34+
]).
35+
36+
%% Metric declarations
37+
-export([
38+
declare_counter/3,
39+
declare_gauge/3,
40+
declare_histogram/3,
41+
declare_histogram/4,
42+
declare_pool_metrics/1
43+
]).
1844

1945
-include("hackney.hrl").
2046

47+
%% Default duration histogram buckets (in seconds)
48+
-define(DEFAULT_DURATION_BUCKETS, [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]).
2149

50+
%% @doc Initialize the metrics system.
51+
%% Determines the backend to use and declares all hackney metrics.
2252
init() ->
23-
Metrics = metrics:init(hackney_util:mod_metrics()),
24-
ets:insert(?CONFIG, {mod_metrics, Metrics}).
53+
Backend = hackney_util:mod_metrics(),
54+
ets:insert(?CONFIG, {metrics_backend, Backend}),
55+
declare_metrics(Backend).
56+
57+
%% @doc Get the current metrics backend module.
58+
get_backend() ->
59+
try
60+
ets:lookup_element(?CONFIG, metrics_backend, 2)
61+
catch
62+
error:badarg ->
63+
%% ETS table not ready yet, return dummy backend
64+
hackney_metrics_dummy
65+
end.
66+
67+
%% @doc Increment a counter by 1.
68+
counter_inc(Name, Labels) ->
69+
(get_backend()):counter_inc(Name, Labels).
70+
71+
%% @doc Increment a counter by a value.
72+
counter_inc(Name, Labels, Value) ->
73+
(get_backend()):counter_inc(Name, Labels, Value).
74+
75+
%% @doc Set a gauge to a value.
76+
gauge_set(Name, Labels, Value) ->
77+
(get_backend()):gauge_set(Name, Labels, Value).
78+
79+
%% @doc Increment a gauge by 1.
80+
gauge_inc(Name, Labels) ->
81+
(get_backend()):gauge_inc(Name, Labels).
82+
83+
%% @doc Decrement a gauge by 1.
84+
gauge_dec(Name, Labels) ->
85+
(get_backend()):gauge_dec(Name, Labels).
86+
87+
%% @doc Observe a value for a histogram.
88+
histogram_observe(Name, Labels, Value) ->
89+
(get_backend()):histogram_observe(Name, Labels, Value).
90+
91+
%% @doc Declare a counter metric.
92+
declare_counter(Name, Help, LabelKeys) ->
93+
(get_backend()):declare_counter(Name, Help, LabelKeys).
94+
95+
%% @doc Declare a gauge metric.
96+
declare_gauge(Name, Help, LabelKeys) ->
97+
(get_backend()):declare_gauge(Name, Help, LabelKeys).
98+
99+
%% @doc Declare a histogram metric with default buckets.
100+
declare_histogram(Name, Help, LabelKeys) ->
101+
(get_backend()):declare_histogram(Name, Help, LabelKeys).
102+
103+
%% @doc Declare a histogram metric with custom buckets.
104+
declare_histogram(Name, Help, LabelKeys, Buckets) ->
105+
(get_backend()):declare_histogram(Name, Help, LabelKeys, Buckets).
106+
107+
%% @doc Declare pool-specific metrics.
108+
%% Called when a new pool is created.
109+
declare_pool_metrics(_PoolName) ->
110+
Backend = get_backend(),
111+
%% Only declare once (idempotent for prometheus)
112+
Backend:declare_gauge(hackney_pool_free_count,
113+
<<"Number of free/available connections in the pool">>, [pool]),
114+
Backend:declare_gauge(hackney_pool_in_use_count,
115+
<<"Number of connections currently in use">>, [pool]),
116+
Backend:declare_counter(hackney_pool_checkouts_total,
117+
<<"Total number of connection checkouts">>, [pool]),
118+
ok.
25119

26-
get_engine() ->
27-
ets:lookup_element(?CONFIG, mod_metrics, 2).
120+
%% @private
121+
%% Declare all hackney metrics at startup.
122+
declare_metrics(Backend) ->
123+
%% Request metrics
124+
Backend:declare_counter(hackney_requests_total,
125+
<<"Total number of HTTP requests started">>, [host]),
126+
Backend:declare_gauge(hackney_requests_active,
127+
<<"Number of currently active HTTP requests">>, [host]),
128+
Backend:declare_counter(hackney_requests_finished_total,
129+
<<"Total number of HTTP requests finished">>, [host]),
130+
Backend:declare_histogram(hackney_request_duration_seconds,
131+
<<"HTTP request duration in seconds">>, [host], ?DEFAULT_DURATION_BUCKETS),
132+
%% Pool metrics are declared when pools are created
133+
ok.

src/hackney_metrics_backend.erl

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
%%% -*- erlang -*-
2+
%%%
3+
%%% This file is part of hackney released under the Apache 2 license.
4+
%%% See the NOTICE for more information.
5+
%%%
6+
%%% Copyright (c) 2012-2026 Benoît Chesneau <benoitc@e-engura.org>
7+
%%%
8+
9+
-module(hackney_metrics_backend).
10+
-author("benoitc").
11+
12+
%% Behaviour callbacks for hackney metrics backends
13+
%%
14+
%% Implementations must export all callback functions.
15+
%% See hackney_metrics_dummy for a reference implementation.
16+
17+
%% Counter operations (monotonically increasing)
18+
-callback counter_inc(Name :: atom(), Labels :: map()) -> ok.
19+
-callback counter_inc(Name :: atom(), Labels :: map(), Value :: number()) -> ok.
20+
21+
%% Gauge operations (can go up or down)
22+
-callback gauge_set(Name :: atom(), Labels :: map(), Value :: number()) -> ok.
23+
-callback gauge_inc(Name :: atom(), Labels :: map()) -> ok.
24+
-callback gauge_dec(Name :: atom(), Labels :: map()) -> ok.
25+
26+
%% Histogram operations (for timing/distribution measurements)
27+
-callback histogram_observe(Name :: atom(), Labels :: map(), Value :: number()) -> ok.
28+
29+
%% Metric lifecycle
30+
-callback declare_counter(Name :: atom(), Help :: binary(), LabelKeys :: [atom()]) -> ok.
31+
-callback declare_gauge(Name :: atom(), Help :: binary(), LabelKeys :: [atom()]) -> ok.
32+
-callback declare_histogram(Name :: atom(), Help :: binary(), LabelKeys :: [atom()]) -> ok.
33+
-callback declare_histogram(Name :: atom(), Help :: binary(), LabelKeys :: [atom()], Buckets :: [number()]) -> ok.

0 commit comments

Comments
 (0)