Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save n-rodriguez/4f9cf219480893760a30315e9bc22035 to your computer and use it in GitHub Desktop.

Select an option

Save n-rodriguez/4f9cf219480893760a30315e9bc22035 to your computer and use it in GitHub Desktop.
prometheus_exporter — code audit (2026-08-26, rev ec4c481): 86 findings, empirically verified

prometheus_exporter — Code Audit

Date: 2026-08-26 · Audited revision: ec4c481 (main)

Scope: whole repository, 102 tracked files — 75 production, 27 tests (audited as meta), 0 vendored. Coverage: 47/47 lib/ files read line by line · exe/, bench/, examples/ (3/3) · both GitHub workflows · all 5 gemfiles/ · 12 of the 14 root files. Not read, no audit surface: LICENSE.txt, CODE_OF_CONDUCT.md. README.md was read selectively (~330 lines out of 1060: CLI, Docker, TLS, requirements, examples, authentication sections), not in full — the documentation-drift findings cover those sections only. bin/appraisal, bin/rubocop, bin/stree are Bundler-generated binstubs (role: generated); only bin/rake was read.

86 findings: 4 CRITICAL, 22 HIGH, 38 MEDIUM, 17 LOW, 5 NIT.

Note on provenance. This audit was produced with AI assistance (Claude). That is stated up front because it is material to how you should read it: every claim that depends on non-obvious language or runtime semantics was verified by an executed probe in the session, and those probes are listed below with their measured output. Nothing here is asserted from recollection of how Ruby "probably" behaves. Where a suspicion did not survive verification, it was dropped and recorded in the "Hypotheses checked and dismissed" section rather than shipped as a finding. Line numbers refer to the revision above.

Severity line used

CRITICAL is reserved for defects triggerable without operator misconfiguration that cause silent data loss or corruption, or a security breach. A defect that requires a faulty configuration first caps at HIGH, however bad its downstream effect. This line is stated explicitly so that two readings of the same defect land on the same level.

Semantics verified in session

No claim in this report rests on recollection. Each of these was probed on Ruby 4.0.6, psych 5.4.0, webrick 1.9.2:

  • Base#labels_text escapes label values (\n, ", \) but never keys — measured output: m2{bad"key="v"} 1.
  • to_prometheus_text interpolates help and name raw — a help containing a newline yields # HELP legit help\n# TYPE injected counter\ninjected 999.
  • A break from the block passed to req.body returns control to handle_metrics, which then unconditionally rewrites res.status = 200.
  • Counter#observe with a non-numeric value raises TypeError: String can't be coerced into Integer.
  • Dynamic symbols are garbage-collected (Symbol.all_symbols: 3811 → 3811 after 100,000 to_sym calls plus GC.start) — so no symbol-table DoS via symbolize_keys.
  • Queue#pop blocks indefinitely on an empty queue, and removes the oldest element (FIFO).
  • WEBrick::HTTPAuth::Htpasswd raises NotImplementedError on an MD5 file and on a bcrypt file; only DES crypt is accepted. NotImplementedError is a ScriptError, so it escapes rescue =>.
  • DES crypt truncates to 8 significant characters: "motdepasse-tres-long".crypt("ab") == "motdepas".crypt("ab")true.
  • Thread.current[:k] is fiber-local: nil from inside an explicit Fiber, correct from inside an Enumerator. thread_variable_get crosses fibers.
  • The PeriodicStats thread does not survive fork: parent started?=true counter 6, child started?=false counter frozen at 6.
  • def f(a, b = {}) has arity -2, so arity > 0 is false.
  • "no match".match(RE).to_a[2].to_s yields "", never nil — a downstream || is dead code.
  • !RUBY_ENGINE == "jruby" evaluates to false (precedence); RUBY_ENGINE != "jruby" evaluates to true.
  • On Ruby 4.0.6, logger, json, psych, timeout and stringio are no longer default gems (default_gem? == false).
  • pgrep with no match returns "" with exitstatus 1 — and "".lines.count == 0.
  • Gauge#observe(nil) deletes the series rather than zeroing it (measured: "unicorn_workers{host=\"h1\"} 4""").
  • The binary's ObjectSpace sweep finds 14 built-in TypeCollectors with no custom file loaded at all, and the built-in Collector also matches the -c sweep.

Priority summary

The exporter accepts, on a port that is unauthenticated by default, payloads in which no field is validated, and renders them into a text format where label keys, the metric name and the help field are never escaped. Three consequences compose. Arbitrary series can be forged into the scrape. A label-name collision makes Prometheus reject the entire scrape. And when a malformed payload makes a collector raise, handle_metrics truncates the rest of the batch and then answers 200 OK. So the nominal outcome of an error is: metrics lost, sender never told, exporter apparently healthy.

Second in rank are two defects that hit the instrumented application itself: double-patching in MethodProfiler blows the stack on every SQL/Redis call, and its state is fiber-local, hence invisible under any fiber scheduler.

Finally, the delivery chain publishes automatically on a push to main: the release Docker image cannot build (Ruby 3.1 against a 3.2 gemspec floor), the gem under-declares logger, and the job that runs pull-request code inherits a write-scoped token.


1. Correctness

CRITICAL — COR-lib/prometheus_exporter/server/delayed_job_collector.rb:24

Problem — Default labels are built with Symbol keys and then merged with custom_labels that have String keys, so a name collision emits the same label twice on one line. Same shape at hutch_collector.rb:17, shoryuken_collector.rb:18, sidekiq_collector.rb:19.

Scenario — A payload carrying "queue_name":"default" and "custom_labels":{"queue_name":"EVIL","name":"X"} renders, measured, delayed_jobs_total{queue_name="default",queue_name="EVIL",name="X",job_name="J"} 1. Prometheus rejects the whole scrape with duplicate label name, so every metric from every collector disappears — silently, while /metrics keeps answering 200.

Fix — Build the default labels with String keys so merge overwrites instead of juxtaposing, and reject a collision explicitly.

HIGH — COR-lib/prometheus_exporter/metric/summary.rb:91

Problemrotate_if_needed is only ever called from observe, so a Summary that stops receiving observations serves its last quantiles forever.

Scenario — Measured: after 10 observations of 5.0 and then one hour with no observation at all (ROTATE_AGE is 120 s), the rendering still contains lat{quantile="0.99"} 5.0. An endpoint that has gone silent displays a healthy p99 indefinitely: a latency alert will never fire, and the graph shows a flat line instead of a gap.

Fix — Expire the buffers based on time at render time (call rotate_if_needed from calculate_all_quantiles), or render nothing past 2 × ROTATE_AGE without an observation.

HIGH — COR-lib/prometheus_exporter/server/unicorn_collector.rb:29

Problem — Labels reduce to custom_labels — neither pid nor hostname — and the MetricsContainer has no filter, unlike process/puma/active_record.

Scenario — Measured: two unicorn masters (pid 1/h1/4 workers, then pid 2/h2/8 workers) render a single line unicorn_workers 8; h1's value is overwritten and lost without a trace.

Fix — Include pid and hostname in the labels and set a filter on that pair, as process_collector.rb:31 does.

HIGH — COR-lib/prometheus_exporter/server/sidekiq_stats_collector.rb:37

Problemgauge.observe(value) is called with no labels at all: the payload's custom_labels are ignored entirely. It is the only collector in the set that does this.

Scenario — Measured: two clusters emitting enqueued 10 (cluster a) and 99 (cluster b) render the single line sidekiq_stats_enqueued 99; cluster a's value is lost and the graph oscillates depending on arrival order.

Fixgauge.observe(value, metric["custom_labels"] || {}).

HIGH — COR-lib/prometheus_exporter/server/process_collector.rb:31

Problem — The filter discriminates only on pid and hostname, although metric_labels is part of the series identity at line 47 — active_record_collector.rb:21 does include pool_name in its filter, hence the drift.

Scenario — The documented usage Process.start(type: "web") alongside Process.start(type: "worker") in the same process produces two samples with identical pid and hostname; each evicts the other, only one of the two type series survives, and its value alternates.

Fix — Add a metric_labels comparison to the filter condition.

HIGH — COR-lib/prometheus_exporter/instrumentation/unicorn.rb:55

Problem — The exit status of pgrep is never inspected, and empty output is counted as zero workers.

Scenario — Measured: pgrep with no match returns "" with exitstatus 1, and "".lines.count is 0. A unicorn master whose children do not match the -f unicorn pattern (renamed process, container without pgrep, zombie master) reports workers: 0 as a legitimate measurement: the dashboard shows "zero workers" with no way to tell a broken tool from a broken service.

Fix — Inspect $?.success? and return nil (metric absent) rather than 0 on failure.

HIGH — COR-exe/prometheus_exporter:103-108

Problem — The --collector class is picked by an ObjectSpace sweep with last-match-wins over an unordered enumeration, and the choice is never logged.

Scenario — Measured: the sweep also matches the built-in PrometheusExporter::Server::Collector; with a custom file declaring an abstract base plus the real class, the abstract one can be selected. The exporter starts normally and every scrape returns the selected class's empty rendering, silently.

Fix — Diff ObjectSpace before and after the require, abort with exit 1 if the candidate count is not exactly 1, and log the selected class.

MEDIUM — COR-exe/prometheus_exporter:121-126

Problem — The --type-collector sweep picks up the 14 built-in TypeCollectors, not just those from the operator's file.

Scenario — Measured: the sweep returns 14 classes with no custom file loaded. Combined with --collector, Runner#register_type_collectors then calls register_collector on the custom collector, which does not implement that method: NoMethodError right after "Starting prometheus exporter".

Fix — Snapshot ObjectSpace before the require and keep only the classes that appeared afterwards.

MEDIUM — COR-lib/prometheus_exporter/instrumentation/delayed_job.rb:43

Problem — The .to_s applied to the match result yields an empty string and never nil, which makes the ||= fallback on line 44 unreachable.

Scenario — Measured: "handler with no job_class".match(RE).to_a[2].to_s is "" and ("" || "FALLBACK") is "". Every non-ActiveJob Delayed::Job payload (a Delayed::PerformableMethod, which has no job_class: line) is emitted with name: "", so all such jobs collapse into a single anonymous series.

Fix — Drop the .to_s so the line 44 fallback actually fires.

MEDIUM — COR-lib/prometheus_exporter/server/good_job_collector.rb:29

Problemmetric.fetch("custom_labels", {}) returns nil — not {} — when the key is present with a null value, contrary to what the fetch form suggests. Same site at resque_collector.rb:28.

Scenario — A third-party sender emitting {"scheduled":1,"custom_labels":null} then {"scheduled":2} creates two distinct entries @data[nil] and @data[{}], rendered as two identical lines that Prometheus rejects as a duplicate series.

Fixmetric["custom_labels"] || {}.

MEDIUM — COR-lib/prometheus_exporter/server/delayed_job_collector.rb:49

Problemenqueued and pending are Gauges, and Gauge#observe(nil) deletes the series instead of raising.

Scenario — Measured: after observe(4, {host: "h1"}) the rendering contains unicorn_workers{host="h1"} 4; after observe(nil, {host: "h1"}) it is empty. A sender that omits enqueued for a queue silently erases the series: on the Prometheus side the queue goes from "0 pending" to "series absent", which neutralises any enqueued > N alert with no trace.

Fix — Skip the observation when the value is absent, rather than letting Gauge read nil as a deletion order.

MEDIUM — COR-lib/prometheus_exporter/server/puma_collector.rb:16

Problembusy_threads is only added to the gauge table if ::Puma::Const is defined in the exporter process, yet nothing on the server side loads Puma.

Scenario — A Puma ≥ 6.6 instrumentation always sends busy_threads, but an exporter run as a standalone binary discards the value silently and the metric never exists; it does appear if the exporter runs in-process. The behaviour depends on the collector's environment, not the sender's.

Fix — Export the gauge unconditionally; an absent key is already filtered downstream.

MEDIUM — COR-examples/custom_collector.rb:10

Problemprocess(obj) treats its argument as an already-parsed Hash, whereas WebServer#handle_metrics passes the raw chunk String — the README's own version does the JSON.parse.

Scenario — Once the NameError on line 3 is fixed, client.send_json(thing1: 122) delivers the string '{"thing1":122}'; String#[]("thing1") returns nil without raising, the gauge is never observed, and the scrape shows its initial value forever.

Fix — Parse the string as the first line of process, matching the README.

LOW — COR-lib/prometheus_exporter/instrumentation/shoryuken.rb:16

Problemshutdown is only assigned inside the rescue clause, so it is nil on the nominal path, where the Sidekiq middleware initialises it to false.

Scenario — Every successful Shoryuken job emits shutdown: nil while every successful Sidekiq job emits shutdown: false; any collector-side or PromQL logic written against shutdown == false silently excludes all Shoryuken jobs.

Fix — Initialise shutdown to false next to success.

LOW — COR-lib/prometheus_exporter/instrumentation/method_profiler.rb:37

Problemstop deletes the :__start key without checking it is present, while the documented transfer/start hand-off allows re-injecting a hash that already went through stop.

ScenarioMethodProfiler.start(result_of_a_previous_stop) leaves data.delete(:__start) returning nil, then finish - nil raises TypeError from the request path.

Fix — Return data unchanged when the key is absent.

2. Concurrency

HIGH — CON-lib/prometheus_exporter/instrumentation/method_profiler.rb:48

Problem — The profiler state lives in Thread.current[:_method_profiler], which is fiber-local, not thread-local.

Scenario — Measured: a value set in the root fiber reads back nil from inside an explicit Fiber (an Enumerator does see it; thread_variable_get crosses fibers). Under Falcon, async-http, Fiber.schedule, or any application code issuing its DB calls from a fiber, prof is nil in the patched method: the call falls through to super uninstrumented and the web payload ships with every sql/redis/memcache bucket missing — a metric that reads as "zero DB time", not as an error.

Fix — Use Thread.current.thread_variable_get/set, or a Fiber-storage-aware accessor.

MEDIUM — CON-lib/prometheus_exporter/client.rb:139

Problem — Check-then-act is not atomic on a concurrent Queue: @queue.length > @max_queue_size is evaluated, then @queue.pop is called with no guarantee the queue is still full.

Scenario — Measured: Queue#pop blocks indefinitely on an empty queue. Under load, if the background thread drains the queue entirely between the test and the pop, the pop blocks the calling thread — that is, a Rails request thread: the request hangs until another thread enqueues a message.

Fix@queue.pop(true) with a rescue ThreadError, or a bounded queue with a non-blocking pop.

MEDIUM — CON-lib/prometheus_exporter/instrumentation/periodic_stats.rb:20

Problem — The collection thread does not survive fork and nothing restarts it; started? returns false forever, with no warning and no log line.

Scenario — Measured: parent started?=true counter 6, child started?=false counter frozen at 6. Under Puma in cluster mode or Unicorn with preload_app, the master emits its metrics and no worker emits any. The README does document the restart (on_worker_boot line 294, after_fork lines 306 and 355, with the explicit note "be sure to run a new process instrumenter after fork"), which bounds the scope: the defect is not the absence of a mechanism but its total silence when the hook is forgotten.

Fix — Register a Process._fork hook that restarts in the child, or at minimum log a warning when start is called in a process whose thread is dead.

MEDIUM — CON-lib/prometheus_exporter/instrumentation/periodic_stats.rb:46

ProblemThread#join has no timeout, and Thread#wakeup only interrupts a sleep, not a blocking read; since start calls stop at line 16, the hang propagates to boot.

Scenario — Probed: with a worker_loop blocked on IO#read, stop had not returned after 2 s, whereas the same test with sleep(100) returned immediately. A SidekiqQueue instrumentation querying a Redis whose TCP connection is black-holed freezes application shutdown, or startup if start is called again.

Fixjoin bounded by frequency plus a grace period, then kill if the thread is still alive.

MEDIUM — CON-Dockerfile:1

ProblemARG RUBY_VERSION=3.1 contradicts the gemspec floor required_ruby_version >= 3.2.0, and CI passes only GEM_VERSION as a build arg.

Scenario — Verified by reading: the build-args block in ci.yml contains only GEM_VERSION, so the release image really does start from ruby:3.1-slim and runs gem install prometheus_exporter there, which rubygems refuses on an unsatisfied version floor. The gem is published and then the image job fails, leaving the latest tag stale, with nothing having been tested beforehand.

Fix — Raise ARG RUBY_VERSION to at least the gemspec floor, or pass it from the workflow.

LOW — CON-lib/prometheus_exporter/instrumentation/periodic_stats.rb:46

Problem — Window between the alive? check and the wakeup call.

Scenario — Probed: Thread#wakeup on a finished thread raises ThreadError. If the collection thread dies inside that window, the ThreadError escapes stop, hence escapes start, and aborts the initializer that was merely restarting collection.

Fix — Wrap the wakeup/join pair in a rescue ThreadError.

LOW — CON-lib/prometheus_exporter/instrumentation/periodic_stats.rb:16

Problem — Every subclass sets @worker_loop before calling super, which calls stop, and the running thread re-reads the class ivar on each iteration.

Scenario — Two successive Process.start calls with different labels and clients open a window during which the old thread executes the new closure: one sample goes out with the new labels through the new client while stop is still tearing down.

Fix — Stop first, assign afterwards.

LOW — CON-.github/workflows/linting.yml:28

Problem — The format check covers neither the extensionless Ruby files nor the gemspec.

Scenario — Verified: git ls-files '*.rb' yields 76 files, none of which is exe/prometheus_exporter or the gemspec, and the '*.rake'/'*.thor' patterns yield nothing. The CLI entry point, the gemspec, the Rakefile, the Guardfile and Appraisals are never format-checked, which is why exe/ uses single-quoted strings against the rest of the codebase's style.

Fix — Add those paths explicitly to the invocation.

LOW — CON-.github/workflows/ci.yml:64

Problem — The Dockerfile is only exercised by the publish job, gated on a new version — never on pull_request nor on the weekly schedule.

Scenario — This is precisely what lets the Ruby 3.1 / 3.2-floor contradiction sit in main unseen: the only run that would reveal it is the release run itself, after the gem has already been pushed to RubyGems.

Fix — Add a build-without-push step on the pull-request path.

3. Error model

CRITICAL — ERR-lib/prometheus_exporter/server/web_server.rb:136

Problem — The rescue in handle_metrics does a break, then the next two lines unconditionally rewrite the body and status to 200 OK, while the rest of the chunked stream is abandoned.

Scenario — Measured: a break from a block returns control to the calling method, which continues executing. A single malformed payload in the middle of a 25-second chunked session therefore silently loses every subsequent message in the batch (all types, all processes), while the client receives 200 OK. The collector_bad_metrics_total counter is incremented, but nothing surfaces it.

Fix — Return from the method after setting the error status, and do not abort the batch on one bad message — log it, count it, and keep processing the rest.

HIGH — ERR-lib/prometheus_exporter/server/sidekiq_stats_collector.rb:34

Problemmetric["stats"][name] dereferences a missing key at render time, not at ingest time.

Scenario — Measured: the payload {"type":"sidekiq_stats"} with no stats key is accepted without error by collect, then raises NoMethodError from prometheus_metrics_text. WebServer#metrics only rescues Timeout::Error, so /metrics returns 500 for all collectors as long as the bad sample lives. Ingest and detonation are separated in time, which makes this very hard to diagnose.

Fix — Validate the presence of stats at ingest, and skip the sample rather than raise at render.

HIGH — ERR-lib/prometheus_exporter/server/sidekiq_queue_collector.rb:40

Problem — Unguarded dereference of an absent payload key, under the global mutex, on the ingest path. Same class of defect at web_collector.rb:75, sidekiq_process_collector.rb:44, delayed_job_collector.rb:32, hutch_collector.rb:22, active_record_collector.rb:21, sidekiq_queue_collector.rb:41.

Scenario — Measured: {"type":"sidekiq_queue"} with no queues key raises NoMethodError: undefined method 'each' for nil; {"type":"web","status":200} with no default_labels raises undefined method 'merge' for nil; a delayed_job payload without latency raises TypeError: nil can't be coerced into Integer. Each one triggers the silent batch truncation described above.

Fix — Normalise and validate the payload at the top of each collect, and skip the invalid sample while incrementing a dedicated counter.

HIGH — ERR-lib/prometheus_exporter/server/web_server.rb:199

Problem — The htpasswd file is re-read and re-parsed on every request, and WEBrick can only read the DES crypt format.

Scenario — Measured: WEBrick::HTTPAuth::Htpasswd raises NotImplementedError ("MD5, SHA1 .htpasswd file not supported") on an MD5 file as well as on a bcrypt file. MD5 and bcrypt are what the htpasswd command produces by default or with -B. An operator following common usage therefore gets a failing /metrics on every scrape; and since NotImplementedError is a ScriptError, it escapes any downstream rescue =>.

Fix — Validate and load the file once at startup, abort with an explicit message if the format is unsupported, and memoize the auth object.

MEDIUM — ERR-lib/prometheus_exporter/instrumentation/sidekiq.rb:36

Problem — The arity test method_arity > 0 misclassifies any custom_labels method with an optional parameter, whose arity is negative.

Scenario — Measured: def custom_labels(msg, opts = {}) has arity -2, so the test fails and the method is called with no arguments: ArgumentError: wrong number of arguments (given 0, expected 1..2). This is raised from the middleware's ensure block, so it replaces the job's real exception or success: Sidekiq reports the job as failed for a reason unrelated to the job.

Fix — Test arity != 0, and wrap the ensure body in a rescue that logs instead of raising.

MEDIUM — ERR-lib/prometheus_exporter/instrumentation/delayed_job.rb:45

Problemstart is assigned after two statements that can raise, while the ensure at line 52 uses it unguarded.

Scenario — Probed: a raise before the assignment makes the ensure evaluate clock_gettime(...) - nil, and the original error is replaced by TypeError: nil can't be coerced into Float. Concretely, if job.handler raises during deserialization (a classic Delayed::Job failure mode for a payload referencing a removed class), the worker sees a TypeError from the metrics plugin instead of the real error.

Fix — Assign start as the first statement and guard the ensure.

MEDIUM — ERR-lib/prometheus_exporter/instrumentation/puma.rb:66

Problem — Five accumulations with no nil coercion: any key missing from the stats hash raises TypeError.

Scenario — A Puma version or run mode whose stats omit one of the expected keys sends the TypeError up to the PeriodicStats rescue, which logs one line and drops the entire Puma batch — permanently, since the key stays absent on every cycle. The failure presents as "the Puma metrics disappeared", with a single startup-era log line nobody re-reads.

Fix — Coerce each value, so that an unknown Puma layout degrades one gauge to zero instead of losing all the others.

MEDIUM — ERR-lib/prometheus_exporter/instrumentation/periodic_stats.rb:25

Problem — The rescue handler is itself unprotected: if the logger call raises, the exception escapes the loop and kills the thread, and since Thread.abort_on_exception is false, nothing is printed.

Scenario — Probed: a worker_loop that raises, combined with a logger whose #error raises (descriptor closed by logrotate, a Rails logger torn down at reload, client.logger returning nil), leaves started? false with zero output; collection stops permanently after a single transient error.

Fix — Protect the logger call with its own rescue and continue the loop.

MEDIUM — ERR-lib/prometheus_exporter.rb:15

ProblemOjCompat calls Oj although the file never requires "oj", and the client selects OjCompat from the bare :oj symbol with no detection.

Scenario — Measured: Client.new(json_serializer: :oj) without the oj gem installed raises NameError: uninitialized constant PrometheusExporter::OjCompat::Oj. Because serialization happens in the calling thread, that NameError fires inside the Sidekiq middleware's ensure on every single job, masking the real outcome — not in a background thread where it would merely be logged.

Fix — Route the client through detect_json_serializer so an unavailable Oj falls back to JSON.

MEDIUM — ERR-lib/prometheus_exporter/instrumentation.rb:4

Problem — None of the PeriodicStats subclasses requires the file that defines their parent class; they only work because instrumentation.rb loads it first.

Scenario — Measured: require "prometheus_exporter/instrumentation/puma" on its own raises NameError: uninitialized constant PrometheusExporter (puma.rb:6). The message differs from what the missing constant name would suggest, but the defect stands: the targeted require, which is the natural way to avoid loading all fifteen files, works for no instrumentation.

Fix — Add the missing require_relative at the top of each file.

MEDIUM — ERR-exe/prometheus_exporter:42

Problem — The JSON.parse for --label is unguarded, and .parse! has no rescue at all.

Scenario — Measured: --label 'env=prod' produces a JSON::ParserError and a six-frame backtrace through optparse, instead of a usage message. Worse, a syntactically valid but non-Hash value (--label '"oops"') is accepted at startup and only explodes at scrape time: the process logs its startup, stays up, and /metrics returns a WEBrick 500 page. Prometheus sees the target as down with no clue in the exporter's own log.

Fix — Rescue the parse error and abort with a message, then check the value is a Hash.

MEDIUM — ERR-exe/prometheus_exporter:24

Problem--port is coerced but never bounds-checked.

Scenario — Measured: -p 99999 logs "Starting prometheus exporter on localhost:99999" then dies with a Socket::ResolutionError, which reads as a DNS problem rather than an out-of-range port.

Fix — Validate membership in 1..65535 in the callback.

LOW — ERR-exe/prometheus_exporter:133

Problem — The trailing sleep installs no SIGTERM or SIGINT trap, so Runner#stop and WEBrick's clean shutdown are never reached.

Scenario — Measured: SIGTERM on a process blocked in sleep exits 143, SIGINT exits 130 after printing a backtrace. A docker stop during a scrape therefore cuts the connection mid-response, and Prometheus logs a scrape error.

Fix — Trap TERM and INT to call runner.stop and exit cleanly.

LOW — ERR-lib/prometheus_exporter/server/collector_base.rb:7

Problemprocess and prometheus_metrics_text are empty stubs returning nil, where TypeCollector raises "must implement" — a contract drift between the two base classes.

Scenario — A custom collector that forgets process swallows every payload silently; /metrics returns an empty body and collector_working 0, with no error logged.

Fix — Raise in both stubs, as TypeCollector does.

LOW — ERR-lib/prometheus_exporter/instrumentation/puma.rb:49

Problemworker["last_status"].empty? assumes the key is always a Hash.

Scenario — During a phased restart a freshly forked worker can appear in the list before its first status ping; if its last_status is nil, the NoMethodError kills the whole collection cycle, with the same all-or-nothing failure mode as above.

Fix — Test for nil before testing for emptiness.

4. Performance, memory, CPU

HIGH — PERF-lib/prometheus_exporter/server/web_collector.rb:77

Problem — Neither the WebCollector's metrics nor those of the job collectors are subject to a TTL or a cardinality cap, although their labels come entirely from the unauthenticated network. Same sites at sidekiq_collector.rb:19, delayed_job_collector.rb:25, hutch_collector.rb:17, shoryuken_collector.rb:18.

Scenario — A sender — or anyone who can reach port 9394 — emits web payloads whose status or custom_labels vary; each distinct value creates a permanent entry, and for a Summary, two buffers of raw floats. Measured: 600,000 observations cost 13.6 MB of RSS. No eviction, no cap, no reset.

Fix — Cap the number of series per metric with a drop counter, and purge series not observed for N seconds.

HIGH — PERF-lib/prometheus_exporter/instrumentation/delayed_job.rb:17

Problem — Two COUNT queries are issued on every job invocation, from the around hook.

Scenario — A worker draining 100 jobs/s against a 2M-row delayed_jobs table executes 200 extra COUNTs per second; the predicate used has no covering index in the stock schema, so these are sequential scans that can cost more than the jobs themselves.

Fix — Move these counts out of the per-job hook and sample them periodically.

HIGH — PERF-lib/prometheus_exporter/instrumentation/sidekiq_process.rb:36

Problemidentity is pushed as a Prometheus label, but a Sidekiq identity is hostname:pid:randomhex, hence unique per process boot.

Scenario — 10 workers restarted on each of 20 daily deploys create 200 label sets per day that never go away while the collector holds them; after a month the scrape carries thousands of dead series per gauge, and every PromQL aggregation walks them.

Fix — Drop identity and tag from the labels, or gate them behind an option documented as high-cardinality.

MEDIUM — PERF-lib/prometheus_exporter/metric/summary.rb:106

Problem — Every observation is stored twice, in both buffers.

Scenario — Measured: after 1000 observations, buffer0 and buffer1 each hold 1000 values. A Summary's memory footprint is therefore double what the rotation window suggests, before label cardinality is even counted.

Fix — Feed only the current buffer and compute quantiles over the union of both at render time.

MEDIUM — PERF-lib/prometheus_exporter/instrumentation/active_record.rb:55

ProblemObjectSpace.each_object walks the entire heap on every collection cycle, and returns pools that are unreachable but not yet collected.

Scenario — A Rails app with a 4M-object heap pays a full traversal every 30 s in the metrics thread, which holds the GVL throughout; worse, after an establish_connection or a shard switch the superseded pool is still in the heap and two metrics carrying the same pool_name arrive in the same batch.

Fix — Enumerate the connection handler's public pool list instead of sweeping the heap.

MEDIUM — PERF-lib/prometheus_exporter/instrumentation/good_job.rb:18

Problem — Seven COUNT queries per cycle, three of them unbounded full-table counts.

Scenario — A good_jobs table retaining 5M finished rows (GoodJob's cleanup is opt-in) causes three COUNTs scanning millions of rows every 30 s while holding a pooled connection; with the default pool of 5, this starves request threads.

Fix — Rely on GoodJob's cumulative counters, or make these counts opt-in and document their cost.

MEDIUM — PERF-lib/prometheus_exporter/instrumentation/process.rb:89

Problem — A second full heap sweep per cycle, followed by a heap_stats call on every context found.

Scenario — An application loading MiniRacer with a large heap undergoes a full traversal plus N V8 calls every 30 s, in a thread holding the GVL: request latency spikes on a fixed period.

Fix — Keep a weak-reference registry, or gate this collection behind an explicit option.

MEDIUM — PERF-lib/prometheus_exporter/server/good_job_collector.rb:35

Problem — The gauge hash is persistent and never reset, unlike the Sidekiq collectors which call reset!. Same site at resque_collector.rb:33.

Scenario — Measured: host a sends scheduled 5 then disappears, host b sends scheduled 7, and the rendering still contains good_job_scheduled{host="a"} 5 indefinitely — a dead value presented as live, plus unbounded memory growth driven by custom_labels cardinality.

Fix — Reset the gauges at the top of metrics, as the Sidekiq collectors do.

LOW — PERF-lib/prometheus_exporter/instrumentation/process.rb:47

Problem — The page size is obtained by spawning a subprocess through backticks.

Scenario — The first collection cycle in every process forks and execs getconf — a fork of a possibly multi-gigabyte Ruby process from a background thread, which on a memory-pressured host can fail; the rescue then falls back to 4096, which is wrong on arm64 Linux (16 KiB pages), under-reporting RSS by a factor of 4.

Fix — Read the value via Etc.sysconf with no subprocess.

5. API design

HIGH — API-prometheus_exporter.gemspec:27

Problem — Only webrick is declared as a dependency, although the library does require "logger" unconditionally.

Scenario — Measured on Ruby 4.0.6: logger, json, psych, timeout and stringio are no longer default gems (default_gem? returns false for all five). A Ruby 4.0 application without Rails that installs the gem and loads prometheus_exporter/server gets a LoadError at boot. CI hides this because each of the four appraisal gemfiles pulls in activesupport, which depends on logger.

Fix — Declare logger as a runtime dependency, and decide on the other four based on the requires actually present in lib/.

MEDIUM — API-lib/prometheus_exporter/server/collector_base.rb:11

Problem — The documented signature takes an argument while the server calls the method with none.

Scenario — Measured: a custom collector inheriting from CollectorBase without redefining the method gets ArgumentError: wrong number of arguments (given 0, expected 1) on the first scrape, and /metrics returns 500. The shipped base class is therefore literally unusable as-is.

Fix — Remove the parameter from the signature.

MEDIUM — API-lib/prometheus_exporter.rb:36

Problemdetect_json_serializer is used only by the collector; the client never auto-detects.

Scenario — An application with oj in its Gemfile and no explicit option serialises every payload with stdlib JSON while the collector in the same deployment parses with Oj. The auto-detection the gem advertises applies to only one half of the pipeline, and the busiest half is the one that misses out.

Fix — Route the client through detect_json_serializer, or document that the client does not auto-detect.

LOW — API-lib/prometheus_exporter/instrumentation/periodic_stats.rb:5

Problem — The signature swallows any positional argument and any unknown keyword.

ScenarioPeriodicStats.start(frequency: 30, lables: {env: "prod"}) — a typo on labels — is accepted, runs, and ships metrics with no labels: no ArgumentError, no log.

Fix — Drop the catch-all and let Ruby raise on an unknown keyword.

LOW — API-lib/prometheus_exporter/instrumentation/active_record.rb:19

Problemconfig_labels.map! mutates the caller's array in place.

Scenario — Probed: passing a frozen literal raises FrozenError at boot; passing a non-frozen array silently rewrites the caller's contents, which surprises any code reusing that array elsewhere.

Fix — Use the non-destructive form.

LOW — API-lib/prometheus_exporter.rb:11

ProblemDEFAULT_LABEL is an unfrozen Hash shared process-wide; the frozen_string_literal pragma does not freeze collections.

Scenario — The common reflex for adding a global label is to write into this constant, which changes the default for every metric in the process, including ones registered before the mutation, with no way to undo it.

Fix — Freeze the constant.

LOW — API-lib/prometheus_exporter/instrumentation/hutch.rb:7

Problem — The client is hardcoded to Client.default with no injection parameter — the only instrumentation in the set with this limitation.

Scenario — An application using a non-default client (custom host, port or labels, or a LocalClient in tests) wires the tracer as the README instructs, and all Hutch metrics still go to the default client; in tests they cannot be captured.

Fix — Accept a client at configuration time, as Shoryuken does.

LOW — API-lib/prometheus_exporter/server/process_collector.rb:53

Problem — The only collector in the set to export unprefixed names (rss, heap_free_slots, marking_time…), where the other thirteen prefix theirs.

Scenario — With no default_prefix configured, the exporter exposes a series literally named rss, impossible to select by family in a recording rule and liable to collide with any other source aggregated into the same federated job.

Fix — Prefix them, with a dual-publication period.

NIT — API-lib/prometheus_exporter/server/puma_collector.rb:5

Problem — The constant is named MAX_PUMA_METRIC_AGE where the eight other container-backed collectors use MAX_METRIC_AGE.

Scenario — An operator who overrides the expected constant name to lengthen the TTL gets no effect and no warning — they have created an unused constant.

Fix — Rename it.

NIT — API-lib/prometheus_exporter/instrumentation/sidekiq.rb:72

Problem — The private marker has no effect on the singleton methods that follow it.

Scenario — A reader trusts the marker and treats these methods as internal, while they are public and actually called from outside; conversely a refactor that "fixes" the visibility would break that call.

Fix — Use private_class_method, or drop the marker.

6. Security

CRITICAL — SEC-lib/prometheus_exporter/metric/base.rb:84

Problem — Label keys are interpolated verbatim into the exposition text; only values go through escape_value.

Scenario — Measured: an unauthenticated POST to /send-metrics carrying a label key containing a quote and a newline renders m2{bad"key="v"} 1 — a fully forged series injected into the scrape. An attacker, or a buggy sender, can thereby fabricate arbitrary metric values: SLO falsification, alert suppression, or dashboard poisoning. No faulty configuration is required.

Fix — Validate keys against the Prometheus identifier format on ingest and reject the payload, or escape them in labels_text.

CRITICAL — SEC-lib/prometheus_exporter/metric/base.rb:93

Problem — The metric name and the help field are interpolated raw into the # HELP and # TYPE lines, and a registered name never expires.

Scenario — Measured: a help containing a newline renders # HELP legit help followed, in the clear on the next lines, by # TYPE injected counter and injected 999. Since register_metric_unsafe records the name permanently in @metrics, a single payload corrupts the exposition for the life of the process: Prometheus fails to parse the scrape, and every metric from the exporter is lost silently, with HTTP 200.

Fix — Validate name and help before registration, refuse any control character, and log the rejection.

HIGH — SEC-exe/prometheus_exporter:75

Problem--tls-cert-file and --tls-key-file are accepted independently, and the server only enables TLS if both are present, with no warning otherwise.

Scenarioprometheus_exporter --auth htpasswd --tls-cert-file cert.pem, with the key flag omitted or misspelled, starts up logging only its listen address and serves plaintext. The operator believes /metrics is HTTPS while basic-auth credentials and metrics cross the network in the clear. This requires a configuration mistake, hence HIGH rather than CRITICAL per the severity line stated above.

Fix — Abort with exit 1 if exactly one of the two flags is supplied.

HIGH — SEC-lib/prometheus_exporter/client.rb:268

Problem — Client-side encryption requires all three TLS files, so configuring only the CA leaves traffic in plaintext with no error.

Scenario — The usual client-side TLS configuration — verify the server without presenting a client certificate — consists of supplying only the CA file. Here use_ssl? then returns false and the client emits in plaintext: the user who explicitly configured TLS does not have it. The feature is mentioned nowhere in the README (searching for tls_ca_file, tls_cert_file, tls_key_file: zero hits), so nothing signals that mutual TLS is mandatory.

Fix — Enable TLS as soon as the CA file is supplied, require cert and key to come as a pair, and document it.

HIGH — SEC-lib/prometheus_exporter/middleware.rb:130

Problem — A malformed X-Amzn-Trace-Id header raises IndexError from the very first line of call, before the application is invoked.

Scenario — Measured: the header Root=abc, with no dash, produces IndexError: index 1 outside of array bounds. fetch raises where the chain's &. only guards against nil. Since measure_queue_time is the first statement of call and is unguarded, any client able to set that header kills the request; on a deployment where the header is not rewritten by the load balancer, this is a one-header-per-request denial of service.

Fix — Replace fetch with an indexed access and wrap all of measure_queue_time in a rescue returning nil.

HIGH — SEC-lib/prometheus_exporter/middleware.rb:124

Problem — The content of X-Request-Start is converted without validation and feeds a duration metric directly.

Scenario — Measured: the header t=garbage produces a queue_time of 1.79 billion seconds, which is not negative and is therefore recorded. The sum of http_request_queue_duration_seconds is corrupted for the life of the process, and the quantiles with it. The header is client-supplied: the corruption is remotely triggerable, silent, and not repairable without a restart.

Fix — Reject any value that does not match the expected format, and bound the result to a plausible duration.

HIGH — SEC-.github/workflows/ci.yml:11

Problemcontents: write and packages: write are declared workflow-wide, so the build job — which runs on pull_request and executes PR-supplied code — inherits a write-scoped token, which checkout persists into the git config.

Scenario — Verified by reading: the permissions block is at workflow level, the build job does trigger on pull_request, and no checkout sets persist-credentials: false. A same-repo branch PR adding a Rake task prerequisite that reads the git config obtains a token able to push to main; since a push to main triggers publication, that amounts to an arbitrary release under the project's name.

Fix — Move permissions to job level (read-only on build), and set persist-credentials: false on the build checkout.

MEDIUM — SEC-README.md:924

Problem — The documentation recommends creating the htpasswd file with the -d flag, i.e. DES crypt.

Scenario — Measured: DES crypt truncates to 8 significant characters ("motdepasse-tres-long".crypt("ab") equals "motdepas".crypt("ab")). An operator following the README and choosing a long password believes they have its full strength when only the first 8 characters count. It is nevertheless the only format WEBrick accepts (see ERR-web_server.rb:199), so the recommendation cannot be fixed without changing the authentication mechanism.

Fix — Replace WEBrick basic auth with a mechanism accepting bcrypt, or explicitly document the 8-character limit and recommend fronting the exporter with an authenticating proxy.

MEDIUM — SEC-lib/prometheus_exporter/server/web_server.rb:109

Problem — Authentication, when configured, protects only /metrics; /send-metrics is always open and no option closes it.

Scenario — The README presents authentication as covering "your /metrics route", which is accurate but suggests the exporter is protected. Yet /send-metrics is the write surface, and it is what makes the two escaping defects above exploitable. The official Docker image moreover listens on all interfaces (ENTRYPOINT with -b ANY).

Fix — Apply authentication to both routes, or provide a dedicated option, and document that the ingest port must stay on a trusted network.

MEDIUM — SEC-gemfiles/ar_71.gemfile:5

Problem — The appraisal dependency is pulled from a git repository with no ref, tag or branch, and no lockfile is tracked.

Scenario — Every CI run resolves that third-party repository's default branch head; a force-push or an upstream compromise executes arbitrary Ruby in the build job, which holds precisely the write-scoped token from the finding above. Identical sites in the three other gemfiles and in the Gemfile.

Fix — Pin by SHA, or return to the released gem.

MEDIUM — SEC-.github/workflows/ci.yml:34

Problem — No action is pinned by commit SHA — checkout, setup-ruby, publish-rubygems-action, the three Docker actions and login-action all use mutable tags.

Scenario — A tag is repointed on the publish action; the next merge to main runs the new code in the job that has the RubyGems API key in its environment, and the key is exfiltrated without a single commit touching this repository.

Fix — Pin each uses: to a full SHA with the version in a trailing comment; same treatment in the lint workflow.

MEDIUM — SEC-lib/prometheus_exporter/server/collector.rb:82

Problem — Metric options coming from the network are symbolised and passed straight to the Summary and Histogram constructors.

Scenario — A payload can therefore set quantiles or buckets to an arbitrarily large array, which the rendering will sort and walk on every scrape under the global mutex. The symbol-DoS risk, by contrast, was ruled out by probe: dynamic symbols are collected.

Fix — Validate the type and size of the options before passing them on.

NIT — SEC-prometheus_exporter.gemspec:26

Problem — No metadata block: rubygems_mfa_required is unset.

Scenario — Publication is automated with a long-lived API key; with no second-factor requirement, a leaked key alone suffices to push a malicious version.

Fix — Declare rubygems_mfa_required, along with source_code_uri and changelog_uri.

NIT — SEC-Dockerfile:6

Problem — The apt cache is left in the layer, the apt porcelain is used in a script, and no USER is set.

Scenario — An image scanner reports an image running as root with a stale package cache; a remote code execution in WEBrick would land with uid 0.

Fix — Use apt-get with --no-install-recommends, purge the lists, and switch to an unprivileged user.

7. Test coverage

HIGH — TEST-test/test_helper.rb:6

Problem — The entire "malformed payload" class of defect is untested: 3 assert_raises across 27 test files, none on an incomplete payload sent to a collector.

Scenario — Verified: the three occurrences cover WrongInheritance and the Gauge guards. The eleven findings in the "Error model" section describe payloads the collector either accepts or raises on, and not one is covered. Fixing any of them would be protected by no regression test.

Fix — Add a battery of degraded payloads (missing key, null value, unexpected type) per collector type, asserting that no exception escapes and that a drop counter advances.

MEDIUM — TEST-exe/prometheus_exporter:1

Problem — The CLI entry point has zero coverage.

Scenario — Verified: no reference to exe/ in test/ or the Rakefile, and runner_test.rb injects doubles straight into Runner.new, bypassing exactly the ObjectSpace discovery, the JSON label parsing, the auth-file check and the exit codes. Every CLI defect in this report therefore sails through a green 16-cell matrix.

Fix — Add a test spawning the binary as a subprocess with fixture collector files, asserting exit codes and stderr.

MEDIUM — TEST-Gemfile:22

Problem — The JRuby exclusion condition is inverted by an operator-precedence bug, so raindrops is never installed and the Unicorn instrumentation is never loadable in CI.

Scenario — Measured: !RUBY_ENGINE == "jruby" evaluates to false where RUBY_ENGINE != "jruby" is true, and raindrops is absent from the generated gemfiles (count 0). instrumentation/unicorn.rb requires raindrops inside a silent begin/rescue LoadError: the missing gem breaks nothing, it merely means the Unicorn code is never exercised by any of the 16 matrix cells.

Fix — Fix the condition and regenerate the appraisal gemfiles.

MEDIUM — TEST-lib/prometheus_exporter/instrumentation/hutch.rb:1

Problem — Four files have no reference of any kind in test/.

Scenario — Verified by searching for the class name and then the keyword: instrumentation/good_job.rb, instrumentation/hutch.rb, instrumentation/periodic_stats.rb and server/hutch_collector.rb are cited nowhere; "hutch" appears in no test file. periodic_stats.rb is the parent class of eight instrumentations and carries five findings in this report, including fork handling and the stop hang.

Fix — Cover periodic_stats.rb first, since its behaviour is inherited by eight subclasses.

LOW — TEST-test/test_helper.rb:6

Problem — SimpleCov is started with no minimum threshold, no drop refusal and no CI formatter, and the workflow never reads the coverage directory, which is itself gitignored.

Scenario — A PR removing the last assertions covering a collector produces a report nobody opens and a green build; the gem adopted simplecov with no enforcing effect.

Fix — Set minimum_coverage to the current value and refuse coverage drops.

LOW — TEST-test/custom_type_collector.rb:8

Problem — Dead fixture, referenced nowhere, implementing observe where the interface is collect.

Scenario — Verified: no occurrence of custom_type_collector in test/. A contributor reaching for the repository's only type-collector example copies observe, and their collector never receives any message, silently.

Fix — Wire it into a test exercising --type-collector end to end, or delete it.

8. Documentation

HIGH — DOC-examples/custom_collector.rb:3

Problem — The shipped example inherits from a constant that exists nowhere.

Scenario — Verified: searching the whole repository for BaseCollector returns only this line, the real class being CollectorBase, and the code index knows no symbol by that name. The README instructs running the exporter with this file as --collector: the require raises NameError before anything starts. No CI job loads examples/, so the breakage is permanent.

Fix — Correct the name and add a test that loads every file under examples/.

HIGH — DOC-README.md:1014

Problem — The README says to pull the image from Docker Hub while CI publishes only to the GitHub registry.

Scenario — Verified: the README writes docker pull discourse/prometheus_exporter:latest while the workflow sets DOCKER_REPO to ghcr.io/discourse/prometheus_exporter and pushes only there. A user following the Docker section pulls either nothing or an image unrelated to this pipeline.

Fix — Correct the section, or add a Docker Hub push.

MEDIUM — DOC-README.md:801

Problembin/prometheus_exporter is given as the launch command in three places, but that file does not exist.

Scenario — Verified: git ls-files bin returns only appraisal, rake, rubocop and stree, and the gemspec's bindir is exe. The command copied from the README fails with "No such file or directory", and the same wrong path appears in the docker-compose snippet.

Fix — Use bundle exec prometheus_exporter for a checkout and the bare command inside the image.

MEDIUM — DOC-README.md:869

Problem — The documented CLI help block omits three real options: --logger-path, --tls-key-file and --tls-cert-file.

Scenario — Verified: searching the README for tls, ssl and logger-path returns no hits, while the CHANGELOG presents TLS as the headline feature of 2.3.1. The feature is therefore undiscoverable other than by reading the source, which compounds the two TLS fail-open findings above.

Fix — Regenerate the block from the actual --help output and add a TLS subsection.

MEDIUM — DOC-README.md:43

Problem — The Requirements section states a minimum of Ruby 3.0.0 while the gemspec requires 3.2.0.

Scenario — Verified: the gemspec carries required_ruby_version >= 3.2.0. A team on Ruby 3.0 reads the section, adds the gem, and installation fails on a resolution error naming no supported version; the README is the only place claiming 3.0 works.

Fix — Align it with the gemspec and point to it as the source of truth.

MEDIUM — DOC-CHANGELOG:8

Problem — The file has no "Unreleased" section although five commits have landed since the last version bump, one of them a metric-type change.

Scenario — Verified: the head commit builds shoryuken_job_duration_seconds from default_aggregation, which turns the metric from a summary into a histogram when -g is set — a breaking change for existing dashboards and recording rules. Since publication triggers on a version bump, the next release will ship with a changelog that says nothing about it.

Fix — Open an "Unreleased" section now and add it to the release checklist.

MEDIUM — DOC-bench/bench.rb:5

Problem — The benchmark and the binary do not run from a checkout, because lib/ is not added to the load path.

Scenario — Verified: ruby bench/bench.rb fails with a LoadError on prometheus_exporter/server/metrics_container, because type_collector.rb uses an absolute require; the binary fails identically. The README nonetheless presents /bench as runnable. The installed gem masks the defect, since rubygems activates the spec's lib path.

Fix — Add lib/ to the front of the load path in both files.

NIT — DOC-README.md:6

Problem — The table of contents omits the exporter process configuration section and the healthcheck section.

Scenario — The complete CLI flag reference is the section a first-time operator needs most, and it is the only one unreachable from the table of contents of a 1060-line README.

Fix — Add both anchors.

9. Remediation order

Phased by severity, ordered within a phase by dependency and file batching, with security weighted up at equal severity.

Phase 1 — stop the silent loss (do these together; all three compose)

  1. ERR-lib/prometheus_exporter/server/web_server.rb:136 — stop answering 200 after a failed batch, and stop truncating the batch on a bad message. Root cause: while this stands, every defect in phase 2 stays invisible in production.
  2. SEC-lib/prometheus_exporter/metric/base.rb:84 — escape or validate label keys.
  3. SEC-lib/prometheus_exporter/metric/base.rb:93 — validate name and help before permanent registration. Same file as #2, do it in the same pass.
  4. COR-lib/prometheus_exporter/server/delayed_job_collector.rb:24 (plus hutch_collector.rb:17, shoryuken_collector.rb:18, sidekiq_collector.rb:19) — unify label key types. Depends on #2: both touch label construction, so handle them together.

Phase 2 — degraded payloads (depends on #1 to be observable)

  1. ERR-lib/prometheus_exporter/server/sidekiq_queue_collector.rb:40 and the six same-class sites (web_collector.rb:75, sidekiq_process_collector.rb:44, delayed_job_collector.rb:32, hutch_collector.rb:22, active_record_collector.rb:21, sidekiq_queue_collector.rb:41) — normalise the payload at the top of each collect. One pass, seven files.
  2. ERR-lib/prometheus_exporter/server/sidekiq_stats_collector.rb:34 — move the validation from render time to ingest time. Extends #5 within the same collector family.
  3. TEST-test/test_helper.rb:6 — the degraded-payload battery, written with #5 and #6 rather than after, otherwise nothing guarantees coverage.
  4. COR-lib/prometheus_exporter/server/delayed_job_collector.rb:49 — stop using nil as a series-deletion order. Same file as #5.

Phase 3 — the instrumented application

  1. COR-lib/prometheus_exporter/instrumentation/method_profiler.rb:75 — fix the patch idempotence guard. Root cause of the harshest incident in the set.
  2. CON-lib/prometheus_exporter/instrumentation/method_profiler.rb:48 — switch to thread variables. Same file as #9.
  3. SEC-lib/prometheus_exporter/middleware.rb:130 — guard the trace-header parsing.
  4. SEC-lib/prometheus_exporter/middleware.rb:124 — validate the queue-time header. Same file as #11.
  5. PERF-lib/prometheus_exporter/instrumentation/delayed_job.rb:17 — move the counts out of the per-job hook.
  6. PERF-lib/prometheus_exporter/instrumentation/sidekiq_process.rb:36 — drop the unbounded-cardinality labels.
  7. PERF-lib/prometheus_exporter/server/web_collector.rb:77 — cap cardinality on the server side. Complementary to #14: one dries up the source, the other bounds the collector.

Phase 4 — delivery chain (independent of phases 1–3, can proceed in parallel)

  1. SEC-.github/workflows/ci.yml:11 — move permissions to job level and stop persisting credentials. Do before #17 and #18, which touch the same file.
  2. SEC-.github/workflows/ci.yml:34 — pin actions by SHA.
  3. SEC-gemfiles/ar_71.gemfile:5 — pin the appraisal git dependency.
  4. CON-Dockerfile:1 — align the Ruby version with the gemspec floor.
  5. CON-.github/workflows/ci.yml:64 — build the image on pull requests. Validates #19 and prevents a recurrence.
  6. API-prometheus_exporter.gemspec:27 — declare logger as a runtime dependency.
  7. TEST-Gemfile:22 — fix the JRuby condition and regenerate the gemfiles; until then the Unicorn instrumentation stays untested.
  8. SEC-prometheus_exporter.gemspec:26 — require a second factor for publication.
  9. NIT-ci.yml:45 — bound the publish job's duration.
  10. NIT-Dockerfile:6 — harden the image.

Phase 5 — operator surface

  1. SEC-exe/prometheus_exporter:75 — abort if only one of the two TLS flags is supplied.
  2. SEC-lib/prometheus_exporter/client.rb:268 — fix client-side TLS activation.
  3. DOC-README.md:869 — document the TLS options. Depends on #26 and #27: document the fixed behaviour, not the current one.
  4. ERR-lib/prometheus_exporter/server/web_server.rb:199 — load and validate the auth file once, at startup.
  5. SEC-README.md:924 — decide on the authentication mechanism. Depends on #29: it is while fixing that one that you find only DES crypt passes.
  6. SEC-lib/prometheus_exporter/server/web_server.rb:109 — decide on protecting the ingest route.
  7. COR-exe/prometheus_exporter:103-108 and COR-exe/prometheus_exporter:121-126 — replace both ObjectSpace sweeps. Same file, same mechanism, one pass.
  8. ERR-exe/prometheus_exporter:42 — guard the label parsing and check the type.
  9. ERR-exe/prometheus_exporter:24 — bound the port.
  10. ERR-exe/prometheus_exporter:133 — trap the shutdown signals.
  11. TEST-exe/prometheus_exporter:1 — the binary's test, written alongside #32 to #35.

Phase 6 — metric correctness

  1. COR-lib/prometheus_exporter/metric/summary.rb:91 — expire quantiles at render time.
  2. PERF-lib/prometheus_exporter/metric/summary.rb:106 — stop double-storing. Same file as #37.
  3. COR-lib/prometheus_exporter/server/unicorn_collector.rb:29 — include pid and hostname in the labels.
  4. COR-lib/prometheus_exporter/server/sidekiq_stats_collector.rb:37 — honour custom_labels.
  5. COR-lib/prometheus_exporter/server/process_collector.rb:31 — include metric_labels in the filter.
  6. PERF-lib/prometheus_exporter/server/good_job_collector.rb:35 and resque_collector.rb:33 — reset the gauges.
  7. COR-lib/prometheus_exporter/server/good_job_collector.rb:29 and resque_collector.rb:28 — fix the custom_labels default. Same file as #42.
  8. COR-lib/prometheus_exporter/server/puma_collector.rb:16 — export the gauge unconditionally.
  9. COR-lib/prometheus_exporter/instrumentation/unicorn.rb:55 — inspect the pgrep exit status.
  10. COR-lib/prometheus_exporter/instrumentation/delayed_job.rb:43 — make the job-name fallback reachable.
  11. ERR-lib/prometheus_exporter/instrumentation/delayed_job.rb:45 — assign the start timestamp first and guard the ensure. Same file as #46, same pass.
  12. ERR-lib/prometheus_exporter/instrumentation/sidekiq.rb:36 — fix the arity test and protect the ensure body, so the middleware stops masking real job outcomes.
  13. COR-lib/prometheus_exporter/instrumentation/shoryuken.rb:16 — initialise the shutdown flag.
  14. ERR-lib/prometheus_exporter/instrumentation/puma.rb:66 and ERR-lib/prometheus_exporter/instrumentation/puma.rb:49 — coerce the stats values.
  15. PERF-lib/prometheus_exporter/instrumentation/active_record.rb:55 — stop sweeping the heap.
  16. PERF-lib/prometheus_exporter/instrumentation/good_job.rb:18 — lighten the counts.
  17. PERF-lib/prometheus_exporter/instrumentation/process.rb:89 and PERF-lib/prometheus_exporter/instrumentation/process.rb:47 — second heap sweep and subprocess.

Phase 7 — lifecycle and contracts

  1. CON-lib/prometheus_exporter/instrumentation/periodic_stats.rb:20 — signal the thread's death after fork.
  2. CON-lib/prometheus_exporter/instrumentation/periodic_stats.rb:46 (unbounded join), periodic_stats.rb:46 (wakeup window) and periodic_stats.rb:16 (stop/assign ordering) — same file, one pass.
  3. ERR-lib/prometheus_exporter/instrumentation/periodic_stats.rb:25 — protect the error handler.
  4. TEST-lib/prometheus_exporter/instrumentation/hutch.rb:1 — cover periodic_stats.rb first, since #54 to #56 are inherited by eight subclasses.
  5. ERR-lib/prometheus_exporter/instrumentation.rb:4 — fix the standalone require.
  6. ERR-lib/prometheus_exporter.rb:15 and API-lib/prometheus_exporter.rb:36 — serializer consistency between client and collector. Same file.
  7. CON-lib/prometheus_exporter/client.rb:139 — non-blocking pop.
  8. API-lib/prometheus_exporter/server/collector_base.rb:11 and ERR-lib/prometheus_exporter/server/collector_base.rb:7 — fix the signature and the stubs. Same file.
  9. DOC-examples/custom_collector.rb:3 and COR-examples/custom_collector.rb:10 — fix the example. Depends on #61.
  10. SEC-lib/prometheus_exporter/server/collector.rb:82 — validate options coming from the network.

Phase 8 — documentation and finishing

  1. DOC-README.md:1014 — the image registry.
  2. DOC-README.md:801 — the binary path.
  3. DOC-README.md:43 — the minimum Ruby version.
  4. DOC-CHANGELOG:8 — the Unreleased section.
  5. DOC-bench/bench.rb:5 — the load path.
  6. CON-.github/workflows/linting.yml:28 — widen the format check.
  7. API-lib/prometheus_exporter/server/process_collector.rb:53 — prefix the process metric names.
  8. API-lib/prometheus_exporter/instrumentation/periodic_stats.rb:5, API-lib/prometheus_exporter/instrumentation/active_record.rb:19, API-lib/prometheus_exporter.rb:11, API-lib/prometheus_exporter/instrumentation/hutch.rb:7 — the four minor design findings.
  9. COR-lib/prometheus_exporter/instrumentation/method_profiler.rb:37 — the hand-off guard.
  10. API-lib/prometheus_exporter/server/puma_collector.rb:5, API-lib/prometheus_exporter/instrumentation/sidekiq.rb:72, DOC-README.md:6, TEST-test/custom_type_collector.rb:8, TEST-test/test_helper.rb:6 (SimpleCov threshold) — the finishing touches.

Hypotheses checked and dismissed

  1. Symbol-table exhaustion DoS via symbolize_keys on network-supplied keys — dismissed by probe: Symbol.all_symbols stays at 3811 after 100,000 conversions and a GC.start. Dynamic symbols have been collected since Ruby 2.2.
  2. Cross-request MethodProfiler contamination when the application raises and stop is never reached — dismissed by probe: start unconditionally overwrites the thread state at the top of every request. The hash also only grows by the number of distinct categories, at most three.
  3. Unsafe YAML deserialization in the Sidekiq middleware — dismissed: the gemspec floor of Ruby 3.2 implies Psych 4+, where YAML.load is safe mode. Probed on psych 5.4.0: a !ruby/object: payload raises Psych::DisallowedClass, and the code rescues precisely that exception, confirming the safe behaviour is the expected nominal path.
  4. Command injection in the Unicorn instrumentation's pgrep call — dismissed: the interpolated value goes through File.read(...).to_i, so it is an Integer. The real defect at that site is the unchecked exit status, reported separately.
  5. Data race between ingest and render — dismissed: process_hash and prometheus_metrics_text take the same mutex, and no collector exposes state outside those two paths. Public accessors would allow a bypass, but no caller in the repository uses them outside tests.
  6. Summary rendering CPU cost exceeding the 2-second timeout — dismissed by measurement: 100 series of 6000 observations, i.e. 600,000 sorted values, render in 0.076 s. Roughly 16M observations would be needed to reach the threshold. The real cost is memory, and it is reported as such.
  7. Certificate verification disabled on the client — dismissed by reading: the client's SSL context does set VERIFY_PEER. The defect at that site is the three-file requirement, not the verification.
  8. spec.files missing a file loaded at runtime — dismissed: git ls-files lib returns no file without a .rb extension, and the inventory of requires in lib/ names no data file.
  9. bin/rake overwriting the matrix's BUNDLE_GEMFILE, making CI test the same ActiveRecord sixteen times — dismissed: the binstub uses a conditional assignment, which leaves the workflow's value in place.
  10. The suite not actually running in CI — dismissed: the workflow calls bin/rake, whose default task is test, which covers all 26 tracked _test.rb files. No test file omits the helper, and SimpleCov starts before the library is loaded.
  11. The trailing sleep bypassing at_exit handlers on SIGTERM — dismissed by probe: the handlers do run, exit 143. The real defect is the absence of a trap calling runner.stop, reformulated accordingly.
  12. An empty ARG GEM_VERSION= breaking a local image build — dismissed: an empty --version= is ignored by rubygems and the install succeeds.
  13. json missing from the gemspec just like logger — dismissed in practice: under Bundler with an empty Gemfile, require "json" succeeds while require "logger" raises. Only logger produces an observable failure, hence a targeted finding rather than a generic one.
  14. Two PeriodicStats subclasses interfering through class-level state — dismissed by probe: class ivars are not shared between subclasses in Ruby, and two subclasses started concurrently each ran their own loop. The problematic sharing is intra-class, and is reported as such.
  15. stop hanging on a worker_loop in sleep — dismissed by probe: Thread#wakeup does interrupt a sleep and stop returns in under a second. The hang exists only on a blocking I/O, which bounds the finding's scenario.
  16. An Enumerator breaking MethodProfiler the same way a Fiber does — dismissed by probe: the value is visible from inside an Enumerator. The scenario is bounded to explicit fibers and the fiber scheduler.
  17. Connection credentials leaking into labels in the ActiveRecord instrumentation — dismissed: the config allow-list excludes the password, and emission is opt-in.
  18. An untracked Gemfile.lock being an anomaly — dismissed: that is the convention for a published gem. The real complaint is the absence of appraisal lockfiles against an unpinned git dependency, reported separately.
  19. /ping being documented but absent — dismissed: the route exists and answers, verified live.
  20. The ObjectSpace sweep always picking the built-in collector over the custom class — not reproduced as such: two consecutive runs did select the custom class. The demonstrated defect is different — two subclasses in the custom file, or the guaranteed presence of the built-in collector among the candidates — and that is the form reported.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment