rails_pod_kit
The operational endpoints a Rails pod needs to be a good Kubernetes citizen, packaged behind a single, opinionated entry point:
- Prometheus metrics for Puma and Sidekiq, served in-process on a
single
/metricsendpoint (default port 9394) in both the web (Puma) and worker (Sidekiq) processes — no sidecar, no separate collector process. A metrics agent (e.g. the Datadog Agent via OpenMetrics autodiscovery) scrapes the pod directly. - Health checks on
/healthz(database, cache, optionally Redis and Sidekiq), wired for Kubernetes startup/liveness/readiness probes — a thin, opinionated wrapper around health-monitor-rails.
The metrics side is a thin wrapper around the yabeda ecosystem:
| gem | what it gives us |
|---|---|
yabeda |
in-process metrics registry |
yabeda-puma-plugin |
Puma thread-pool / worker stats + the :yabeda / :yabeda_prometheus Puma plugins |
yabeda-sidekiq |
Sidekiq per-process job metrics + global/Redis-wide queue metrics |
yabeda-prometheus-mmap |
Prometheus text exporter, multiprocess-safe via prometheus-client-mmap |
webrick |
HTTP server for the Sidekiq exporter |
Scope: runtime/worker metrics only (Puma backlog/threads, Sidekiq queues and jobs). HTTP request-level metrics are intentionally out of scope — that is covered by APM.
The gem is deliberately connection-agnostic: it never reads REDIS_URL and
makes no TLS decisions. Wherever a Redis connection is needed (health checks,
the dedicated global exporter) the host injects its own options, so connection
config keeps a single, host-owned source of truth.
Wiring
The host app wires the gem in three one-liners. Every metrics hook is a
complete no-op when the exporter is disabled or in the test environment, so
the suite never binds the port. The integrations wire themselves at
Bundler.require and the gem's Railtie mounts the health endpoint — no
require: gymnastics in the Gemfile, no routes change.
1. config/initializers/rails_pod_kit.rb — configure once:
RailsPodKit.configure do |c|
c.enabled = !Rails.env.test?
c.port = Integer(ENV.fetch('PROMETHEUS_EXPORTER_PORT', 9394))
end
RailsPodKit::Health.install!(
redis: { url: ENV['REDIS_URL'] }, # the host's own Redis options
sidekiq: { queue_size: 200, latency: 10.minutes } # omit on hosts without Sidekiq
)
2. config/puma.rb — activate the Puma plugins:
require 'rails_pod_kit/puma'
RailsPodKit::Puma.activate(self)
3. config/initializers/sidekiq.rb (inside Sidekiq.configure_server):
RailsPodKit::Sidekiq.install!(config)
Configuration
RailsPodKit::Config is an anyway_config
config: besides the RailsPodKit.configure block (which runs last and wins),
every setting can come from a RAILS_POD_KIT_* env var (e.g.
RAILS_POD_KIT_ENABLED=false, RAILS_POD_KIT_PORT=9500,
RAILS_POD_KIT_SIDEKIQ_GLOBAL_METRICS=off) or an optional
config/rails_pod_kit.yml — handy for the Rails-free exporter pod, which runs
no initializers.
| setting | default | meaning |
|---|---|---|
enabled |
on, except in test |
master switch; false ⇒ no exporter, no port bound |
port |
9394 (env PROMETHEUS_EXPORTER_PORT) |
exporter bind port for Puma and Sidekiq |
sidekiq_global_metrics |
:web |
who exports the Redis-wide queue metrics: :web = only the always-on web process (no per-worker duplication); :all = every worker; :off = nobody |
puma_control_url |
tcp://127.0.0.1:9293 (env PUMA_CONTROL_URL) |
localhost-only Puma control app the stats reader queries |
retries_segmented_by_queue |
false |
when true, the global retry gauge (sidekiq_jobs_retry_count) carries a queue tag instead of being a single scalar. Opt-in: per-queue retries increase series cardinality. |
silence_exporter_access_log |
true |
suppress the exporter's per-scrape GET /metrics access log on both transports (Puma plugin + WEBrick). The endpoint is pod-internal and scraped on a fixed interval, so the line is pure noise. Set false only to debug the exporter. |
Health checks (RailsPodKit::Health)
Health.install! configures health-monitor-rails with the kit's defaults:
endpoint at /healthz, checking database (health_monitor's default),
cache, — when a redis: connection is given — Redis (connection
injected by the host) and — when a sidekiq: thresholds hash is given —
Sidekiq. Omit redis: on hosts with no Redis dependency (e.g. a Solid
Queue stack) to get a database + cache only endpoint. The gem's Railtie mounts
HealthMonitor::Engine at / automatically; pass mount: false to keep
route ownership (custom mount point, constraints) and mount it yourself in
config/routes.rb. Any further tuning goes through the optional block, which
receives the HealthMonitor configuration.
RailsPodKit::Health.install!(
redis: { url: ENV['REDIS_URL'] }, # optional: a ready Redis/ConnectionPool
# object or options hash; omit for no
# Redis provider
path: :healthz, # default
sidekiq: { queue_size: 200, latency: 10.minutes }
) do |config|
config.error_callback = ->(e) { ... } # host-specific extras
end
Without a redis: argument the endpoint checks only database and cache
(plus Sidekiq when its thresholds are given):
RailsPodKit::Health.install!(path: :healthz) # database + cache only
Probe wiring on Kubernetes:
- startup probe →
/healthz— the full check, so the pod doesn't go ready until its dependencies are actually up; - liveness/readiness probes →
/healthz?providers[]=none— short-circuits the dependency checks once the pod is live, so a transient Redis hiccup doesn't restart the app.
The same providers[]=none trick works for a manual basic check:
curl '/healthz?providers[]=none' -H 'Accept: application/json'
The per-probe controller logging is silenced by default (kubelet probes hit the
endpoint every few seconds); pass silence_controller_log: false to keep it.
Metrics exposed on /metrics
- Puma:
puma_workers,puma_running,puma_pool_capacity,puma_max_threads,puma_backlog,puma_busy_threads,puma_requests_count, … - Sidekiq (per-process):
sidekiq_jobs_executed_total,sidekiq_jobs_success_total,sidekiq_jobs_failed_total,sidekiq_job_runtime_seconds,sidekiq_job_latency_seconds,sidekiq_running_job_runtime_seconds, … - Sidekiq (global/Redis-wide):
sidekiq_jobs_waiting_count,sidekiq_queue_latency,sidekiq_active_processes,sidekiq_active_workers_count,sidekiq_jobs_retry_count,sidekiq_jobs_dead_count,sidekiq_jobs_scheduled_count.
Series are intentionally untagged: a scraping agent adds
service/env/version and kube_* tags at scrape time, so the gem doesn't
duplicate them.
Datadog naming — the canonical metric set
The gem emits the untagged native Prometheus names above (puma_running,
sidekiq_queue_latency, …). To get a clean, org-shared metric set every
adopter must use the same Datadog OpenMetrics check config, so all series
differ only by service / env (+ the kube_*/host/pid tags the Agent adds
at scrape time) and dashboards/monitors are reusable across projects.
The check namespace prepends puma. / sidekiq.; left alone that would
double-prefix the native names (puma.puma_running). raw_metric_prefix
strips the redundant native segment before the namespace is applied, so series
land under the canonical puma.<name> / sidekiq.<name>:
# Puma (web pod) — :9394/metrics
instances:
- openmetrics_endpoint: "http://%%host%%:9394/metrics"
namespace: "puma"
raw_metric_prefix: "puma_" # puma_running -> puma.running
metrics: [".*"]
tag_by_endpoint: false
# Sidekiq (worker pod + dedicated global exporter) — :9394/metrics
instances:
- openmetrics_endpoint: "http://%%host%%:9394/metrics"
namespace: "sidekiq"
raw_metric_prefix: "sidekiq_" # sidekiq_queue_latency -> sidekiq.queue_latency
metrics: [".*"]
tag_by_endpoint: false
On Kubernetes this is typically wired as pod-annotation autodiscovery. The
openmetrics_endpoint above uses the Datadog Agent's %%host%% autodiscovery
template (resolves to the pod IP) — a non-k8s adopter (a plain conf.yaml
check) swaps %%host%%:9394 for the real host:port; everything else
(namespace, raw_metric_prefix, metrics) is identical.
Invariant — the endpoint must stay prefix-pure
The Datadog check uses metrics: [".*"], which ingests every series on the
endpoint. raw_metric_prefix only strips the prefix when present — it does
not filter. So the naming scheme above holds only because the /metrics
endpoint exposes solely yabeda-registered series, all sharing the
puma_ / sidekiq_ group prefix:
- the gem registers only the
:pumaand:sidekiqyabeda groups (no process, GC, or Ruby-runtime collectors); - the exposition serves Yabeda's registry only — the Prometheus client's HTTP
request collector (
http_*) is not mounted on the exporter.
If a series without the puma_ / sidekiq_ prefix ever appeared on the
endpoint, metrics: [".*"] would ingest it un-stripped under the namespace
(e.g. puma.http_requests_total). Do not add cross-cutting metrics (process,
runtime, HTTP request) to this exporter, and do not mount the Prometheus Rack
collector on it. If you ever need such metrics, expose them on a separate
endpoint with its own check rather than polluting this one. Conversely, any new
metric you do add to the :puma / :sidekiq groups is picked up automatically
by [".*"] — no check change needed.
⚠️ Renaming the namespace prefix is a breaking metric rename — existing dashboards/monitors built on the old
puma.puma_*/sidekiq.sidekiq_*series must be migrated.
Canonical catalog
Functional (non-technical) tags only; service/env/kube_*/pid/… are added
by the Agent. index = Puma worker index (0 in single-mode); worker = the
Sidekiq job class.
Puma (web pod, namespace: puma):
| canonical Datadog metric | functional tags |
|---|---|
puma.running |
index |
puma.backlog |
index |
puma.busy_threads |
index |
puma.pool_capacity |
index |
puma.max_threads |
index |
puma.requests_count |
index |
Sidekiq — global / Redis-wide (dedicated global-exporter pod, namespace: sidekiq):
| canonical Datadog metric | functional tags |
|---|---|
sidekiq.jobs_waiting_count |
queue |
sidekiq.queue_latency |
queue |
sidekiq.jobs_scheduled_count |
— |
sidekiq.jobs_retry_count |
— (queue if retries_segmented_by_queue) |
sidekiq.jobs_dead_count |
— |
sidekiq.active_processes |
— |
sidekiq.active_workers_count |
— |
Sidekiq — per-process / job (worker pod, namespace: sidekiq; emitted on job activity):
| canonical Datadog metric | type | functional tags |
|---|---|---|
sidekiq.jobs_executed_total |
counter | queue, worker |
sidekiq.jobs_success_total |
counter | queue, worker |
sidekiq.jobs_failed_total |
counter | queue, worker |
sidekiq.jobs_enqueued_total |
counter | queue, worker |
sidekiq.jobs_rerouted_total |
counter | from_queue, to_queue, worker |
sidekiq.running_job_runtime |
gauge | queue, worker |
sidekiq.job_runtime |
histogram | queue, worker |
sidekiq.job_latency |
histogram | queue, worker |
Where Sidekiq global metrics come from
The global (Redis-wide) queue gauges — sidekiq_jobs_waiting_count,
sidekiq_queue_latency, … — are read from Redis and are identical regardless of
which process reads them. The default :web policy collects them only in the
always-on web process (which already has the Sidekiq client / Redis access),
so there's a single source and no per-worker duplication: workers export only
their own per-process job metrics. Workers can scale (even to zero) without
losing the queue metrics.
Alternatives via c.sidekiq_global_metrics:
:all— every worker pod exports the global gauges (the legacy behaviour). Datadog then has one series per pod, so aggregate in dashboards/monitors withmax:sidekiq.queue_latency{*} by {queue}(Datadog space aggregation — neversum, which would multiply by pod count).:off— neither web nor workers collect them. Use this with a dedicated exporter (below) so the gauges still come from somewhere — exactly once.
Under
:web, if the web Deployment runs more than one replica the gauges are duplicated across those (few, stable) web pods — apply the samemax:… by {queue}aggregation, or use the dedicated exporter for one series.
Dedicated global-metrics exporter (recommended for multi-replica / KEDA)
When the web and worker pods are autoscaled (e.g. KEDA, possibly to zero), the cleanest single source for the Redis-wide queue gauges is a dedicated 1-replica Deployment that does nothing but read them from Redis and expose them. It is decoupled from web/worker scaling and yields exactly one series per gauge.
Set c.sidekiq_global_metrics = :off (so web/workers don't collect them), then
run a 1-replica Deployment with a thin, host-owned entrypoint — the gem
deliberately ships no executable, so the Redis connection config stays with the
host. E.g. bin/pod-exporter:
#!/usr/bin/env ruby
# bundler/setup only wires the $LOAD_PATH from the lockfile — nothing is
# required beyond the lines below, so the process stays tiny.
require 'bundler/setup'
require 'rails_pod_kit/global_exporter'
RailsPodKit::GlobalExporter.run!(redis: { url: ENV['REDIS_URL'] })
command: ["bin/pod-exporter"]
The entrypoint is deliberately Rails-free: run! only wires the Sidekiq
client's Redis connection from the injected options plus the yabeda stack,
declares the cluster gauges, starts the exporter and blocks until SIGTERM.
Booting the full host app just to read a handful of Redis counters would cost
~300Mi RSS for nothing — this process sits at ~60Mi.
Caveats
- Puma only. The Puma plugins only activate under Puma; under any other
app server the in-process
/metricsendpoint is not exposed. - Puma control app.
yabeda-puma-pluginreads Puma's thread-pool stats through Puma's control app, soPuma.activateactivates one on a localhost-only socket (no_token: true, never network-exposed). - Rack version. Under Rack 3+ the mmap exporter's WEBrick handler also
needs the
rackupgem. Under Rack 2.xwebrickalone is enough, but on Ruby ≥ 3.5 make sureostructis in the bundle (Rack 2.2 requires it without declaring it, and it's no longer a default gem). - Two kinds of entry point. The main file (
require 'rails_pod_kit', what Bundler.require loads in a Rails app) pulls in every integration unconditionally — including the health-monitor-rails engine and its railties foundation — regardless of Gemfile declaration order. The sub-entry points (rails_pod_kit/puma,rails_pod_kit/global_exporter) require only the config core, so the Rails-free exporter stays railties-free and apuma -C config/puma.rbboot (which evaluatesconfig/puma.rbbefore Rails) still gets the integrations onceBundler.requireruns. The main file also loads the yabeda-prometheus-mmap adapter at require time — before any host initializer runs — so host initializers may declare Yabeda metrics (yabeda-rails, yabeda-activejob, a custom collector) safely: the adapter registers itself while no metric exists yet, regardless of whether the app boots viabin/rails serverorpuma -C config/puma.rb.
Local verification
Boot each process with the exporter enabled and scrape :9394/metrics:
# Web (Puma): puma_* series
bundle exec rails server
curl -s localhost:9394/metrics | grep '^puma_'
# Worker (Sidekiq): sidekiq_* series (run a job first so per-process counters appear)
bundle exec sidekiq
curl -s localhost:9394/metrics | grep '^sidekiq_'
# Health endpoint
curl -s localhost:3000/healthz -H 'Accept: application/json'
Tests
The gem's specs run in isolation (they do not load a host Rails app):
bundle install
bundle exec rspec
The suite runs against a matrix of Rails/Rack versions via Appraisal:
bundle exec appraisal install
bundle exec appraisal rails-8.1 rspec
License
Released under the MIT License.