io-metrics 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff485ebbe44dc9bae2117fa771ed89e7b2e42e3270d93094bae2fa4808405a5d
4
- data.tar.gz: e724a731428b1c7d5219f6abcb518b99cddd572efcf0d9de02eff1d30f90400e
3
+ metadata.gz: 50d4b28faec3b5ae9389c3437d1e5687c1e54229d0abfd79305699e3f43e798b
4
+ data.tar.gz: 33db46c67476acedd0ee7695d437679ffe8e3602f60de2d15363e9b59423aab6
5
5
  SHA512:
6
- metadata.gz: 27ef4c0c0f75b3933bbd7cf6815997bf62d3d3cc05c6dbe0ca9e75a3db143f6fb122153d4d7b967fd2b04781b0c12dc1158a7998178cee6bc954d7c7e576a3b6
7
- data.tar.gz: c1bfed9052fb9fbf92f25bf9952eb83d3dc0759bb55746cca68068942d772a145c43659888045315b58ba9b7c4b4209ce196889b21d0c0d17c4b7cd6cacff5b4
6
+ metadata.gz: 4131cbf7a3d8d15256972f057c24608ac1f9a72e958a6361ef24e36ceb97f669767e80dfc309d9c39df6d8aa530d10f82d93d9e53a61f7e6d2b1a47e16568434
7
+ data.tar.gz: b3c5ae946df3a0f7d1fe59d787f91aa0faf95c2f8b8353a2b132576325f033c3e7819613923b5ba79fe87e153424591fa66f3f1cfbc6030592bfa2d44e39619d
checksums.yaml.gz.sig CHANGED
Binary file
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
+ require "socket"
7
+
6
8
  class IO
7
9
  module Metrics
8
10
  # Darwin (macOS) implementation of listener statistics using netstat -L.
@@ -14,31 +16,43 @@ class IO
14
16
  File.executable?(NETSTAT)
15
17
  end
16
18
 
17
- # Parse an address from netstat format to "ip:port" format.
19
+ # Parse an address from netstat format to Addrinfo (TCP, numeric port).
18
20
  # @parameter address [String] Address string from netstat, e.g. "127.0.0.1.50876" or "*.63703".
19
- # @returns [String] Address in "ip:port" format, e.g. "127.0.0.1:50876" or "0.0.0.0:63703".
21
+ # @returns [Addrinfo | Nil] Addrinfo for the listener, or nil if the line cannot be parsed.
20
22
  def self.parse_address(address)
21
23
  # Handle wildcard addresses: *.port -> 0.0.0.0:port
22
24
  if address.start_with?("*.")
23
- port = address[2..-1]
24
- return "0.0.0.0:#{port}"
25
+ port = address[2..-1].to_i
26
+ return Addrinfo.tcp("0.0.0.0", port)
25
27
  end
26
28
 
27
29
  # Handle IPv4 addresses: ip.port -> ip:port
28
30
  if address =~ /^([0-9.]+)\.(\d+)$/
29
31
  ip = $1
30
- port = $2
31
- return "#{ip}:#{port}"
32
+ port = $2.to_i
33
+ return Addrinfo.tcp(ip, port)
32
34
  end
33
35
 
34
- # Handle IPv6 addresses (if present in future)
35
- # For now, return as-is
36
- return address
36
+ # Handle IPv6 or other formats: best-effort via Addrinfo.parse
37
+ begin
38
+ Addrinfo.parse(address)
39
+ rescue ArgumentError, SocketError
40
+ nil
41
+ end
42
+ end
43
+
44
+ # Build a stable string key for TCP listener filter matching (same style as Linux / user filters).
45
+ def self.tcp_listener_key(addrinfo)
46
+ if addrinfo.ipv6?
47
+ "[#{addrinfo.ip_address}]:#{addrinfo.ip_port}"
48
+ else
49
+ "#{addrinfo.ip_address}:#{addrinfo.ip_port}"
50
+ end
37
51
  end
38
52
 
39
53
  # Parse netstat -L output and extract listener statistics.
40
54
  # @parameter addresses [Array(String) | Nil] Optional filter for specific addresses.
41
- # @returns [Hash(String, Listener)] Hash mapping "ip:port" to Listener.
55
+ # @returns [Array(Listener)] One entry per listening socket reported by netstat.
42
56
  def self.capture_tcp(addresses = nil)
43
57
  listeners = {}
44
58
  address_filter = addresses ? addresses.map{|address| address.downcase}.to_set : nil
@@ -62,36 +76,32 @@ class IO
62
76
  # incomplete_queue_length = $2.to_i # incomplete connections (SYN_RECV)
63
77
  # maximum_queue_length = $3.to_i # maximum queue size
64
78
 
65
- # Parse address
66
- address = parse_address(local_address_raw)
79
+ addrinfo = parse_address(local_address_raw)
80
+ next unless addrinfo
67
81
 
82
+ key = tcp_listener_key(addrinfo)
68
83
  # Apply filter if specified
69
- next if address_filter && !address_filter.include?(address)
84
+ next if address_filter && !address_filter.include?(key.downcase)
70
85
 
71
- listeners[address] ||= Listener.zero
72
- listeners[address].queue_size = queue_length
86
+ listeners[key] ||= Listener.new(addrinfo, 0, 0)
87
+ listeners[key].queue_size = queue_length
73
88
  # active_connections set to 0 (can't reliably count per listener)
74
- listeners[address].active_connections = 0
89
+ listeners[key].active_connections = 0
75
90
  end
76
91
  end
77
92
  end
78
93
 
79
- return listeners
94
+ return listeners.values
80
95
  rescue Errno::ENOENT, Errno::EACCES
81
- return {}
96
+ return []
82
97
  end
83
98
 
84
99
  # Capture listener listeners for TCP sockets.
85
100
  # @parameter addresses [Array(String) | Nil] TCP address(es) to capture, e.g. ["0.0.0.0:80"]. If nil, captures all.
86
101
  # @parameter paths [Array(String) | Nil] Unix socket path(s) to capture (not supported on Darwin).
87
- # @returns [Hash(String, Listener)] Hash mapping addresses to Listener.
102
+ # @returns [Array(Listener)] TCP listeners from netstat.
88
103
  def self.capture(addresses: nil, paths: nil)
89
- listeners = {}
90
-
91
- # Capture TCP listeners (Unix sockets not supported on Darwin via netstat)
92
- listeners.merge!(capture_tcp(addresses))
93
-
94
- return listeners
104
+ capture_tcp(addresses)
95
105
  end
96
106
  end
97
107
  end
@@ -109,7 +119,7 @@ if IO::Metrics::Listener::Darwin.supported?
109
119
  # Capture listener listeners for the given address(es).
110
120
  # @parameter addresses [Array(String) | Nil] TCP address(es) to capture, e.g. ["0.0.0.0:80"]. If nil, captures all listening TCP sockets.
111
121
  # @parameter paths [Array(String) | Nil] Unix socket path(s) to capture (not supported on Darwin).
112
- # @returns [Hash(String, Listener) | Nil] A hash mapping addresses to Listener, or nil if not supported.
122
+ # @returns [Array(Listener) | Nil] Captured listeners, or nil if not supported.
113
123
  def capture(**options)
114
124
  IO::Metrics::Listener::Darwin.capture(**options)
115
125
  end
@@ -103,7 +103,7 @@ class IO
103
103
 
104
104
  # Find the best matching listener for an ESTABLISHED connection.
105
105
  # @parameter local_address [String] Local address in "ip:port" or "[ipv6]:port" format.
106
- # @parameter listeners [Hash(String, Listener)] Hash of listener addresses to Listener objects.
106
+ # @parameter listeners [Hash(String, Listener)] Internal map from display address string to Listener.
107
107
  # @returns [String | Nil] The address of the matching listener, or nil if no match.
108
108
  def self.find_matching_listener(local_address, listeners)
109
109
  # Try exact match first
@@ -149,8 +149,15 @@ class IO
149
149
  # @parameter file [String] Path to /proc/net/tcp or /proc/net/tcp6.
150
150
  # @parameter addresses [Array(String) | Nil] Optional filter for specific addresses.
151
151
  # @parameter ipv6 [Boolean] Whether parsing IPv6 addresses.
152
- # @returns [Hash(String, Listener)] Hash mapping "ip:port" or "[ipv6]:port" to Listener.
152
+ # @returns [Array(Listener)] One entry per listening socket.
153
153
  def self.capture_tcp_file(file, addresses = nil, ipv6: false)
154
+ gather_tcp_file(file, addresses, ipv6: ipv6).values
155
+ rescue Errno::ENOENT, Errno::EACCES
156
+ return []
157
+ end
158
+
159
+ # Internal: same as capture_tcp_file but returns a Hash keyed by display address for merging and connection matching.
160
+ def self.gather_tcp_file(file, addresses = nil, ipv6: false)
154
161
  listeners = {}
155
162
  address_filter = addresses ? addresses.map{|address| address.downcase}.to_set : nil
156
163
  connections = []
@@ -174,7 +181,8 @@ class IO
174
181
  if state == :listen
175
182
  if ipv6
176
183
  local_ip = parse_ipv6(local_ip_hex)
177
- local_address = "[#{local_ip}]:#{parse_port(local_port_hex)}"
184
+ local_port = parse_port(local_port_hex)
185
+ local_address = "[#{local_ip}]:#{local_port}"
178
186
  else
179
187
  local_ip = parse_ipv4(local_ip_hex)
180
188
  local_port = parse_port(local_port_hex)
@@ -182,11 +190,12 @@ class IO
182
190
  end
183
191
 
184
192
  # Apply filter if specified
185
- next if address_filter && !address_filter.include?(local_address)
193
+ next if address_filter && !address_filter.include?(local_address.downcase)
186
194
 
187
- listeners[local_address] ||= Listener.zero
188
- # rx_queue shows number of connections waiting to be accepted
189
- listeners[local_address].queue_size = rx_queue_hex.to_i(16)
195
+ listeners[local_address] ||= Listener.new(Addrinfo.tcp(local_ip, local_port), 0, 0)
196
+ # rx_queue shows number of connections waiting to be accepted.
197
+ # Accumulate across SO_REUSEPORT sockets sharing the same address.
198
+ listeners[local_address].queue_size += rx_queue_hex.to_i(16)
190
199
  listeners[local_address].active_connections = 0
191
200
  # Collect ESTABLISHED connections to count later
192
201
  elsif state == :established
@@ -210,6 +219,14 @@ class IO
210
219
  end
211
220
  end
212
221
 
222
+ # /proc lists every ESTABLISHED child with the listener's local address, including
223
+ # sockets still in the accept queue. Those are already counted in queue_size on the
224
+ # LISTEN row (same meaning as Raindrops inet_diag queued vs inode != 0 for active).
225
+ listeners.each_value do |listener|
226
+ backlog = listener.queue_size
227
+ listener.active_connections = [listener.active_connections - backlog, 0].max
228
+ end
229
+
213
230
  return listeners
214
231
  rescue Errno::ENOENT, Errno::EACCES
215
232
  return {}
@@ -218,7 +235,7 @@ class IO
218
235
  # Parse /proc/net/unix and extract listener statistics for Unix domain sockets.
219
236
  # @parameter paths [Array(String) | Nil] Optional filter for specific socket paths.
220
237
  # @parameter file [String] Optional path to Unix socket file (defaults to "/proc/net/unix").
221
- # @returns [Hash(String, Listener)] Hash mapping socket path to Listener.
238
+ # @returns [Array(Listener)] One entry per socket path with any matching activity.
222
239
  def self.capture_unix(paths = nil, file: "/proc/net/unix")
223
240
  listeners = {}
224
241
  path_filter = paths ? paths.to_set : nil
@@ -246,7 +263,7 @@ class IO
246
263
 
247
264
  state = state_hex.to_i(16)
248
265
 
249
- listeners[path] ||= Listener.zero
266
+ listeners[path] ||= Listener.new(Addrinfo.unix(path), 0, 0)
250
267
 
251
268
  case state
252
269
  when SS_CONNECTING # Queued connections
@@ -256,37 +273,37 @@ class IO
256
273
  end
257
274
  end
258
275
 
259
- return listeners
276
+ return listeners.values
260
277
  rescue Errno::ENOENT, Errno::EACCES
261
- return {}
278
+ return []
262
279
  end
263
280
 
264
281
  # Parse /proc/net/tcp and /proc/net/tcp6 and extract listener statistics.
265
282
  # @parameter addresses [Array(String) | Nil] Optional filter for specific addresses.
266
- # @returns [Hash(String, Listener)] Hash mapping "ip:port" or "[ipv6]:port" to Listener.
283
+ # @returns [Array(Listener)] TCP listeners from both stacks.
267
284
  def self.capture_tcp(addresses = nil)
268
285
  listeners = {}
269
286
 
270
287
  # Capture IPv4 listeners and connections
271
288
  if File.readable?("/proc/net/tcp")
272
- listeners.merge!(capture_tcp_file("/proc/net/tcp", addresses, ipv6: false))
289
+ listeners.merge!(gather_tcp_file("/proc/net/tcp", addresses, ipv6: false))
273
290
  end
274
291
 
275
292
  # Capture IPv6 listeners and connections
276
293
  if File.readable?("/proc/net/tcp6")
277
- listeners.merge!(capture_tcp_file("/proc/net/tcp6", addresses, ipv6: true))
294
+ listeners.merge!(gather_tcp_file("/proc/net/tcp6", addresses, ipv6: true))
278
295
  end
279
296
 
280
- return listeners
297
+ return listeners.values
281
298
  end
282
299
 
283
300
  # Capture listener listeners for TCP and/or Unix domain sockets.
284
301
  # @parameter addresses [Array(String) | Nil] TCP address(es) to capture, e.g. ["0.0.0.0:80"]. If nil and paths is nil, captures all. If nil but paths specified, captures none.
285
302
  # @parameter paths [Array(String) | Nil] Unix socket path(s) to capture. If nil and addresses is nil, captures all. If nil but addresses specified, captures none.
286
303
  # @parameter unix_file [String] Optional path to Unix socket file (defaults to "/proc/net/unix").
287
- # @returns [Hash(String, Listener)] Hash mapping addresses/paths to Listener.
304
+ # @returns [Array(Listener)] All matching listeners (TCP and/or Unix).
288
305
  def self.capture(addresses: nil, paths: nil, unix_file: "/proc/net/unix")
289
- listeners = {}
306
+ result = []
290
307
 
291
308
  # If addresses are specified but paths is nil, don't capture Unix sockets
292
309
  # Only capture Unix sockets if paths is explicitly provided or addresses is nil
@@ -294,12 +311,12 @@ class IO
294
311
  unix_paths = paths.nil? && !addresses.nil? ? :skip : paths
295
312
 
296
313
  # Capture TCP listeners (only if not skipped)
297
- listeners.merge!(capture_tcp(tcp_addresses)) unless tcp_addresses == :skip
314
+ result.concat(capture_tcp(tcp_addresses)) unless tcp_addresses == :skip
298
315
 
299
316
  # Capture Unix domain socket listeners (only if not skipped)
300
- listeners.merge!(capture_unix(unix_paths, file: unix_file)) unless unix_paths == :skip
317
+ result.concat(capture_unix(unix_paths, file: unix_file)) unless unix_paths == :skip
301
318
 
302
- return listeners
319
+ return result
303
320
  end
304
321
  end
305
322
  end
@@ -317,7 +334,7 @@ if IO::Metrics::Listener::Linux.supported?
317
334
  # Capture listener listeners for the given address(es).
318
335
  # @parameter addresses [Array(String) | Nil] TCP address(es) to capture, e.g. ["0.0.0.0:80"]. If nil, captures all listening TCP sockets.
319
336
  # @parameter paths [Array(String) | Nil] Unix socket path(s) to capture. If nil and addresses is nil, captures all. If nil but addresses specified, captures none.
320
- # @returns [Hash(String, Listener) | Nil] A hash mapping addresses/paths to Listener, or nil if not supported.
337
+ # @returns [Array(Listener) | Nil] Captured listeners, or nil if not supported.
321
338
  def capture(**options)
322
339
  IO::Metrics::Listener::Linux.capture(**options)
323
340
  end
@@ -8,20 +8,28 @@ require "json"
8
8
  class IO
9
9
  module Metrics
10
10
  # Represents a network listener socket with its queue statistics.
11
+ # @attribute address [Addrinfo | Nil] Listening endpoint from capture; nil only for {Listener.zero} placeholders.
11
12
  # @attribute queue_size [Integer] Number of connections waiting to be accepted (queued).
12
13
  # @attribute active_connections [Integer] Number of active connections (already accepted).
13
- class Listener < Struct.new(:queue_size, :active_connections)
14
- alias as_json to_h
14
+ class Listener < Struct.new(:address, :queue_size, :active_connections)
15
+ # Serialize for JSON; address uses Addrinfo#inspect_sockaddr.
16
+ def as_json(*)
17
+ {
18
+ address: address&.inspect_sockaddr,
19
+ queue_size: queue_size,
20
+ active_connections: active_connections,
21
+ }
22
+ end
15
23
 
16
24
  # Convert the object to a JSON string.
17
25
  def to_json(*arguments)
18
26
  as_json.to_json(*arguments)
19
27
  end
20
28
 
21
- # Create a zero-initialized Listener instance.
22
- # @returns [Listener] A new Listener object with all fields set to zero.
29
+ # Create a zero-initialized Listener instance (no endpoint; for tests or templates).
30
+ # @returns [Listener] Counters zero; {#address} is nil.
23
31
  def self.zero
24
- self.new(0, 0)
32
+ new(nil, 0, 0)
25
33
  end
26
34
 
27
35
  # Whether listener stats can be captured on this system.
@@ -32,7 +40,7 @@ class IO
32
40
  # Capture listener stats for the given address(es).
33
41
  # @parameter addresses [Array(String) | Nil] TCP address(es) to capture, e.g. ["0.0.0.0:80"]. If nil, captures all listening TCP sockets.
34
42
  # @parameter paths [Array(String) | Nil] Unix socket path(s) to capture. If nil and addresses is nil, captures all. If nil but addresses specified, captures none.
35
- # @returns [Hash(String, Listener) | Nil] A hash mapping addresses/paths to Listener, or nil if not supported.
43
+ # @returns [Array(Listener) | Nil] Captured listeners, or nil if not supported.
36
44
  def self.capture(**options)
37
45
  return nil
38
46
  end
@@ -7,6 +7,6 @@
7
7
  class IO
8
8
  # @namespace
9
9
  module Metrics
10
- VERSION = "0.1.0"
10
+ VERSION = "0.2.1"
11
11
  end
12
12
  end
data/readme.md CHANGED
@@ -10,6 +10,20 @@ Please see the [project documentation](https://socketry.github.io/io-metrics/) f
10
10
 
11
11
  - [Getting Started](https://socketry.github.io/io-metrics/guides/getting-started/index) - This guide explains how to use `io-metrics` to capture listener queue statistics from the host operating system.
12
12
 
13
+ ## Releases
14
+
15
+ Please see the [project releases](https://socketry.github.io/io-metrics/releases/index) for all releases.
16
+
17
+ ### v0.2.1
18
+
19
+ - Fixed `queue_size` under-reporting when multiple `SO_REUSEPORT` sockets share the same address — queue depths are now accumulated across all sockets rather than overwritten by the last one.
20
+ - **Linux** `Listener#active_connections` for TCP no longer counts sockets that are still in the kernel accept queue (those remain in `queue_size`). Counts now match the usual “past `accept()`” meaning and align with tools such as Raindrops’ `ListenStats#active`.
21
+
22
+ ### v0.2.0
23
+
24
+ - **Breaking** `IO::Metrics::Listener.capture` returns an `Array` of `Listener` rows instead of a `Hash` keyed by address string.
25
+ - Each `Listener` has `address` (`Addrinfo` for TCP or Unix), `queue_size`, and `active_connections`. `Listener.zero` sets `address` to `nil`. JSON uses `Addrinfo#inspect_sockaddr` for `address`, or `null` when absent.
26
+
13
27
  ## Contributing
14
28
 
15
29
  We welcome contributions to this project.
@@ -20,6 +34,22 @@ We welcome contributions to this project.
20
34
  4. Push to the branch (`git push origin my-new-feature`).
21
35
  5. Create new Pull Request.
22
36
 
37
+ ### Running Tests
38
+
39
+ To run the test suite:
40
+
41
+ ``` shell
42
+ bundle exec sus
43
+ ```
44
+
45
+ ### Making Releases
46
+
47
+ To make a new release:
48
+
49
+ ``` shell
50
+ bundle exec bake gem:release:patch # or minor or major
51
+ ```
52
+
23
53
  ### Developer Certificate of Origin
24
54
 
25
55
  In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
data/releases.md ADDED
@@ -0,0 +1,11 @@
1
+ # Releases
2
+
3
+ ## v0.2.1
4
+
5
+ - Fixed `queue_size` under-reporting when multiple `SO_REUSEPORT` sockets share the same address — queue depths are now accumulated across all sockets rather than overwritten by the last one.
6
+ - **Linux** `Listener#active_connections` for TCP no longer counts sockets that are still in the kernel accept queue (those remain in `queue_size`). Counts now match the usual “past `accept()`” meaning and align with tools such as Raindrops’ `ListenStats#active`.
7
+
8
+ ## v0.2.0
9
+
10
+ - **Breaking** `IO::Metrics::Listener.capture` returns an `Array` of `Listener` rows instead of a `Hash` keyed by address string.
11
+ - Each `Listener` has `address` (`Addrinfo` for TCP or Unix), `queue_size`, and `active_connections`. `Listener.zero` sets `address` to `nil`. JSON uses `Addrinfo#inspect_sockaddr` for `address`, or `null` when absent.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: io-metrics
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -77,6 +77,7 @@ files:
77
77
  - lib/io/metrics/version.rb
78
78
  - license.md
79
79
  - readme.md
80
+ - releases.md
80
81
  homepage: https://github.com/socketry/io-metrics
81
82
  licenses:
82
83
  - MIT
@@ -91,14 +92,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
91
92
  requirements:
92
93
  - - ">="
93
94
  - !ruby/object:Gem::Version
94
- version: '3.2'
95
+ version: '3.3'
95
96
  required_rubygems_version: !ruby/object:Gem::Requirement
96
97
  requirements:
97
98
  - - ">="
98
99
  - !ruby/object:Gem::Version
99
100
  version: '0'
100
101
  requirements: []
101
- rubygems_version: 4.0.3
102
+ rubygems_version: 4.0.6
102
103
  specification_version: 4
103
104
  summary: Extract I/O metrics from the host system.
104
105
  test_files: []
metadata.gz.sig CHANGED
Binary file