promproto 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 871334ffa068b6468e54037c4f9ec27afb7d0d58f876250664a5448a33024317
4
+ data.tar.gz: e09172bef789aaf9e87badebfde3b5e0ec807d137642c9029f01fd47a2c19087
5
+ SHA512:
6
+ metadata.gz: 64ec3528751a00e64f98540acd3847d750bd3566abe8b084ee6b9e9378e77e75d07639419202ca0feba596ee21da1cd8ac8689daddfb9a190c229f3fba4d94fc
7
+ data.tar.gz: 589923e4bafcac52eea6536a1d17fd510701195cb6df5e1b78326d787851265610dd64249191eceee14d222be5ecbb38bd569d8cdc028894c798172c484443a4
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Lewis Buckley
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Promproto
2
+
3
+ A command-line tool to fetch and display Prometheus metrics in protobuf format.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ gem install promproto
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ # Fetch metrics from a target
15
+ promproto localhost:9394
16
+
17
+ # Watch mode - continuously refresh
18
+ promproto -w localhost:9394
19
+
20
+ # Custom refresh interval (in seconds)
21
+ promproto -w -n 5 localhost:9394
22
+
23
+ # Full URL
24
+ promproto http://localhost:9394/metrics
25
+ ```
26
+
27
+ ## Features
28
+
29
+ - Fetches metrics using the Prometheus protobuf exposition format
30
+ - Color-coded output for easy reading
31
+ - Supports both classic and native histograms
32
+ - Watch mode for continuous monitoring
33
+ - Automatic URL normalization (adds http:// and /metrics if missing)
34
+
35
+ ## Native Histograms
36
+
37
+ When the server exports native histograms, promproto displays:
38
+ - Schema and zero threshold
39
+ - Zero bucket count
40
+ - Positive and negative bucket spans with computed bounds
41
+ - Visual bar charts for bucket counts
42
+
43
+ ## Requirements
44
+
45
+ - Ruby 3.1+
46
+ - google-protobuf gem
47
+
48
+ ## License
49
+
50
+ MIT
data/exe/promproto ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require_relative "../lib/promproto"
6
+
7
+ options = { watch: false, interval: Promproto::CLI::DEFAULT_WATCH_INTERVAL }
8
+
9
+ parser = OptionParser.new do |opts|
10
+ opts.banner = "Usage: promproto [options] <target>"
11
+ opts.separator ""
12
+ opts.separator "Fetches Prometheus metrics in protobuf format and renders them."
13
+ opts.separator ""
14
+ opts.separator "Target can be:"
15
+ opts.separator " - Full URL: http://localhost:9090/metrics"
16
+ opts.separator " - Host:port: localhost:9090 (assumes http:// and /metrics)"
17
+ opts.separator " - Host only: localhost (assumes http://, port 80, and /metrics)"
18
+ opts.separator ""
19
+ opts.separator "Options:"
20
+
21
+ opts.on("-w", "--watch", "Continuously fetch and display metrics") do
22
+ options[:watch] = true
23
+ end
24
+
25
+ opts.on("-n", "--interval SECONDS", Integer, "Refresh interval in seconds (default: #{Promproto::CLI::DEFAULT_WATCH_INTERVAL})") do |n|
26
+ options[:interval] = n
27
+ end
28
+
29
+ opts.on("-h", "--help", "Show this help message") do
30
+ puts opts
31
+ exit
32
+ end
33
+
34
+ opts.on("-v", "--version", "Show version") do
35
+ puts "promproto #{Promproto::VERSION}"
36
+ exit
37
+ end
38
+ end
39
+
40
+ begin
41
+ parser.parse!
42
+ rescue OptionParser::InvalidOption => e
43
+ abort "#{e.message}\nUse --help for usage information."
44
+ end
45
+
46
+ if ARGV.empty?
47
+ puts parser
48
+ exit 1
49
+ end
50
+
51
+ url = Promproto.normalize_url(ARGV[0])
52
+ Promproto::CLI.new(url, watch: options[:watch], interval: options[:interval]).run
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Promproto
7
+ class CLI
8
+ ACCEPT_HEADER = "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited"
9
+ DEFAULT_WATCH_INTERVAL = 2
10
+
11
+ def initialize(url, watch: false, interval: DEFAULT_WATCH_INTERVAL)
12
+ @url = url
13
+ @watch = watch
14
+ @interval = interval
15
+ end
16
+
17
+ def run
18
+ if @watch
19
+ run_watch
20
+ else
21
+ run_once
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def run_once
28
+ data = fetch_metrics
29
+ families = parse_delimited(data)
30
+ render(families)
31
+ rescue Errno::ECONNREFUSED => e
32
+ abort "Error: Connection refused to #{@url}\n\nMake sure the metrics server is running and accessible."
33
+ rescue Errno::ETIMEDOUT, Net::OpenTimeout
34
+ abort "Error: Connection timed out to #{@url}\n\nThe server may be unreachable or behind a firewall."
35
+ rescue SocketError => e
36
+ abort "Error: Could not resolve host for #{@url}\n\n#{e.message}"
37
+ rescue Net::ReadTimeout
38
+ abort "Error: Read timeout waiting for response from #{@url}"
39
+ rescue StandardError => e
40
+ abort "Error: #{e.message}"
41
+ end
42
+
43
+ def run_watch
44
+ loop do
45
+ print "\e[2J\e[H" # Clear screen and move cursor to top
46
+ puts "\e[90m#{Time.now.strftime("%Y-%m-%d %H:%M:%S")} - #{@url}\e[0m"
47
+ puts
48
+
49
+ begin
50
+ data = fetch_metrics
51
+ families = parse_delimited(data)
52
+ render(families)
53
+ rescue Errno::ECONNREFUSED
54
+ puts "\e[31mError: Connection refused\e[0m"
55
+ puts "\e[90mMake sure the metrics server is running and accessible.\e[0m"
56
+ rescue Errno::ETIMEDOUT, Net::OpenTimeout
57
+ puts "\e[31mError: Connection timed out\e[0m"
58
+ rescue SocketError => e
59
+ puts "\e[31mError: #{e.message}\e[0m"
60
+ rescue StandardError => e
61
+ puts "\e[31mError: #{e.message}\e[0m"
62
+ end
63
+
64
+ sleep @interval
65
+ end
66
+ rescue Interrupt
67
+ puts "\nStopped."
68
+ end
69
+
70
+ def fetch_metrics
71
+ uri = URI.parse(@url)
72
+ http = Net::HTTP.new(uri.host, uri.port)
73
+ http.use_ssl = uri.scheme == "https"
74
+ http.open_timeout = 10
75
+ http.read_timeout = 30
76
+
77
+ request = Net::HTTP::Get.new(uri.request_uri)
78
+ request["Accept"] = ACCEPT_HEADER
79
+
80
+ response = http.request(request)
81
+
82
+ unless response.is_a?(Net::HTTPSuccess)
83
+ raise "HTTP #{response.code} #{response.message}"
84
+ end
85
+
86
+ content_type = response["Content-Type"] || ""
87
+ unless content_type.include?("application/vnd.google.protobuf")
88
+ warn "Warning: Server returned Content-Type: #{content_type}"
89
+ warn "Expected: #{ACCEPT_HEADER}"
90
+ warn ""
91
+ end
92
+
93
+ response.body
94
+ end
95
+
96
+ def parse_delimited(data)
97
+ families = []
98
+ pos = 0
99
+
100
+ while pos < data.bytesize
101
+ # Read varint length
102
+ len, bytes_read = read_varint(data, pos)
103
+ break if len.zero? || bytes_read.zero?
104
+
105
+ pos += bytes_read
106
+
107
+ break if pos + len > data.bytesize
108
+
109
+ msg_data = data[pos, len]
110
+ begin
111
+ family = Io::Prometheus::Client::MetricFamily.decode(msg_data)
112
+ families << family
113
+ rescue Google::Protobuf::ParseError => e
114
+ warn "Warning: Failed to decode message at offset #{pos}: #{e.message}"
115
+ end
116
+
117
+ pos += len
118
+ end
119
+
120
+ families
121
+ end
122
+
123
+ def read_varint(data, pos)
124
+ value = 0
125
+ shift = 0
126
+ bytes_read = 0
127
+
128
+ loop do
129
+ return [0, 0] if pos >= data.bytesize
130
+
131
+ byte = data.getbyte(pos)
132
+ pos += 1
133
+ bytes_read += 1
134
+
135
+ value |= (byte & 0x7F) << shift
136
+
137
+ break if (byte & 0x80).zero?
138
+
139
+ shift += 7
140
+ return [0, 0] if shift > 63 # Overflow protection
141
+ end
142
+
143
+ [value, bytes_read]
144
+ end
145
+
146
+ def render(families)
147
+ families.each do |family|
148
+ render_family(family)
149
+ end
150
+ end
151
+
152
+ def render_family(family)
153
+ puts "\e[1;36m#{family.name}\e[0m \e[33m(#{type_name(family.type)})\e[0m"
154
+ puts " \e[90m#{family.help}\e[0m" unless family.help.empty?
155
+
156
+ family.metric.each do |metric|
157
+ render_metric(metric, family.type)
158
+ end
159
+ puts
160
+ end
161
+
162
+ def type_name(type)
163
+ case type
164
+ when :COUNTER then "counter"
165
+ when :GAUGE then "gauge"
166
+ when :SUMMARY then "summary"
167
+ when :HISTOGRAM then "histogram"
168
+ when :GAUGE_HISTOGRAM then "gauge_histogram"
169
+ when :UNTYPED then "untyped"
170
+ else type.to_s.downcase
171
+ end
172
+ end
173
+
174
+ def render_metric(metric, type)
175
+ labels = format_labels(metric.label)
176
+
177
+ case type
178
+ when :COUNTER
179
+ puts " #{labels} \e[32m#{metric.counter.value}\e[0m"
180
+ when :GAUGE
181
+ puts " #{labels} \e[32m#{metric.gauge.value}\e[0m"
182
+ when :SUMMARY
183
+ render_summary(metric, labels)
184
+ when :HISTOGRAM
185
+ render_histogram(metric, labels)
186
+ when :UNTYPED
187
+ puts " #{labels} \e[32m#{metric.untyped.value}\e[0m"
188
+ end
189
+ end
190
+
191
+ def format_labels(labels)
192
+ return "" if labels.empty?
193
+
194
+ pairs = labels.map { |l| "\e[35m#{l.name}\e[0m=\"\e[34m#{l.value}\e[0m\"" }
195
+ "{#{pairs.join(", ")}}"
196
+ end
197
+
198
+ def render_summary(metric, labels)
199
+ summary = metric.summary
200
+ puts " #{labels}"
201
+ puts " count: \e[32m#{summary.sample_count}\e[0m sum: \e[32m#{summary.sample_sum}\e[0m"
202
+ summary.quantile.each do |q|
203
+ puts " p#{(q.quantile * 100).to_i}: \e[32m#{q.value}\e[0m"
204
+ end
205
+ end
206
+
207
+ def render_histogram(metric, labels)
208
+ histogram = metric.histogram
209
+ puts " #{labels}"
210
+ puts " count: \e[32m#{histogram.sample_count}\e[0m sum: \e[32m#{format_number(histogram.sample_sum)}\e[0m"
211
+
212
+ # Check if this is a native histogram (has schema) or classic
213
+ if histogram.schema != 0 || histogram.positive_span.any? || histogram.negative_span.any?
214
+ render_native_histogram(histogram)
215
+ elsif histogram.bucket.any?
216
+ render_classic_histogram(histogram)
217
+ end
218
+ end
219
+
220
+ def render_native_histogram(histogram)
221
+ puts " \e[90mschema: #{histogram.schema} zero_threshold: #{histogram.zero_threshold}\e[0m"
222
+
223
+ if histogram.zero_count > 0
224
+ puts " zero: \e[32m#{histogram.zero_count}\e[0m"
225
+ end
226
+
227
+ if histogram.negative_span.any?
228
+ puts " \e[90mnegative buckets:\e[0m"
229
+ render_spans(histogram.negative_span, histogram.negative_delta, histogram.schema, negative: true)
230
+ end
231
+
232
+ if histogram.positive_span.any?
233
+ puts " \e[90mpositive buckets:\e[0m"
234
+ render_spans(histogram.positive_span, histogram.positive_delta, histogram.schema, negative: false)
235
+ end
236
+ end
237
+
238
+ def render_spans(spans, deltas, schema, negative:)
239
+ bucket_idx = 0
240
+ count = 0
241
+ delta_idx = 0
242
+
243
+ spans.each do |span|
244
+ bucket_idx += span.offset
245
+
246
+ span.length.times do
247
+ break if delta_idx >= deltas.size
248
+
249
+ count += deltas[delta_idx]
250
+ delta_idx += 1
251
+
252
+ lower, upper = bucket_bounds(bucket_idx, schema)
253
+ if negative
254
+ lower, upper = -upper, -lower
255
+ end
256
+
257
+ bar = bar_chart(count, 20)
258
+ puts " [#{format_bound(lower)}, #{format_bound(upper)}): #{bar} \e[32m#{count}\e[0m"
259
+
260
+ bucket_idx += 1
261
+ end
262
+ end
263
+ end
264
+
265
+ def bucket_bounds(index, schema)
266
+ base = 2.0 ** (2.0 ** -schema)
267
+ lower = base ** index
268
+ upper = base ** (index + 1)
269
+ [lower, upper]
270
+ end
271
+
272
+ def render_classic_histogram(histogram)
273
+ puts " \e[90mclassic buckets:\e[0m"
274
+ histogram.bucket.each do |bucket|
275
+ bar = bar_chart(bucket.cumulative_count, 20)
276
+ puts " le=#{format_bound(bucket.upper_bound)}: #{bar} \e[32m#{bucket.cumulative_count}\e[0m"
277
+ end
278
+ end
279
+
280
+ def bar_chart(value, max_width)
281
+ return "" if value <= 0
282
+
283
+ width = [Math.log10(value + 1) * max_width / 5, max_width].min.to_i
284
+ "\e[44m#{" " * width}\e[0m"
285
+ end
286
+
287
+ def format_bound(value)
288
+ if value.infinite?
289
+ value.positive? ? "+Inf" : "-Inf"
290
+ elsif value.abs >= 1000 || (value.abs < 0.001 && value != 0)
291
+ format("%.3e", value)
292
+ else
293
+ format("%.4g", value)
294
+ end
295
+ end
296
+
297
+ def format_number(value)
298
+ if value.abs >= 1000 || (value.abs < 0.001 && value != 0)
299
+ format("%.3e", value)
300
+ else
301
+ format("%.4g", value)
302
+ end
303
+ end
304
+ end
305
+ end
Binary file
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Prometheus client model protobuf definitions
4
+ # Loaded from compiled FileDescriptorSet
5
+
6
+ require "google/protobuf"
7
+ require "google/protobuf/descriptor_pb"
8
+
9
+ module Io
10
+ module Prometheus
11
+ module Client
12
+ # Load the compiled FileDescriptorSet
13
+ DESCRIPTOR_DATA = File.binread(
14
+ File.expand_path("metrics.pb", __dir__)
15
+ ).freeze
16
+
17
+ # Parse the FileDescriptorSet
18
+ FILE_DESCRIPTOR_SET = Google::Protobuf::FileDescriptorSet.decode(DESCRIPTOR_DATA)
19
+
20
+ # Add each file descriptor to a pool
21
+ DESCRIPTOR_POOL = Google::Protobuf::DescriptorPool.new
22
+ FILE_DESCRIPTOR_SET.file.each do |file_proto|
23
+ DESCRIPTOR_POOL.add_serialized_file(file_proto.to_proto)
24
+ end
25
+
26
+ LabelPair = DESCRIPTOR_POOL.lookup("io.prometheus.client.LabelPair").msgclass
27
+ Gauge = DESCRIPTOR_POOL.lookup("io.prometheus.client.Gauge").msgclass
28
+ Counter = DESCRIPTOR_POOL.lookup("io.prometheus.client.Counter").msgclass
29
+ Quantile = DESCRIPTOR_POOL.lookup("io.prometheus.client.Quantile").msgclass
30
+ Summary = DESCRIPTOR_POOL.lookup("io.prometheus.client.Summary").msgclass
31
+ Untyped = DESCRIPTOR_POOL.lookup("io.prometheus.client.Untyped").msgclass
32
+ BucketSpan = DESCRIPTOR_POOL.lookup("io.prometheus.client.BucketSpan").msgclass
33
+ Bucket = DESCRIPTOR_POOL.lookup("io.prometheus.client.Bucket").msgclass
34
+ Histogram = DESCRIPTOR_POOL.lookup("io.prometheus.client.Histogram").msgclass
35
+ Metric = DESCRIPTOR_POOL.lookup("io.prometheus.client.Metric").msgclass
36
+ MetricFamily = DESCRIPTOR_POOL.lookup("io.prometheus.client.MetricFamily").msgclass
37
+ MetricType = DESCRIPTOR_POOL.lookup("io.prometheus.client.MetricType").enummodule
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Promproto
4
+ VERSION = "0.1.0"
5
+ end
data/lib/promproto.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "promproto/version"
4
+ require_relative "promproto/metrics_pb"
5
+ require_relative "promproto/cli"
6
+
7
+ module Promproto
8
+ def self.normalize_url(input)
9
+ url = input.dup
10
+
11
+ # Add http:// if no scheme
12
+ unless url.match?(%r{^https?://})
13
+ url = "http://#{url}"
14
+ end
15
+
16
+ # Parse to check/add path
17
+ uri = URI.parse(url)
18
+
19
+ # Add /metrics if path is empty or just /
20
+ if uri.path.nil? || uri.path.empty? || uri.path == "/"
21
+ uri.path = "/metrics"
22
+ end
23
+
24
+ uri.to_s
25
+ end
26
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: promproto
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Lewis Buckley
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-12-11 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: google-protobuf
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.21'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.21'
26
+ description: A command-line tool that fetches Prometheus metrics using the protobuf
27
+ exposition format and renders them with color-coded output. Supports both classic
28
+ and native histograms.
29
+ email:
30
+ - lewis@basecamp.com
31
+ executables:
32
+ - promproto
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE.md
37
+ - README.md
38
+ - exe/promproto
39
+ - lib/promproto.rb
40
+ - lib/promproto/cli.rb
41
+ - lib/promproto/metrics.pb
42
+ - lib/promproto/metrics_pb.rb
43
+ - lib/promproto/version.rb
44
+ homepage: https://github.com/lewispb/promproto
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ homepage_uri: https://github.com/lewispb/promproto
49
+ source_code_uri: https://github.com/lewispb/promproto
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: 3.1.0
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 3.6.2
65
+ specification_version: 4
66
+ summary: CLI tool to fetch and display Prometheus metrics in protobuf format
67
+ test_files: []