io-metrics 0.1.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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/lib/io/metrics/listener/darwin.rb +117 -0
- data/lib/io/metrics/listener/linux.rb +325 -0
- data/lib/io/metrics/listener.rb +47 -0
- data/lib/io/metrics/version.rb +12 -0
- data/lib/io/metrics.rb +7 -0
- data/license.md +21 -0
- data/readme.md +29 -0
- data.tar.gz.sig +0 -0
- metadata +104 -0
- metadata.gz.sig +0 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ff485ebbe44dc9bae2117fa771ed89e7b2e42e3270d93094bae2fa4808405a5d
|
|
4
|
+
data.tar.gz: e724a731428b1c7d5219f6abcb518b99cddd572efcf0d9de02eff1d30f90400e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 27ef4c0c0f75b3933bbd7cf6815997bf62d3d3cc05c6dbe0ca9e75a3db143f6fb122153d4d7b967fd2b04781b0c12dc1158a7998178cee6bc954d7c7e576a3b6
|
|
7
|
+
data.tar.gz: c1bfed9052fb9fbf92f25bf9952eb83d3dc0759bb55746cca68068942d772a145c43659888045315b58ba9b7c4b4209ce196889b21d0c0d17c4b7cd6cacff5b4
|
checksums.yaml.gz.sig
ADDED
|
Binary file
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
class IO
|
|
7
|
+
module Metrics
|
|
8
|
+
# Darwin (macOS) implementation of listener statistics using netstat -L.
|
|
9
|
+
class Listener::Darwin
|
|
10
|
+
NETSTAT = "/usr/sbin/netstat"
|
|
11
|
+
|
|
12
|
+
# Whether listener listeners can be captured on this system.
|
|
13
|
+
def self.supported?
|
|
14
|
+
File.executable?(NETSTAT)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Parse an address from netstat format to "ip:port" format.
|
|
18
|
+
# @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".
|
|
20
|
+
def self.parse_address(address)
|
|
21
|
+
# Handle wildcard addresses: *.port -> 0.0.0.0:port
|
|
22
|
+
if address.start_with?("*.")
|
|
23
|
+
port = address[2..-1]
|
|
24
|
+
return "0.0.0.0:#{port}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Handle IPv4 addresses: ip.port -> ip:port
|
|
28
|
+
if address =~ /^([0-9.]+)\.(\d+)$/
|
|
29
|
+
ip = $1
|
|
30
|
+
port = $2
|
|
31
|
+
return "#{ip}:#{port}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Handle IPv6 addresses (if present in future)
|
|
35
|
+
# For now, return as-is
|
|
36
|
+
return address
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parse netstat -L output and extract listener statistics.
|
|
40
|
+
# @parameter addresses [Array(String) | Nil] Optional filter for specific addresses.
|
|
41
|
+
# @returns [Hash(String, Listener)] Hash mapping "ip:port" to Listener.
|
|
42
|
+
def self.capture_tcp(addresses = nil)
|
|
43
|
+
listeners = {}
|
|
44
|
+
address_filter = addresses ? addresses.map{|address| address.downcase}.to_set : nil
|
|
45
|
+
|
|
46
|
+
IO.popen([NETSTAT, "-L", "-an", "-p", "tcp"], "r") do |io|
|
|
47
|
+
# Skip header lines
|
|
48
|
+
io.each_line do |line|
|
|
49
|
+
# Skip header and empty lines
|
|
50
|
+
next if line.start_with?("Current") || line.start_with?("Listen") || line.strip.empty?
|
|
51
|
+
|
|
52
|
+
# Format: "queue_length/incomplete_queue_length/maximum_queue_length Local Address"
|
|
53
|
+
fields = line.split(/\s+/)
|
|
54
|
+
next if fields.size < 2
|
|
55
|
+
|
|
56
|
+
queue_statistics = fields[0]
|
|
57
|
+
local_address_raw = fields[1]
|
|
58
|
+
|
|
59
|
+
# Parse queue statistics: "queue_length/incomplete_queue_length/maximum_queue_length"
|
|
60
|
+
if queue_statistics =~ /^(\d+)\/(\d+)\/(\d+)$/
|
|
61
|
+
queue_length = $1.to_i
|
|
62
|
+
# incomplete_queue_length = $2.to_i # incomplete connections (SYN_RECV)
|
|
63
|
+
# maximum_queue_length = $3.to_i # maximum queue size
|
|
64
|
+
|
|
65
|
+
# Parse address
|
|
66
|
+
address = parse_address(local_address_raw)
|
|
67
|
+
|
|
68
|
+
# Apply filter if specified
|
|
69
|
+
next if address_filter && !address_filter.include?(address)
|
|
70
|
+
|
|
71
|
+
listeners[address] ||= Listener.zero
|
|
72
|
+
listeners[address].queue_size = queue_length
|
|
73
|
+
# active_connections set to 0 (can't reliably count per listener)
|
|
74
|
+
listeners[address].active_connections = 0
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
return listeners
|
|
80
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
81
|
+
return {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Capture listener listeners for TCP sockets.
|
|
85
|
+
# @parameter addresses [Array(String) | Nil] TCP address(es) to capture, e.g. ["0.0.0.0:80"]. If nil, captures all.
|
|
86
|
+
# @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.
|
|
88
|
+
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
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Wire Listener.capture and Listener.supported? to this implementation on Darwin.
|
|
101
|
+
if IO::Metrics::Listener::Darwin.supported?
|
|
102
|
+
class << IO::Metrics::Listener
|
|
103
|
+
# Whether listener capture is supported on this platform.
|
|
104
|
+
# @returns [Boolean] True if netstat is executable.
|
|
105
|
+
def supported?
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Capture listener listeners for the given address(es).
|
|
110
|
+
# @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
|
+
# @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.
|
|
113
|
+
def capture(**options)
|
|
114
|
+
IO::Metrics::Listener::Darwin.capture(**options)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "set"
|
|
7
|
+
require "ipaddr"
|
|
8
|
+
|
|
9
|
+
class IO
|
|
10
|
+
module Metrics
|
|
11
|
+
# Linux implementation of listener statistics using /proc/net/tcp, /proc/net/tcp6, and /proc/net/unix.
|
|
12
|
+
class Listener::Linux
|
|
13
|
+
# TCP socket states (from include/net/tcp_states.h)
|
|
14
|
+
TCP_ESTABLISHED = 0x01
|
|
15
|
+
TCP_SYN_SENT = 0x02
|
|
16
|
+
TCP_SYN_RECV = 0x03
|
|
17
|
+
TCP_FIN_WAIT1 = 0x04
|
|
18
|
+
TCP_FIN_WAIT2 = 0x05
|
|
19
|
+
TCP_TIME_WAIT = 0x06
|
|
20
|
+
TCP_CLOSE = 0x07
|
|
21
|
+
TCP_CLOSE_WAIT = 0x08
|
|
22
|
+
TCP_LAST_ACK = 0x09
|
|
23
|
+
TCP_LISTEN = 0x0A
|
|
24
|
+
TCP_CLOSING = 0x0B
|
|
25
|
+
|
|
26
|
+
# Unix socket states (from include/uapi/linux/net.h)
|
|
27
|
+
SS_UNCONNECTED = 0x01
|
|
28
|
+
SS_CONNECTING = 0x02
|
|
29
|
+
SS_CONNECTED = 0x03
|
|
30
|
+
|
|
31
|
+
# Regex pattern for parsing /proc/net/tcp and /proc/net/tcp6 lines.
|
|
32
|
+
# Captures: local_ip, local_port, remote_ip, remote_port, state, tx_queue, rx_queue
|
|
33
|
+
TCP_LINE_PATTERN = /\A\s*\d+:\s+([0-9A-Fa-f]+):([0-9A-Fa-f]+)\s+([0-9A-Fa-f]+):([0-9A-Fa-f]+)\s+([0-9A-Fa-f]+)\s+([0-9A-Fa-f]+):([0-9A-Fa-f]+)/
|
|
34
|
+
|
|
35
|
+
# Whether listener listeners can be captured on this system.
|
|
36
|
+
def self.supported?
|
|
37
|
+
File.readable?("/proc/net/tcp")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Parse an IPv4 address from /proc/net/tcp format (hex, little-endian).
|
|
41
|
+
# @parameter hex [String] Hexadecimal address string, e.g. "0100007F" for 127.0.0.1.
|
|
42
|
+
# @returns [String] IP address in dotted decimal format, e.g. "127.0.0.1".
|
|
43
|
+
def self.parse_ipv4(hex)
|
|
44
|
+
raise ArgumentError, "Invalid IPv4 hex format: #{hex.inspect}" unless hex =~ /\A[0-9A-Fa-f]{8}\z/
|
|
45
|
+
|
|
46
|
+
# Each byte is 2 hex chars, read in reverse order (little-endian)
|
|
47
|
+
bytes = hex.scan(/../).reverse.map{|byte| byte.to_i(16)}
|
|
48
|
+
bytes.join(".")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Parse an IPv6 address from /proc/net/tcp6 format (hex, little-endian).
|
|
52
|
+
# @parameter hex [String] Hexadecimal address string, 32 hex chars (16 bytes).
|
|
53
|
+
# @returns [String] IP address in compressed IPv6 format, e.g. "::1" or "2001:db8::1".
|
|
54
|
+
def self.parse_ipv6(hex)
|
|
55
|
+
raise ArgumentError, "Invalid IPv6 hex format: #{hex.inspect}" unless hex =~ /\A[0-9A-Fa-f]{32}\z/
|
|
56
|
+
|
|
57
|
+
# IPv6 is 16 bytes (32 hex chars) stored as 4-byte words in little-endian format
|
|
58
|
+
# Split into 4-byte words (8 hex chars each) and reverse bytes within each word
|
|
59
|
+
words = hex.scan(/.{8}/)
|
|
60
|
+
|
|
61
|
+
reversed_words = words.map do |word|
|
|
62
|
+
word.scan(/../).reverse.join("")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Convert to 16-bit segments and create colon-separated format
|
|
66
|
+
segments = reversed_words.flat_map do |word|
|
|
67
|
+
[word[0..3], word[4..7]]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
ipv6_expanded = segments.join(":")
|
|
71
|
+
|
|
72
|
+
# Use IPAddr to compress the address
|
|
73
|
+
IPAddr.new(ipv6_expanded).to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Parse a port from /proc/net/tcp format (hex).
|
|
77
|
+
# @parameter hex [String] Hexadecimal port string, e.g. "0050" for port 80.
|
|
78
|
+
# @returns [Integer] Port number.
|
|
79
|
+
def self.parse_port(hex)
|
|
80
|
+
hex.to_i(16)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Parse a socket state from /proc/net/tcp format.
|
|
84
|
+
# @parameter hex [String] Hexadecimal state string.
|
|
85
|
+
# @returns [Symbol] Socket state (:listen, :established, etc.)
|
|
86
|
+
def self.parse_state(hex)
|
|
87
|
+
state = hex.to_i(16)
|
|
88
|
+
case state
|
|
89
|
+
when TCP_LISTEN then :listen
|
|
90
|
+
when TCP_ESTABLISHED then :established
|
|
91
|
+
when TCP_SYN_SENT then :syn_sent
|
|
92
|
+
when TCP_SYN_RECV then :syn_recv
|
|
93
|
+
when TCP_FIN_WAIT1 then :fin_wait1
|
|
94
|
+
when TCP_FIN_WAIT2 then :fin_wait2
|
|
95
|
+
when TCP_TIME_WAIT then :time_wait
|
|
96
|
+
when TCP_CLOSE then :close
|
|
97
|
+
when TCP_CLOSE_WAIT then :close_wait
|
|
98
|
+
when TCP_LAST_ACK then :last_ack
|
|
99
|
+
when TCP_CLOSING then :closing
|
|
100
|
+
else :unknown
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Find the best matching listener for an ESTABLISHED connection.
|
|
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.
|
|
107
|
+
# @returns [String | Nil] The address of the matching listener, or nil if no match.
|
|
108
|
+
def self.find_matching_listener(local_address, listeners)
|
|
109
|
+
# Try exact match first
|
|
110
|
+
return local_address if listeners.key?(local_address)
|
|
111
|
+
|
|
112
|
+
# Parse the address to extract IP and port
|
|
113
|
+
if local_address.start_with?("[")
|
|
114
|
+
# IPv6 format: [::1]:port
|
|
115
|
+
if match = local_address.match(/\A\[(.+)\]:(\d+)\z/)
|
|
116
|
+
local_ip = $1
|
|
117
|
+
local_port = $2
|
|
118
|
+
else
|
|
119
|
+
return nil
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
# IPv4 format: 127.0.0.1:port
|
|
123
|
+
local_ip, local_port = local_address.split(":", 2)
|
|
124
|
+
return nil unless local_port
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Determine address type using IPAddr for robust detection
|
|
128
|
+
begin
|
|
129
|
+
ip_address = IPAddr.new(local_ip)
|
|
130
|
+
|
|
131
|
+
if ip_address.ipv4?
|
|
132
|
+
# Try IPv4 wildcard match (0.0.0.0:port)
|
|
133
|
+
wildcard_address = "0.0.0.0:#{local_port}"
|
|
134
|
+
return wildcard_address if listeners.key?(wildcard_address)
|
|
135
|
+
else
|
|
136
|
+
# Try IPv6 wildcard match ([::]:port)
|
|
137
|
+
wildcard_address = "[::]:#{local_port}"
|
|
138
|
+
return wildcard_address if listeners.key?(wildcard_address)
|
|
139
|
+
end
|
|
140
|
+
rescue IPAddr::InvalidAddressError
|
|
141
|
+
# If IP parsing fails, return nil
|
|
142
|
+
return nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Parse /proc/net/tcp or /proc/net/tcp6 and extract listener statistics (optimized single-pass).
|
|
149
|
+
# @parameter file [String] Path to /proc/net/tcp or /proc/net/tcp6.
|
|
150
|
+
# @parameter addresses [Array(String) | Nil] Optional filter for specific addresses.
|
|
151
|
+
# @parameter ipv6 [Boolean] Whether parsing IPv6 addresses.
|
|
152
|
+
# @returns [Hash(String, Listener)] Hash mapping "ip:port" or "[ipv6]:port" to Listener.
|
|
153
|
+
def self.capture_tcp_file(file, addresses = nil, ipv6: false)
|
|
154
|
+
listeners = {}
|
|
155
|
+
address_filter = addresses ? addresses.map{|address| address.downcase}.to_set : nil
|
|
156
|
+
connections = []
|
|
157
|
+
|
|
158
|
+
# Single pass: collect LISTEN sockets and ESTABLISHED connections
|
|
159
|
+
File.foreach(file) do |line|
|
|
160
|
+
next if line.start_with?("sl")
|
|
161
|
+
|
|
162
|
+
if match = TCP_LINE_PATTERN.match(line)
|
|
163
|
+
local_ip_hex = match[1]
|
|
164
|
+
local_port_hex = match[2]
|
|
165
|
+
remote_ip_hex = match[3]
|
|
166
|
+
remote_port_hex = match[4]
|
|
167
|
+
state_hex = match[5]
|
|
168
|
+
tx_queue_hex = match[6]
|
|
169
|
+
rx_queue_hex = match[7]
|
|
170
|
+
|
|
171
|
+
state = parse_state(state_hex)
|
|
172
|
+
|
|
173
|
+
# Process LISTEN sockets
|
|
174
|
+
if state == :listen
|
|
175
|
+
if ipv6
|
|
176
|
+
local_ip = parse_ipv6(local_ip_hex)
|
|
177
|
+
local_address = "[#{local_ip}]:#{parse_port(local_port_hex)}"
|
|
178
|
+
else
|
|
179
|
+
local_ip = parse_ipv4(local_ip_hex)
|
|
180
|
+
local_port = parse_port(local_port_hex)
|
|
181
|
+
local_address = "#{local_ip}:#{local_port}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Apply filter if specified
|
|
185
|
+
next if address_filter && !address_filter.include?(local_address)
|
|
186
|
+
|
|
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)
|
|
190
|
+
listeners[local_address].active_connections = 0
|
|
191
|
+
# Collect ESTABLISHED connections to count later
|
|
192
|
+
elsif state == :established
|
|
193
|
+
if ipv6
|
|
194
|
+
local_ip = parse_ipv6(local_ip_hex)
|
|
195
|
+
local_address = "[#{local_ip}]:#{parse_port(local_port_hex)}"
|
|
196
|
+
else
|
|
197
|
+
local_ip = parse_ipv4(local_ip_hex)
|
|
198
|
+
local_port = parse_port(local_port_hex)
|
|
199
|
+
local_address = "#{local_ip}:#{local_port}"
|
|
200
|
+
end
|
|
201
|
+
connections << local_address
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Count ESTABLISHED connections for each listener
|
|
207
|
+
connections.each do |local_address|
|
|
208
|
+
if listener_address = find_matching_listener(local_address, listeners)
|
|
209
|
+
listeners[listener_address].active_connections += 1
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
return listeners
|
|
214
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
215
|
+
return {}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Parse /proc/net/unix and extract listener statistics for Unix domain sockets.
|
|
219
|
+
# @parameter paths [Array(String) | Nil] Optional filter for specific socket paths.
|
|
220
|
+
# @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.
|
|
222
|
+
def self.capture_unix(paths = nil, file: "/proc/net/unix")
|
|
223
|
+
listeners = {}
|
|
224
|
+
path_filter = paths ? paths.to_set : nil
|
|
225
|
+
|
|
226
|
+
File.foreach(file) do |line|
|
|
227
|
+
line = line.strip
|
|
228
|
+
next if line.start_with?("Num")
|
|
229
|
+
|
|
230
|
+
# Format: Num RefCount Protocol Flags Type St Inode Path
|
|
231
|
+
# Example (stripped): "00000000cf265b54: 00000003 00000000 00000000 0001 03 18324 /run/user/1000/wayland-0"
|
|
232
|
+
# After splitting by whitespace:
|
|
233
|
+
# [0] = "00000000cf265b54:", [1] = RefCount, [2] = Protocol, [3] = Flags, [4] = Type, [5] = St, [6] = Inode, [7+] = Path
|
|
234
|
+
fields = line.split(/\s+/)
|
|
235
|
+
next if fields.size < 7
|
|
236
|
+
|
|
237
|
+
# State field is at index 5 (St)
|
|
238
|
+
# 01 = SS_UNCONNECTED (listening), 02 = SS_CONNECTING (queued), 03 = SS_CONNECTED (active)
|
|
239
|
+
state_hex = fields[5]
|
|
240
|
+
# Path starts at index 7
|
|
241
|
+
path = fields[7..-1]&.join(" ") || ""
|
|
242
|
+
|
|
243
|
+
# Apply filter if specified
|
|
244
|
+
next if path_filter && !path_filter.include?(path)
|
|
245
|
+
next if path.empty?
|
|
246
|
+
|
|
247
|
+
state = state_hex.to_i(16)
|
|
248
|
+
|
|
249
|
+
listeners[path] ||= Listener.zero
|
|
250
|
+
|
|
251
|
+
case state
|
|
252
|
+
when SS_CONNECTING # Queued connections
|
|
253
|
+
listeners[path].queue_size += 1
|
|
254
|
+
when SS_CONNECTED # Active connections
|
|
255
|
+
listeners[path].active_connections += 1
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
return listeners
|
|
260
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
261
|
+
return {}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Parse /proc/net/tcp and /proc/net/tcp6 and extract listener statistics.
|
|
265
|
+
# @parameter addresses [Array(String) | Nil] Optional filter for specific addresses.
|
|
266
|
+
# @returns [Hash(String, Listener)] Hash mapping "ip:port" or "[ipv6]:port" to Listener.
|
|
267
|
+
def self.capture_tcp(addresses = nil)
|
|
268
|
+
listeners = {}
|
|
269
|
+
|
|
270
|
+
# Capture IPv4 listeners and connections
|
|
271
|
+
if File.readable?("/proc/net/tcp")
|
|
272
|
+
listeners.merge!(capture_tcp_file("/proc/net/tcp", addresses, ipv6: false))
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Capture IPv6 listeners and connections
|
|
276
|
+
if File.readable?("/proc/net/tcp6")
|
|
277
|
+
listeners.merge!(capture_tcp_file("/proc/net/tcp6", addresses, ipv6: true))
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
return listeners
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Capture listener listeners for TCP and/or Unix domain sockets.
|
|
284
|
+
# @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
|
+
# @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
|
+
# @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.
|
|
288
|
+
def self.capture(addresses: nil, paths: nil, unix_file: "/proc/net/unix")
|
|
289
|
+
listeners = {}
|
|
290
|
+
|
|
291
|
+
# If addresses are specified but paths is nil, don't capture Unix sockets
|
|
292
|
+
# Only capture Unix sockets if paths is explicitly provided or addresses is nil
|
|
293
|
+
tcp_addresses = addresses.nil? && !paths.nil? ? :skip : addresses
|
|
294
|
+
unix_paths = paths.nil? && !addresses.nil? ? :skip : paths
|
|
295
|
+
|
|
296
|
+
# Capture TCP listeners (only if not skipped)
|
|
297
|
+
listeners.merge!(capture_tcp(tcp_addresses)) unless tcp_addresses == :skip
|
|
298
|
+
|
|
299
|
+
# Capture Unix domain socket listeners (only if not skipped)
|
|
300
|
+
listeners.merge!(capture_unix(unix_paths, file: unix_file)) unless unix_paths == :skip
|
|
301
|
+
|
|
302
|
+
return listeners
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Wire Listener.capture and Listener.supported? to this implementation on Linux.
|
|
309
|
+
if IO::Metrics::Listener::Linux.supported?
|
|
310
|
+
class << IO::Metrics::Listener
|
|
311
|
+
# Whether listener capture is supported on this platform.
|
|
312
|
+
# @returns [Boolean] True if /proc/net/tcp is readable.
|
|
313
|
+
def supported?
|
|
314
|
+
true
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Capture listener listeners for the given address(es).
|
|
318
|
+
# @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
|
+
# @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.
|
|
321
|
+
def capture(**options)
|
|
322
|
+
IO::Metrics::Listener::Linux.capture(**options)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
class IO
|
|
9
|
+
module Metrics
|
|
10
|
+
# Represents a network listener socket with its queue statistics.
|
|
11
|
+
# @attribute queue_size [Integer] Number of connections waiting to be accepted (queued).
|
|
12
|
+
# @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
|
|
15
|
+
|
|
16
|
+
# Convert the object to a JSON string.
|
|
17
|
+
def to_json(*arguments)
|
|
18
|
+
as_json.to_json(*arguments)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Create a zero-initialized Listener instance.
|
|
22
|
+
# @returns [Listener] A new Listener object with all fields set to zero.
|
|
23
|
+
def self.zero
|
|
24
|
+
self.new(0, 0)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Whether listener stats can be captured on this system.
|
|
28
|
+
def self.supported?
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Capture listener stats for the given address(es).
|
|
33
|
+
# @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
|
+
# @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.
|
|
36
|
+
def self.capture(**options)
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if RUBY_PLATFORM.include?("linux")
|
|
44
|
+
require_relative "listener/linux"
|
|
45
|
+
elsif RUBY_PLATFORM.include?("darwin")
|
|
46
|
+
require_relative "listener/darwin"
|
|
47
|
+
end
|
data/lib/io/metrics.rb
ADDED
data/license.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright, 2026, by Samuel Williams.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/readme.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# IO::Metrics
|
|
2
|
+
|
|
3
|
+
Extract I/O metrics from the host system, specifically listen queue statistics.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/socketry/io-metrics/actions?workflow=Test)
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Please see the [project documentation](https://socketry.github.io/io-metrics/) for more details.
|
|
10
|
+
|
|
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
|
+
|
|
13
|
+
## Contributing
|
|
14
|
+
|
|
15
|
+
We welcome contributions to this project.
|
|
16
|
+
|
|
17
|
+
1. Fork it.
|
|
18
|
+
2. Create your feature branch (`git checkout -b my-new-feature`).
|
|
19
|
+
3. Commit your changes (`git commit -am 'Add some feature'`).
|
|
20
|
+
4. Push to the branch (`git push origin my-new-feature`).
|
|
21
|
+
5. Create new Pull Request.
|
|
22
|
+
|
|
23
|
+
### Developer Certificate of Origin
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
### Community Guidelines
|
|
28
|
+
|
|
29
|
+
This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
|
data.tar.gz.sig
ADDED
|
Binary file
|
metadata
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: io-metrics
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Samuel Williams
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain:
|
|
10
|
+
- |
|
|
11
|
+
-----BEGIN CERTIFICATE-----
|
|
12
|
+
MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11
|
|
13
|
+
ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK
|
|
14
|
+
CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz
|
|
15
|
+
MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd
|
|
16
|
+
MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj
|
|
17
|
+
bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB
|
|
18
|
+
igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2
|
|
19
|
+
9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW
|
|
20
|
+
sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE
|
|
21
|
+
e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN
|
|
22
|
+
XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss
|
|
23
|
+
RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn
|
|
24
|
+
tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM
|
|
25
|
+
zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW
|
|
26
|
+
xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O
|
|
27
|
+
BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs
|
|
28
|
+
aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs
|
|
29
|
+
aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE
|
|
30
|
+
cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl
|
|
31
|
+
xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/
|
|
32
|
+
c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp
|
|
33
|
+
8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws
|
|
34
|
+
JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP
|
|
35
|
+
eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt
|
|
36
|
+
Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
|
|
37
|
+
voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
|
|
38
|
+
-----END CERTIFICATE-----
|
|
39
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
40
|
+
dependencies:
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: console
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.8'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.8'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: json
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '2'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '2'
|
|
69
|
+
executables: []
|
|
70
|
+
extensions: []
|
|
71
|
+
extra_rdoc_files: []
|
|
72
|
+
files:
|
|
73
|
+
- lib/io/metrics.rb
|
|
74
|
+
- lib/io/metrics/listener.rb
|
|
75
|
+
- lib/io/metrics/listener/darwin.rb
|
|
76
|
+
- lib/io/metrics/listener/linux.rb
|
|
77
|
+
- lib/io/metrics/version.rb
|
|
78
|
+
- license.md
|
|
79
|
+
- readme.md
|
|
80
|
+
homepage: https://github.com/socketry/io-metrics
|
|
81
|
+
licenses:
|
|
82
|
+
- MIT
|
|
83
|
+
metadata:
|
|
84
|
+
documentation_uri: https://socketry.github.io/io-metrics/
|
|
85
|
+
funding_uri: https://github.com/sponsors/ioquatix
|
|
86
|
+
source_code_uri: https://github.com/socketry/io-metrics.git
|
|
87
|
+
rdoc_options: []
|
|
88
|
+
require_paths:
|
|
89
|
+
- lib
|
|
90
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '3.2'
|
|
95
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '0'
|
|
100
|
+
requirements: []
|
|
101
|
+
rubygems_version: 4.0.3
|
|
102
|
+
specification_version: 4
|
|
103
|
+
summary: Extract I/O metrics from the host system.
|
|
104
|
+
test_files: []
|
metadata.gz.sig
ADDED
|
Binary file
|