fast-prometheus 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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +38 -0
  3. data/CHANGELOG.md +59 -0
  4. data/README.md +109 -0
  5. data/lib/fast/prometheus/counter.rb +22 -0
  6. data/lib/fast/prometheus/errors.rb +11 -0
  7. data/lib/fast/prometheus/exposition.rb +46 -0
  8. data/lib/fast/prometheus/formats/metrics_pb.rb +33 -0
  9. data/lib/fast/prometheus/formats/protobuf.rb +154 -0
  10. data/lib/fast/prometheus/formats/text.rb +90 -0
  11. data/lib/fast/prometheus/gauge.rb +37 -0
  12. data/lib/fast/prometheus/histogram.rb +112 -0
  13. data/lib/fast/prometheus/metric.rb +144 -0
  14. data/lib/fast/prometheus/middleware/exporter.rb +39 -0
  15. data/lib/fast/prometheus/middleware/instrumentation.rb +34 -0
  16. data/lib/fast/prometheus/native_histogram.rb +145 -0
  17. data/lib/fast/prometheus/otlp/grpc_exporter.rb +39 -0
  18. data/lib/fast/prometheus/otlp/http_exporter.rb +43 -0
  19. data/lib/fast/prometheus/otlp/mapper.rb +207 -0
  20. data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.rb +27 -0
  21. data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/common/v1/common_pb.rb +26 -0
  22. data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/metrics/v1/metrics_pb.rb +41 -0
  23. data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/resource/v1/resource_pb.rb +23 -0
  24. data/lib/fast/prometheus/otlp/push.rb +39 -0
  25. data/lib/fast/prometheus/otlp/service_interface.rb +18 -0
  26. data/lib/fast/prometheus/rack/exporter.rb +32 -0
  27. data/lib/fast/prometheus/rack/instrumentation.rb +33 -0
  28. data/lib/fast/prometheus/registry.rb +111 -0
  29. data/lib/fast/prometheus/request_metrics.rb +45 -0
  30. data/lib/fast/prometheus/snapshot.rb +78 -0
  31. data/lib/fast/prometheus/store.rb +37 -0
  32. data/lib/fast/prometheus/summary.rb +50 -0
  33. data/lib/fast/prometheus/version.rb +7 -0
  34. data/lib/fast/prometheus.rb +17 -0
  35. metadata +158 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bf50aa0854cf98fd362294bd64ac2f07d04526428b6aa595c50c1058ffc82664
4
+ data.tar.gz: 66b21fc919d3913bdda5ae6b1aff7b661eb850981a43f54f5928d67aa0dbf16e
5
+ SHA512:
6
+ metadata.gz: cb355b90d7dd5dcc885ff0181d87611ef6c39120f613053ed9a95e15988cf04b33959c3289bec698e8c664720a85a567729c8547ad53c427aa88214ba0143868
7
+ data.tar.gz: 379b37ebbccda56a8c3c7975351f709e138c4928be30ff3a8ccdac991164285db35fe1ce0758630506e36bb68199a311273de47d477b9578dd643ab2a8bc0da7
data/AGENTS.md ADDED
@@ -0,0 +1,38 @@
1
+ # AGENTS.md
2
+
3
+ Standing context for agents working in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ bundle install
9
+ bundle exec sus
10
+ bundle exec rubocop
11
+ bundle exec ruby script/e2e_prometheus_scrape.rb
12
+ BENCH_QUICK=1 bundle exec ruby benchmark/observe.rb
13
+ ```
14
+
15
+ ## Style Rules
16
+
17
+ - 2-space indentation
18
+ - `# frozen_string_literal: true` on every source file
19
+ - Double-quoted strings (`"..."`)
20
+ - Fibers remain the IO concurrency model. `Thread::Mutex`/`Monitor` are permitted only where shared mutable state is guarded — metric stores and the registry. `Thread` is permitted only in tests and scripts that verify cross-thread behavior.
21
+
22
+ ## Test Rules
23
+
24
+ - Use `sus` as the test framework
25
+ - Tests are terse, exercising public interfaces only
26
+ - No mock/stub of the class under test
27
+
28
+ ## Local Reference Sources
29
+
30
+ - `~/architect/src/github.com/socketry/async`
31
+ - `~/architect/src/github.com/socketry/async-http`
32
+ - `~/architect/src/github.com/socketry/async-grpc`
33
+ - `~/architect/src/github.com/socketry/protocol-grpc`
34
+ - `~/architect/src/github.com/socketry/protocol-http`
35
+ - `~/architect/src/github.com/socketry/sus`
36
+ - `~/architect/src/github.com/socketry/sus-fixtures-async`
37
+ - `/Users/eric/architect/spaces/20260715-fast-prometheus-client/repos/client_ruby`
38
+ - `/Users/eric/architect/spaces/20260715-fast-prometheus-client/repos/prometheus`
data/CHANGELOG.md ADDED
@@ -0,0 +1,59 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-11
11
+
12
+ ### Added
13
+
14
+ - `Registry#fetch_or_register(name, &block)` — an atomic fetch-or-register:
15
+ returns the metric already registered under `name`, else registers and
16
+ returns the block's metric, in one lock hold.
17
+ - `Metric#synchronize` — runs a block atomically with respect to every other
18
+ mutation or read of that metric's store, including its `with_labels`-bound
19
+ children.
20
+ - `Fast::Prometheus::Rack::Exporter` and `Fast::Prometheus::Rack::Instrumentation` —
21
+ plain Rack middleware for `config.ru`, Rails, Puma and any other Rack host,
22
+ built on the same content-negotiation and RED-recording core as the
23
+ Falcon-native `Middleware::Exporter`/`Middleware::Instrumentation`.
24
+
25
+ ### Fixed
26
+
27
+ - `Instrumentation` middleware: concurrent construction of the middleware
28
+ against one registry could race two `get`/register check-then-act calls
29
+ and raise `DuplicateMetric`; it now registers its counter and histogram
30
+ through `Registry#fetch_or_register`.
31
+ - `Instrumentation` middleware: when the delegate raised and recording the
32
+ failed request also raised, the recording exception replaced the
33
+ delegate's; the delegate's exception now always propagates.
34
+
35
+ ### Changed
36
+
37
+ - Minimum Ruby lowered from 3.4 to 3.3 (`required_ruby_version >= 3.3`); the
38
+ test suite, RuboCop, the integration harness and the thread-safety stress
39
+ harness all pass on 3.3.12 and 4.0.5, and CI now runs the suite on both.
40
+ - `Registry` and every metric type are now safe to share across OS threads by
41
+ default: each metric's per-series store is guarded by its own lock (shared
42
+ by `with_labels`-bound metrics), snapshot construction observes every
43
+ series at one consistent point, and registry operations are serialized on
44
+ a registry-owned lock. This retires the zero-lock hot-path invariant —
45
+ cross-thread use is now in contract, not out of it.
46
+ - Documentation reorganized: `README.md` is now a front door, and the
47
+ tutorials, how-to guides, reference, and explanation live under `docs/`
48
+ per the Diátaxis framework.
49
+ - `Formats::Text` and `Formats::Protobuf` expose only `.render`; the
50
+ rendering helpers they used to leak as public singleton methods are private.
51
+
52
+ ## [0.1.0.pre.1] - 2026-07-19
53
+
54
+ ### Added
55
+
56
+ - Fiber-native Prometheus client built on `async`.
57
+ - Native histograms via protobuf scrape.
58
+ - OTLP export over gRPC and HTTP.
59
+ - `Fast::Prometheus` namespace.
data/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # fast-prometheus
2
+
3
+ A fiber-native Prometheus client for modern Ruby, built on the socketry/async
4
+ ecosystem: HTTP middleware for `Protocol::HTTP` (Falcon-native) and Rack, safe
5
+ to share one `Registry` across OS threads and fibers by default, native
6
+ histograms as a first-class metric type, protobuf scrape exposition, and OTLP
7
+ export over gRPC (async-grpc) and HTTP.
8
+
9
+ Not a fork of `prometheus/client_ruby` — a new gem that uses it as the
10
+ reference for supported surface.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ gem install fast-prometheus
16
+ ```
17
+
18
+ Or add to your `Gemfile`:
19
+
20
+ ```ruby
21
+ gem "fast-prometheus"
22
+ ```
23
+
24
+ `require "fast/prometheus"` loads only the core, with no IO dependencies. The
25
+ opt-in surfaces and what each pulls in are listed in
26
+ [the require-path reference](docs/reference/require-paths.md).
27
+
28
+ ## Quickstart
29
+
30
+ ```ruby
31
+ require "fast/prometheus"
32
+ require "fast/prometheus/formats/text"
33
+
34
+ registry = Fast::Prometheus::Registry.new
35
+
36
+ requests = registry.counter(:http_requests_total, docstring: "Total HTTP requests", labels: %i[method path])
37
+ duration = registry.histogram(:request_duration_seconds, docstring: "Request duration", labels: [:method])
38
+
39
+ requests.increment(labels: { method: "GET", path: "/" })
40
+ requests.increment(by: 5, labels: { method: "POST", path: "/api" })
41
+ duration.observe(0.042, labels: { method: "GET" })
42
+ duration.observe(0.018, labels: { method: "GET" })
43
+
44
+ puts Fast::Prometheus::Formats::Text.render(registry.collect)
45
+ ```
46
+
47
+ Run with `bundle exec ruby -I lib` from the repo root. For running this
48
+ under Falcon and scraping it with a real Prometheus, see
49
+ [the Falcon tutorial](docs/tutorials/falcon-app.md).
50
+
51
+ ## Documentation
52
+
53
+ ### Tutorials
54
+
55
+ - [Tutorial: instrument a Falcon app](docs/tutorials/falcon-app.md) — build a Falcon app instrumented with fast-prometheus and scrape it with Prometheus.
56
+
57
+ ### How-to guides
58
+
59
+ - [How to instrument a Rack app](docs/how-to/instrument-a-rack-app.md) — add the two Rack middleware to a `config.ru`, Rails app or Puma host.
60
+ - [How to instrument a falcon.rb service](docs/how-to/instrument-a-falcon-service.md) — run the Falcon-native middleware under `falcon host` or a threaded launcher.
61
+ - [How to serve metrics on a separate port](docs/how-to/serve-metrics-on-a-separate-port.md) — run `/metrics` on its own `Async::HTTP::Server`, off the app's port.
62
+ - [How to scrape native histograms](docs/how-to/scrape-native-histograms.md) — configure Prometheus to negotiate protobuf so native histograms aren't dropped.
63
+ - [How to share one registry across boot paths](docs/how-to/share-a-registry.md) — use `fetch_or_register` so multiple boot paths can construct the same metric safely.
64
+ - [How to export over OTLP](docs/how-to/export-otlp.md) — push metrics to an OTLP collector over gRPC or HTTP.
65
+ - [How to verify a release build](docs/how-to/verify-a-release.md) — run the integration harness against a packaged gem before tagging.
66
+
67
+ ### Reference
68
+
69
+ - [Reference: registry and metrics](docs/reference/metrics.md) — every public class and method on `Registry` and the five metric types.
70
+ - [Reference: exposition formats and HTTP middleware](docs/reference/exposition.md) — `Formats::Text`, `Formats::Protobuf`, `Middleware::Exporter`, `Middleware::Instrumentation`.
71
+ - [Reference: OTLP export](docs/reference/otlp.md) — `OTLP::HTTPExporter`, `OTLP::GRPCExporter`, `OTLP::Push`, `OTLP::Mapper`.
72
+ - [Reference: require paths and dependencies](docs/reference/require-paths.md) — what each require path loads and what it pulls in.
73
+
74
+ ### Explanation
75
+
76
+ - [Concurrency model](docs/explanation/concurrency.md) — the locking contract and what it guarantees under threads and fibers.
77
+ - [Native histograms](docs/explanation/native-histograms.md) — what a native histogram is and why it's protobuf-only.
78
+ - [Design: why a new gem](docs/explanation/design.md) — why fast-prometheus exists instead of a `client_ruby` fork.
79
+ - [Benchmarks](docs/explanation/benchmarks.md) — the full benchmark-ips run and how to reproduce it.
80
+
81
+ ## Concurrency
82
+
83
+ A `Registry` is safe to share across every OS thread of a process, and
84
+ remains safe across fibers within a thread — correct under Falcon
85
+ `--threaded --count N`, where the N OS threads share one module-level
86
+ `Fast::Prometheus.registry`. Falcon `--forked` mode gives each process its
87
+ own registry, so a single scrape only sees the metrics of the process that
88
+ handled it; aggregating across processes is out of scope. See
89
+ [the concurrency model](docs/explanation/concurrency.md) for the full guarantee.
90
+
91
+ ## Performance
92
+
93
+ Single process, locked, thread-safe by default (`benchmark-ips` comparisons
94
+ against `prometheus-client`; see [the benchmarks page](docs/explanation/benchmarks.md) for the
95
+ full run and conditions):
96
+
97
+ - Bound counter (fast) is on par with prometheus-client's bound counter (1.04x)
98
+ - Classic histogram observe is **3.0x** faster (1.71M vs 566K i/s)
99
+ - Native histogram observe runs at **1.35M i/s** with no prometheus-client equivalent
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ bundle exec sus
105
+ bundle exec rubocop
106
+ E2E_REQUIRED=1 ./integration/run
107
+ ```
108
+
109
+ See [how to verify a release build](docs/how-to/verify-a-release.md) for the pre-release rule.
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "metric"
4
+
5
+ module Fast
6
+ module Prometheus
7
+ # Counter — a monotonically increasing metric.
8
+ class Counter < Metric
9
+ def type
10
+ :counter
11
+ end
12
+
13
+ def increment(by: 1, labels: {})
14
+ raise ArgumentError, "by must be a non-negative numeric" unless by.is_a?(Numeric) && by >= 0
15
+ return if by.zero?
16
+
17
+ key = resolve(labels)
18
+ store.synchronize { store[key] = (store[key] || 0.0) + by.to_f }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ module Prometheus
5
+ class Error < StandardError; end
6
+ class InvalidMetricName < Error; end
7
+ class InvalidLabelName < Error; end
8
+ class InvalidLabelSet < Error; end
9
+ class DuplicateMetric < Error; end
10
+ end
11
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ require "fast/prometheus"
6
+ require "fast/prometheus/formats/text"
7
+ require "fast/prometheus/formats/protobuf"
8
+
9
+ module Fast
10
+ module Prometheus
11
+ # Renders a registry snapshot into an exposition body and response headers,
12
+ # negotiating format (text vs protobuf) and gzip from Accept / Accept-Encoding
13
+ # header values. Shared by Middleware::Exporter and Rack::Exporter so
14
+ # negotiation and compression exist exactly once.
15
+ module Exposition
16
+ PROTOBUF_ACCEPT = "application/vnd.google.protobuf"
17
+
18
+ FORMAT_MAP = {
19
+ true => [Formats::Protobuf, Formats::Protobuf::CONTENT_TYPE],
20
+ false => [Formats::Text, Formats::Text::CONTENT_TYPE]
21
+ }.freeze
22
+ private_constant :FORMAT_MAP
23
+
24
+ def self.render(registry, accept:, accept_encoding:)
25
+ format, content_type = FORMAT_MAP[protobuf?(accept)]
26
+ body = format.render(registry.collect)
27
+ headers = { "content-type" => content_type }
28
+
29
+ if gzip?(accept_encoding)
30
+ body = Zlib.gzip(body)
31
+ headers["content-encoding"] = "gzip"
32
+ end
33
+
34
+ [body, headers]
35
+ end
36
+
37
+ private_class_method def self.protobuf?(accept)
38
+ accept.to_s.include?(PROTOBUF_ACCEPT)
39
+ end
40
+
41
+ private_class_method def self.gzip?(accept_encoding)
42
+ accept_encoding.to_s.include?("gzip")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # source: metrics.proto
4
+
5
+ require 'google/protobuf'
6
+
7
+ require 'google/protobuf/timestamp_pb'
8
+
9
+
10
+ descriptor_data = "\n\rmetrics.proto\x12\x14io.prometheus.client\x1a\x1fgoogle/protobuf/timestamp.proto\"(\n\tLabelPair\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\x16\n\x05Gauge\x12\r\n\x05value\x18\x01 \x01(\x01\"\x7f\n\x07\x43ounter\x12\r\n\x05value\x18\x01 \x01(\x01\x12\x30\n\x08\x65xemplar\x18\x02 \x01(\x0b\x32\x1e.io.prometheus.client.Exemplar\x12\x33\n\x0fstart_timestamp\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"+\n\x08Quantile\x12\x10\n\x08quantile\x18\x01 \x01(\x01\x12\r\n\x05value\x18\x02 \x01(\x01\"\x9a\x01\n\x07Summary\x12\x14\n\x0csample_count\x18\x01 \x01(\x04\x12\x12\n\nsample_sum\x18\x02 \x01(\x01\x12\x30\n\x08quantile\x18\x03 \x03(\x0b\x32\x1e.io.prometheus.client.Quantile\x12\x33\n\x0fstart_timestamp\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x18\n\x07Untyped\x12\r\n\x05value\x18\x01 \x01(\x01\"\x8f\x04\n\tHistogram\x12\x14\n\x0csample_count\x18\x01 \x01(\x04\x12\x1a\n\x12sample_count_float\x18\x04 \x01(\x01\x12\x12\n\nsample_sum\x18\x02 \x01(\x01\x12,\n\x06\x62ucket\x18\x03 \x03(\x0b\x32\x1c.io.prometheus.client.Bucket\x12\x33\n\x0fstart_timestamp\x18\x0f \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06schema\x18\x05 \x01(\x11\x12\x16\n\x0ezero_threshold\x18\x06 \x01(\x01\x12\x12\n\nzero_count\x18\x07 \x01(\x04\x12\x18\n\x10zero_count_float\x18\x08 \x01(\x01\x12\x37\n\rnegative_span\x18\t \x03(\x0b\x32 .io.prometheus.client.BucketSpan\x12\x16\n\x0enegative_delta\x18\n \x03(\x12\x12\x16\n\x0enegative_count\x18\x0b \x03(\x01\x12\x37\n\rpositive_span\x18\x0c \x03(\x0b\x32 .io.prometheus.client.BucketSpan\x12\x16\n\x0epositive_delta\x18\r \x03(\x12\x12\x16\n\x0epositive_count\x18\x0e \x03(\x01\x12\x31\n\texemplars\x18\x10 \x03(\x0b\x32\x1e.io.prometheus.client.Exemplar\"\x89\x01\n\x06\x42ucket\x12\x18\n\x10\x63umulative_count\x18\x01 \x01(\x04\x12\x1e\n\x16\x63umulative_count_float\x18\x04 \x01(\x01\x12\x13\n\x0bupper_bound\x18\x02 \x01(\x01\x12\x30\n\x08\x65xemplar\x18\x03 \x01(\x0b\x32\x1e.io.prometheus.client.Exemplar\",\n\nBucketSpan\x12\x0e\n\x06offset\x18\x01 \x01(\x11\x12\x0e\n\x06length\x18\x02 \x01(\r\"x\n\x08\x45xemplar\x12.\n\x05label\x18\x01 \x03(\x0b\x32\x1f.io.prometheus.client.LabelPair\x12\r\n\x05value\x18\x02 \x01(\x01\x12-\n\ttimestamp\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xbe\x02\n\x06Metric\x12.\n\x05label\x18\x01 \x03(\x0b\x32\x1f.io.prometheus.client.LabelPair\x12*\n\x05gauge\x18\x02 \x01(\x0b\x32\x1b.io.prometheus.client.Gauge\x12.\n\x07\x63ounter\x18\x03 \x01(\x0b\x32\x1d.io.prometheus.client.Counter\x12.\n\x07summary\x18\x04 \x01(\x0b\x32\x1d.io.prometheus.client.Summary\x12.\n\x07untyped\x18\x05 \x01(\x0b\x32\x1d.io.prometheus.client.Untyped\x12\x32\n\thistogram\x18\x07 \x01(\x0b\x32\x1f.io.prometheus.client.Histogram\x12\x14\n\x0ctimestamp_ms\x18\x06 \x01(\x03\"\x96\x01\n\x0cMetricFamily\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04help\x18\x02 \x01(\t\x12.\n\x04type\x18\x03 \x01(\x0e\x32 .io.prometheus.client.MetricType\x12,\n\x06metric\x18\x04 \x03(\x0b\x32\x1c.io.prometheus.client.Metric\x12\x0c\n\x04unit\x18\x05 \x01(\t*b\n\nMetricType\x12\x0b\n\x07\x43OUNTER\x10\x00\x12\t\n\x05GAUGE\x10\x01\x12\x0b\n\x07SUMMARY\x10\x02\x12\x0b\n\x07UNTYPED\x10\x03\x12\r\n\tHISTOGRAM\x10\x04\x12\x13\n\x0fGAUGE_HISTOGRAM\x10\x05\x42\x16Z\x14io_prometheus_clientb\x06proto3"
11
+
12
+ pool = ::Google::Protobuf::DescriptorPool.generated_pool
13
+ pool.add_serialized_file(descriptor_data)
14
+
15
+ module Io
16
+ module Prometheus
17
+ module Client
18
+ LabelPair = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.LabelPair").msgclass
19
+ Gauge = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.Gauge").msgclass
20
+ Counter = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.Counter").msgclass
21
+ Quantile = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.Quantile").msgclass
22
+ Summary = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.Summary").msgclass
23
+ Untyped = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.Untyped").msgclass
24
+ Histogram = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.Histogram").msgclass
25
+ Bucket = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.Bucket").msgclass
26
+ BucketSpan = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.BucketSpan").msgclass
27
+ Exemplar = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.Exemplar").msgclass
28
+ Metric = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.Metric").msgclass
29
+ MetricFamily = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.MetricFamily").msgclass
30
+ MetricType = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("io.prometheus.client.MetricType").enummodule
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast/prometheus"
4
+ require_relative "metrics_pb"
5
+
6
+ module Fast
7
+ module Prometheus
8
+ module Formats
9
+ module Protobuf
10
+ CONTENT_TYPE = "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited"
11
+
12
+ TYPE_MAP = {
13
+ counter: Io::Prometheus::Client::MetricType::COUNTER,
14
+ gauge: Io::Prometheus::Client::MetricType::GAUGE,
15
+ summary: Io::Prometheus::Client::MetricType::SUMMARY,
16
+ histogram: Io::Prometheus::Client::MetricType::HISTOGRAM,
17
+ native_histogram: Io::Prometheus::Client::MetricType::HISTOGRAM
18
+ }.freeze
19
+ private_constant :TYPE_MAP
20
+
21
+ def self.render(snapshot)
22
+ snapshot.metrics.each_with_object(String.new) do |ms, buffer|
23
+ buffer << encode_frame(build_metric_family(ms))
24
+ end
25
+ end
26
+
27
+ private_class_method def self.build_metric_family(metric_snapshot)
28
+ Io::Prometheus::Client::MetricFamily.new(
29
+ name: metric_snapshot.name.to_s,
30
+ help: metric_snapshot.docstring,
31
+ type: TYPE_MAP.fetch(metric_snapshot.type),
32
+ metric: metric_snapshot.series.map { |s| build_metric(s, metric_snapshot.type) }
33
+ )
34
+ end
35
+
36
+ private_class_method def self.build_metric(series, type)
37
+ labels = series.labels.map do |name, value|
38
+ Io::Prometheus::Client::LabelPair.new(name: name.to_s, value: value)
39
+ end
40
+
41
+ case type
42
+ when :counter
43
+ Io::Prometheus::Client::Metric.new(
44
+ label: labels,
45
+ counter: Io::Prometheus::Client::Counter.new(value: series.value)
46
+ )
47
+ when :gauge
48
+ Io::Prometheus::Client::Metric.new(
49
+ label: labels,
50
+ gauge: Io::Prometheus::Client::Gauge.new(value: series.value)
51
+ )
52
+ when :summary
53
+ Io::Prometheus::Client::Metric.new(
54
+ label: labels,
55
+ summary: Io::Prometheus::Client::Summary.new(
56
+ sample_count: series.value.count,
57
+ sample_sum: series.value.sum
58
+ )
59
+ )
60
+ when :histogram
61
+ buckets = series.value.cumulative_buckets.map do |bound, count|
62
+ Io::Prometheus::Client::Bucket.new(
63
+ upper_bound: bound,
64
+ cumulative_count: count
65
+ )
66
+ end
67
+ Io::Prometheus::Client::Metric.new(
68
+ label: labels,
69
+ histogram: Io::Prometheus::Client::Histogram.new(
70
+ sample_count: series.value.count,
71
+ sample_sum: series.value.sum,
72
+ bucket: buckets
73
+ )
74
+ )
75
+ when :native_histogram
76
+ nv = series.value
77
+ pos_spans, pos_deltas = build_spans_deltas(nv.positive_buckets)
78
+ neg_spans, neg_deltas = build_spans_deltas(nv.negative_buckets)
79
+
80
+ Io::Prometheus::Client::Metric.new(
81
+ label: labels,
82
+ histogram: Io::Prometheus::Client::Histogram.new(
83
+ sample_count: nv.count,
84
+ sample_sum: nv.sum,
85
+ schema: nv.schema,
86
+ zero_threshold: nv.zero_threshold,
87
+ zero_count: nv.zero_count,
88
+ positive_span: pos_spans,
89
+ positive_delta: pos_deltas,
90
+ negative_span: neg_spans,
91
+ negative_delta: neg_deltas
92
+ )
93
+ )
94
+ end
95
+ end
96
+
97
+ # Build BucketSpan and sint64 delta arrays from ascending [index, count] pairs.
98
+ # Worked example: [[1, 3], [2, 1], [5, 4]] ->
99
+ # spans: [{offset: 1, length: 2}, {offset: 2, length: 1}]
100
+ # deltas: [3, -2, 3]
101
+ private_class_method def self.build_spans_deltas(buckets)
102
+ return [], [] if buckets.empty?
103
+
104
+ spans = []
105
+ deltas = []
106
+ span_offset = nil
107
+ span_length = 0
108
+ prev_index = nil
109
+ prev_count = nil
110
+
111
+ buckets.each do |index, count|
112
+ if prev_index.nil?
113
+ span_offset = index
114
+ span_length = 1
115
+ deltas << count
116
+ else
117
+ gap = index - prev_index
118
+ if gap == 1
119
+ span_length += 1
120
+ else
121
+ spans << Io::Prometheus::Client::BucketSpan.new(offset: span_offset, length: span_length)
122
+ span_offset = gap - 1
123
+ span_length = 1
124
+ end
125
+ deltas << count - prev_count
126
+ end
127
+ prev_index = index
128
+ prev_count = count
129
+ end
130
+
131
+ spans << Io::Prometheus::Client::BucketSpan.new(offset: span_offset, length: span_length)
132
+ [spans, deltas]
133
+ end
134
+
135
+ private_class_method def self.encode_frame(message)
136
+ encoded = message.class.encode(message)
137
+ encode_varint(encoded.bytesize) << encoded
138
+ end
139
+
140
+ private_class_method def self.encode_varint(value)
141
+ buffer = String.new
142
+ loop do
143
+ byte = value & 0x7f
144
+ value >>= 7
145
+ byte |= 0x80 if value.positive?
146
+ buffer << byte
147
+ break if value.zero?
148
+ end
149
+ buffer
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast/prometheus"
4
+
5
+ module Fast
6
+ module Prometheus
7
+ module Formats
8
+ # Prometheus text exposition format 0.0.4.
9
+ # Consumes Snapshot value objects; never reads live metrics.
10
+ module Text
11
+ CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
12
+
13
+ DOC_ESCAPE = /[\n\\]/
14
+ DOC_REPLACE = { "\n" => "\\n", "\\" => "\\\\" }.freeze
15
+
16
+ LABEL_ESCAPE = /[\n\\"]/
17
+ LABEL_REPLACE = { "\\" => "\\\\", '"' => '\\"', "\n" => "\\n" }.freeze
18
+
19
+ def self.render(snapshot)
20
+ snapshot.metrics.each_with_object(String.new) do |metric, output|
21
+ next if metric.type == :native_histogram
22
+
23
+ output << "# HELP #{metric.name} #{escape_doc(metric.docstring)}\n"
24
+ output << "# TYPE #{metric.name} #{metric.type}\n"
25
+
26
+ metric.series.each do |series|
27
+ render_series(output, metric.type, metric.name, series)
28
+ end
29
+ end
30
+ end
31
+
32
+ private_class_method def self.render_series(output, type, name, series)
33
+ case type
34
+ when :counter, :gauge
35
+ metric_line(output, name, series.labels, series.value)
36
+ when :histogram
37
+ histogram_lines(output, name, series.labels, series.value)
38
+ when :summary
39
+ summary_lines(output, name, series.labels, series.value)
40
+ end
41
+ end
42
+
43
+ private_class_method def self.escape_doc(string)
44
+ return string unless DOC_ESCAPE.match?(string)
45
+
46
+ string.gsub(DOC_ESCAPE, DOC_REPLACE)
47
+ end
48
+
49
+ private_class_method def self.escape_label(string)
50
+ string = string.to_s
51
+ return string unless LABEL_ESCAPE.match?(string)
52
+
53
+ string.gsub(LABEL_ESCAPE, LABEL_REPLACE)
54
+ end
55
+
56
+ private_class_method def self.format_labels(labels)
57
+ return "" if labels.empty?
58
+
59
+ output = String.new("{")
60
+ labels.each do |key, value|
61
+ output << "#{key}=\"#{escape_label(value)}\""
62
+ output << ","
63
+ end
64
+ output.chop!
65
+ output << "}"
66
+ output
67
+ end
68
+
69
+ private_class_method def self.metric_line(output, name, labels, value)
70
+ output << "#{name}#{format_labels(labels)} #{value}\n"
71
+ end
72
+
73
+ private_class_method def self.histogram_lines(output, name, labels, value)
74
+ value.cumulative_buckets.each do |boundary, count|
75
+ le = boundary.infinite? ? "+Inf" : boundary.to_s
76
+ metric_line(output, "#{name}_bucket", labels.merge("le" => le), count)
77
+ end
78
+
79
+ metric_line(output, "#{name}_sum", labels, value.sum)
80
+ metric_line(output, "#{name}_count", labels, value.count)
81
+ end
82
+
83
+ private_class_method def self.summary_lines(output, name, labels, value)
84
+ metric_line(output, "#{name}_sum", labels, value.sum)
85
+ metric_line(output, "#{name}_count", labels, value.count)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "metric"
4
+
5
+ module Fast
6
+ module Prometheus
7
+ # Gauge — an instantaneous value that can go up or down.
8
+ class Gauge < Metric
9
+ def type
10
+ :gauge
11
+ end
12
+
13
+ def set(value, labels: {})
14
+ raise ArgumentError, "value must be a numeric" unless value.is_a?(Numeric)
15
+
16
+ key = resolve(labels)
17
+ store.synchronize { store[key] = value.to_f }
18
+ end
19
+
20
+ def increment(by: 1, labels: {})
21
+ raise ArgumentError, "by must be a numeric" unless by.is_a?(Numeric)
22
+ return if by.zero?
23
+
24
+ key = resolve(labels)
25
+ store.synchronize { store[key] = (store[key] || 0.0) + by.to_f }
26
+ end
27
+
28
+ def decrement(by: 1, labels: {})
29
+ raise ArgumentError, "by must be a numeric" unless by.is_a?(Numeric)
30
+ return if by.zero?
31
+
32
+ key = resolve(labels)
33
+ store.synchronize { store[key] = (store[key] || 0.0) - by.to_f }
34
+ end
35
+ end
36
+ end
37
+ end