io-metrics 0.1.0 → 0.2.0

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: d3e42882ce221e2e9f10808ed4de4ea6a72b3490662a014727e40b49fe8826ab
4
+ data.tar.gz: da5595f41dfe59f91dc3355ebe6538bac4ca0bc70e1c1040c6cc6e911c977498
5
5
  SHA512:
6
- metadata.gz: 27ef4c0c0f75b3933bbd7cf6815997bf62d3d3cc05c6dbe0ca9e75a3db143f6fb122153d4d7b967fd2b04781b0c12dc1158a7998178cee6bc954d7c7e576a3b6
7
- data.tar.gz: c1bfed9052fb9fbf92f25bf9952eb83d3dc0759bb55746cca68068942d772a145c43659888045315b58ba9b7c4b4209ce196889b21d0c0d17c4b7cd6cacff5b4
6
+ metadata.gz: cee9a635c1df4b7bfc18497e835add923739f5656737126bdc86cae1bca17a8074925243240eed1b802f741e8956d63b18c434af7f8d29853e3750aa3f55b5f7
7
+ data.tar.gz: 512a4e960252f726b0a50348d7ceab9f6bbc13247aab649f17a48d9d3d1b58cf489141b9095b24cebabd0d39546196a5bf454e3e8f51389b652b485e16218a6b
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,9 +190,9 @@ 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
195
+ listeners[local_address] ||= Listener.new(Addrinfo.tcp(local_ip, local_port), 0, 0)
188
196
  # rx_queue shows number of connections waiting to be accepted
189
197
  listeners[local_address].queue_size = rx_queue_hex.to_i(16)
190
198
  listeners[local_address].active_connections = 0
@@ -218,7 +226,7 @@ class IO
218
226
  # Parse /proc/net/unix and extract listener statistics for Unix domain sockets.
219
227
  # @parameter paths [Array(String) | Nil] Optional filter for specific socket paths.
220
228
  # @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.
229
+ # @returns [Array(Listener)] One entry per socket path with any matching activity.
222
230
  def self.capture_unix(paths = nil, file: "/proc/net/unix")
223
231
  listeners = {}
224
232
  path_filter = paths ? paths.to_set : nil
@@ -246,7 +254,7 @@ class IO
246
254
 
247
255
  state = state_hex.to_i(16)
248
256
 
249
- listeners[path] ||= Listener.zero
257
+ listeners[path] ||= Listener.new(Addrinfo.unix(path), 0, 0)
250
258
 
251
259
  case state
252
260
  when SS_CONNECTING # Queued connections
@@ -256,37 +264,37 @@ class IO
256
264
  end
257
265
  end
258
266
 
259
- return listeners
267
+ return listeners.values
260
268
  rescue Errno::ENOENT, Errno::EACCES
261
- return {}
269
+ return []
262
270
  end
263
271
 
264
272
  # Parse /proc/net/tcp and /proc/net/tcp6 and extract listener statistics.
265
273
  # @parameter addresses [Array(String) | Nil] Optional filter for specific addresses.
266
- # @returns [Hash(String, Listener)] Hash mapping "ip:port" or "[ipv6]:port" to Listener.
274
+ # @returns [Array(Listener)] TCP listeners from both stacks.
267
275
  def self.capture_tcp(addresses = nil)
268
276
  listeners = {}
269
277
 
270
278
  # Capture IPv4 listeners and connections
271
279
  if File.readable?("/proc/net/tcp")
272
- listeners.merge!(capture_tcp_file("/proc/net/tcp", addresses, ipv6: false))
280
+ listeners.merge!(gather_tcp_file("/proc/net/tcp", addresses, ipv6: false))
273
281
  end
274
282
 
275
283
  # Capture IPv6 listeners and connections
276
284
  if File.readable?("/proc/net/tcp6")
277
- listeners.merge!(capture_tcp_file("/proc/net/tcp6", addresses, ipv6: true))
285
+ listeners.merge!(gather_tcp_file("/proc/net/tcp6", addresses, ipv6: true))
278
286
  end
279
287
 
280
- return listeners
288
+ return listeners.values
281
289
  end
282
290
 
283
291
  # Capture listener listeners for TCP and/or Unix domain sockets.
284
292
  # @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
293
  # @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
294
  # @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.
295
+ # @returns [Array(Listener)] All matching listeners (TCP and/or Unix).
288
296
  def self.capture(addresses: nil, paths: nil, unix_file: "/proc/net/unix")
289
- listeners = {}
297
+ result = []
290
298
 
291
299
  # If addresses are specified but paths is nil, don't capture Unix sockets
292
300
  # Only capture Unix sockets if paths is explicitly provided or addresses is nil
@@ -294,12 +302,12 @@ class IO
294
302
  unix_paths = paths.nil? && !addresses.nil? ? :skip : paths
295
303
 
296
304
  # Capture TCP listeners (only if not skipped)
297
- listeners.merge!(capture_tcp(tcp_addresses)) unless tcp_addresses == :skip
305
+ result.concat(capture_tcp(tcp_addresses)) unless tcp_addresses == :skip
298
306
 
299
307
  # Capture Unix domain socket listeners (only if not skipped)
300
- listeners.merge!(capture_unix(unix_paths, file: unix_file)) unless unix_paths == :skip
308
+ result.concat(capture_unix(unix_paths, file: unix_file)) unless unix_paths == :skip
301
309
 
302
- return listeners
310
+ return result
303
311
  end
304
312
  end
305
313
  end
@@ -317,7 +325,7 @@ if IO::Metrics::Listener::Linux.supported?
317
325
  # Capture listener listeners for the given address(es).
318
326
  # @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
327
  # @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.
328
+ # @returns [Array(Listener) | Nil] Captured listeners, or nil if not supported.
321
329
  def capture(**options)
322
330
  IO::Metrics::Listener::Linux.capture(**options)
323
331
  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.0"
11
11
  end
12
12
  end
data/readme.md CHANGED
@@ -10,6 +10,15 @@ 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.0
18
+
19
+ - **Breaking** `IO::Metrics::Listener.capture` returns an `Array` of `Listener` rows instead of a `Hash` keyed by address string.
20
+ - 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.
21
+
13
22
  ## Contributing
14
23
 
15
24
  We welcome contributions to this project.
@@ -20,6 +29,22 @@ We welcome contributions to this project.
20
29
  4. Push to the branch (`git push origin my-new-feature`).
21
30
  5. Create new Pull Request.
22
31
 
32
+ ### Running Tests
33
+
34
+ To run the test suite:
35
+
36
+ ``` shell
37
+ bundle exec sus
38
+ ```
39
+
40
+ ### Making Releases
41
+
42
+ To make a new release:
43
+
44
+ ``` shell
45
+ bundle exec bake gem:release:patch # or minor or major
46
+ ```
47
+
23
48
  ### Developer Certificate of Origin
24
49
 
25
50
  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,6 @@
1
+ # Releases
2
+
3
+ ## v0.2.0
4
+
5
+ - **Breaking** `IO::Metrics::Listener.capture` returns an `Array` of `Listener` rows instead of a `Hash` keyed by address string.
6
+ - 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.0
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