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.
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.
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_textescapes label values (\n,",\) but never keys — measured output:m2{bad"key="v"} 1.to_prometheus_textinterpolateshelpandnameraw — ahelpcontaining a newline yields# HELP legit help\n# TYPE injected counter\ninjected 999.- A
breakfrom the block passed toreq.bodyreturns control tohandle_metrics, which then unconditionally rewritesres.status = 200. Counter#observewith a non-numeric value raisesTypeError: String can't be coerced into Integer.- Dynamic symbols are garbage-collected (
Symbol.all_symbols: 3811 → 3811 after 100,000to_symcalls plusGC.start) — so no symbol-table DoS viasymbolize_keys. Queue#popblocks indefinitely on an empty queue, and removes the oldest element (FIFO).WEBrick::HTTPAuth::HtpasswdraisesNotImplementedErroron an MD5 file and on a bcrypt file; only DES crypt is accepted.NotImplementedErroris aScriptError, so it escapesrescue =>.- DES crypt truncates to 8 significant characters:
"motdepasse-tres-long".crypt("ab") == "motdepas".crypt("ab")→true. Thread.current[:k]is fiber-local:nilfrom inside an explicitFiber, correct from inside anEnumerator.thread_variable_getcrosses fibers.- The
PeriodicStatsthread does not survivefork: parentstarted?=truecounter 6, childstarted?=falsecounter frozen at 6. def f(a, b = {})has arity -2, soarity > 0is false."no match".match(RE).to_a[2].to_syields"", nevernil— a downstream||is dead code.!RUBY_ENGINE == "jruby"evaluates tofalse(precedence);RUBY_ENGINE != "jruby"evaluates totrue.- On Ruby 4.0.6,
logger,json,psych,timeoutandstringioare no longer default gems (default_gem? == false). pgrepwith no match returns""withexitstatus 1— and"".lines.count == 0.Gauge#observe(nil)deletes the series rather than zeroing it (measured:"unicorn_workers{host=\"h1\"} 4"→"").- The binary's
ObjectSpacesweep finds 14 built-inTypeCollectors with no custom file loaded at all, and the built-inCollectoralso matches the-csweep.
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.
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.
Problem — rotate_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.
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.
Problem — gauge.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.
Fix — gauge.observe(value, metric["custom_labels"] || {}).
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.
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.
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.
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.
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.
Problem — metric.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.
Fix — metric["custom_labels"] || {}.
Problem — enqueued 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.
Problem — busy_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.
Problem — process(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.
Problem — shutdown 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.
Problem — stop 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.
Scenario — MethodProfiler.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.
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.
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.
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.
Problem — Thread#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.
Fix — join bounded by frequency plus a grace period, then kill if the thread is still alive.
Problem — ARG 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.
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.
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.
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.
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.
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.
Problem — metric["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.
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.
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.
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.
Problem — start 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.
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.
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.
Problem — OjCompat 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.
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.
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.
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.
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.
Problem — process 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.
Problem — worker["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.
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.
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.
Problem — identity 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.
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.
Problem — ObjectSpace.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.
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.
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.
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.
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.
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/.
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.
Problem — detect_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.
Problem — The signature swallows any positional argument and any unknown keyword.
Scenario — PeriodicStats.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.
Problem — config_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.
Problem — DEFAULT_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.
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.
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.
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.
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.
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.
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.
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.
Scenario — prometheus_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.
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.
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.
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.
Problem — contents: 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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/.
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.
Problem — bin/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.
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.
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.
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.
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.
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.
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)
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.SEC-lib/prometheus_exporter/metric/base.rb:84— escape or validate label keys.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.COR-lib/prometheus_exporter/server/delayed_job_collector.rb:24(plushutch_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)
ERR-lib/prometheus_exporter/server/sidekiq_queue_collector.rb:40and 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 eachcollect. One pass, seven files.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.TEST-test/test_helper.rb:6— the degraded-payload battery, written with #5 and #6 rather than after, otherwise nothing guarantees coverage.COR-lib/prometheus_exporter/server/delayed_job_collector.rb:49— stop usingnilas a series-deletion order. Same file as #5.
Phase 3 — the instrumented application
COR-lib/prometheus_exporter/instrumentation/method_profiler.rb:75— fix the patch idempotence guard. Root cause of the harshest incident in the set.CON-lib/prometheus_exporter/instrumentation/method_profiler.rb:48— switch to thread variables. Same file as #9.SEC-lib/prometheus_exporter/middleware.rb:130— guard the trace-header parsing.SEC-lib/prometheus_exporter/middleware.rb:124— validate the queue-time header. Same file as #11.PERF-lib/prometheus_exporter/instrumentation/delayed_job.rb:17— move the counts out of the per-job hook.PERF-lib/prometheus_exporter/instrumentation/sidekiq_process.rb:36— drop the unbounded-cardinality labels.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)
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.SEC-.github/workflows/ci.yml:34— pin actions by SHA.SEC-gemfiles/ar_71.gemfile:5— pin the appraisal git dependency.CON-Dockerfile:1— align the Ruby version with the gemspec floor.CON-.github/workflows/ci.yml:64— build the image on pull requests. Validates #19 and prevents a recurrence.API-prometheus_exporter.gemspec:27— declareloggeras a runtime dependency.TEST-Gemfile:22— fix the JRuby condition and regenerate the gemfiles; until then the Unicorn instrumentation stays untested.SEC-prometheus_exporter.gemspec:26— require a second factor for publication.NIT-ci.yml:45— bound the publish job's duration.NIT-Dockerfile:6— harden the image.
Phase 5 — operator surface
SEC-exe/prometheus_exporter:75— abort if only one of the two TLS flags is supplied.SEC-lib/prometheus_exporter/client.rb:268— fix client-side TLS activation.DOC-README.md:869— document the TLS options. Depends on #26 and #27: document the fixed behaviour, not the current one.ERR-lib/prometheus_exporter/server/web_server.rb:199— load and validate the auth file once, at startup.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.SEC-lib/prometheus_exporter/server/web_server.rb:109— decide on protecting the ingest route.COR-exe/prometheus_exporter:103-108andCOR-exe/prometheus_exporter:121-126— replace bothObjectSpacesweeps. Same file, same mechanism, one pass.ERR-exe/prometheus_exporter:42— guard the label parsing and check the type.ERR-exe/prometheus_exporter:24— bound the port.ERR-exe/prometheus_exporter:133— trap the shutdown signals.TEST-exe/prometheus_exporter:1— the binary's test, written alongside #32 to #35.
Phase 6 — metric correctness
COR-lib/prometheus_exporter/metric/summary.rb:91— expire quantiles at render time.PERF-lib/prometheus_exporter/metric/summary.rb:106— stop double-storing. Same file as #37.COR-lib/prometheus_exporter/server/unicorn_collector.rb:29— include pid and hostname in the labels.COR-lib/prometheus_exporter/server/sidekiq_stats_collector.rb:37— honour custom_labels.COR-lib/prometheus_exporter/server/process_collector.rb:31— include metric_labels in the filter.PERF-lib/prometheus_exporter/server/good_job_collector.rb:35andresque_collector.rb:33— reset the gauges.COR-lib/prometheus_exporter/server/good_job_collector.rb:29andresque_collector.rb:28— fix the custom_labels default. Same file as #42.COR-lib/prometheus_exporter/server/puma_collector.rb:16— export the gauge unconditionally.COR-lib/prometheus_exporter/instrumentation/unicorn.rb:55— inspect the pgrep exit status.COR-lib/prometheus_exporter/instrumentation/delayed_job.rb:43— make the job-name fallback reachable.ERR-lib/prometheus_exporter/instrumentation/delayed_job.rb:45— assign the start timestamp first and guard theensure. Same file as #46, same pass.ERR-lib/prometheus_exporter/instrumentation/sidekiq.rb:36— fix the arity test and protect theensurebody, so the middleware stops masking real job outcomes.COR-lib/prometheus_exporter/instrumentation/shoryuken.rb:16— initialise the shutdown flag.ERR-lib/prometheus_exporter/instrumentation/puma.rb:66andERR-lib/prometheus_exporter/instrumentation/puma.rb:49— coerce the stats values.PERF-lib/prometheus_exporter/instrumentation/active_record.rb:55— stop sweeping the heap.PERF-lib/prometheus_exporter/instrumentation/good_job.rb:18— lighten the counts.PERF-lib/prometheus_exporter/instrumentation/process.rb:89andPERF-lib/prometheus_exporter/instrumentation/process.rb:47— second heap sweep and subprocess.
Phase 7 — lifecycle and contracts
CON-lib/prometheus_exporter/instrumentation/periodic_stats.rb:20— signal the thread's death after fork.CON-lib/prometheus_exporter/instrumentation/periodic_stats.rb:46(unbounded join),periodic_stats.rb:46(wakeup window) andperiodic_stats.rb:16(stop/assign ordering) — same file, one pass.ERR-lib/prometheus_exporter/instrumentation/periodic_stats.rb:25— protect the error handler.TEST-lib/prometheus_exporter/instrumentation/hutch.rb:1— coverperiodic_stats.rbfirst, since #54 to #56 are inherited by eight subclasses.ERR-lib/prometheus_exporter/instrumentation.rb:4— fix the standalone require.ERR-lib/prometheus_exporter.rb:15andAPI-lib/prometheus_exporter.rb:36— serializer consistency between client and collector. Same file.CON-lib/prometheus_exporter/client.rb:139— non-blocking pop.API-lib/prometheus_exporter/server/collector_base.rb:11andERR-lib/prometheus_exporter/server/collector_base.rb:7— fix the signature and the stubs. Same file.DOC-examples/custom_collector.rb:3andCOR-examples/custom_collector.rb:10— fix the example. Depends on #61.SEC-lib/prometheus_exporter/server/collector.rb:82— validate options coming from the network.
Phase 8 — documentation and finishing
DOC-README.md:1014— the image registry.DOC-README.md:801— the binary path.DOC-README.md:43— the minimum Ruby version.DOC-CHANGELOG:8— the Unreleased section.DOC-bench/bench.rb:5— the load path.CON-.github/workflows/linting.yml:28— widen the format check.API-lib/prometheus_exporter/server/process_collector.rb:53— prefix the process metric names.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.COR-lib/prometheus_exporter/instrumentation/method_profiler.rb:37— the hand-off guard.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.
- Symbol-table exhaustion DoS via
symbolize_keyson network-supplied keys — dismissed by probe:Symbol.all_symbolsstays at 3811 after 100,000 conversions and aGC.start. Dynamic symbols have been collected since Ruby 2.2. - Cross-request
MethodProfilercontamination when the application raises andstopis never reached — dismissed by probe:startunconditionally overwrites the thread state at the top of every request. The hash also only grows by the number of distinct categories, at most three. - Unsafe YAML deserialization in the Sidekiq middleware — dismissed: the gemspec floor of Ruby 3.2 implies Psych 4+, where
YAML.loadis safe mode. Probed on psych 5.4.0: a!ruby/object:payload raisesPsych::DisallowedClass, and the code rescues precisely that exception, confirming the safe behaviour is the expected nominal path. - Command injection in the Unicorn instrumentation's
pgrepcall — dismissed: the interpolated value goes throughFile.read(...).to_i, so it is an Integer. The real defect at that site is the unchecked exit status, reported separately. - Data race between ingest and render — dismissed:
process_hashandprometheus_metrics_texttake 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. - 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.
- 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. spec.filesmissing a file loaded at runtime — dismissed:git ls-files libreturns no file without a.rbextension, and the inventory ofrequires inlib/names no data file.bin/rakeoverwriting the matrix'sBUNDLE_GEMFILE, making CI test the same ActiveRecord sixteen times — dismissed: the binstub uses a conditional assignment, which leaves the workflow's value in place.- The suite not actually running in CI — dismissed: the workflow calls
bin/rake, whose default task istest, which covers all 26 tracked_test.rbfiles. No test file omits the helper, and SimpleCov starts before the library is loaded. - The trailing
sleepbypassingat_exithandlers on SIGTERM — dismissed by probe: the handlers do run, exit 143. The real defect is the absence of a trap callingrunner.stop, reformulated accordingly. - An empty
ARG GEM_VERSION=breaking a local image build — dismissed: an empty--version=is ignored by rubygems and the install succeeds. jsonmissing from the gemspec just likelogger— dismissed in practice: under Bundler with an empty Gemfile,require "json"succeeds whilerequire "logger"raises. Onlyloggerproduces an observable failure, hence a targeted finding rather than a generic one.- Two
PeriodicStatssubclasses 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. stophanging on aworker_loopinsleep— dismissed by probe:Thread#wakeupdoes interrupt asleepandstopreturns in under a second. The hang exists only on a blocking I/O, which bounds the finding's scenario.- An
EnumeratorbreakingMethodProfilerthe same way aFiberdoes — dismissed by probe: the value is visible from inside anEnumerator. The scenario is bounded to explicit fibers and the fiber scheduler. - Connection credentials leaking into labels in the ActiveRecord instrumentation — dismissed: the config allow-list excludes the password, and emission is opt-in.
- An untracked
Gemfile.lockbeing 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. /pingbeing documented but absent — dismissed: the route exists and answers, verified live.- The
ObjectSpacesweep 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.