upstate-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4783aff7c6640fdf83df97d8c490f32dfa6269dd242628a8d8a177986b229116
4
+ data.tar.gz: b2684f1698caa10a91248c4950e30ccd38a33ec6002b8bcda4b68104477b7a8f
5
+ SHA512:
6
+ metadata.gz: b1b8c3b7dbc87385211de8fd8736aa96d145ee13c7dc937ac7c219f36fbea7a7e38bdec69e5dede781d46b9a89d0ada46d5de3f7a7a87a0a0fae4a32ed2cd3b7
7
+ data.tar.gz: 8c371fa288593ebae53746217b318bf393fd5c0144d6a558982dbad760ee793b82f0686924b6ea4a088cb50f7790e0bbd65ab5cabf3858c554dab01920c48bb5
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - First release: `GET /upstate/health` (schema 1) with boot facts, migrations, DB pool, Puma,
6
+ Solid Queue / Sidekiq / GoodJob job numbers, cache probe, and per-minute action rollups.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Upstate
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # upstate-rails
2
+
3
+ Rails app health for [Upstate](https://upstate.cloud): boot facts, migrations, Puma, jobs, and the slowest actions. Counts only.
4
+
5
+ The gem adds one endpoint, `GET /upstate/health`. Upstate's host observer reads it from inside your server, over your private Docker network, every minute. Nothing else is installed, and nothing is sent anywhere by the gem itself.
6
+
7
+ ## What is sent
8
+
9
+ - App version (`APP_VERSION`, else `KAMAL_VERSION`, else `unknown`), Rails and Ruby versions, whether YJIT is on, when the process booted.
10
+ - Whether the database answers, and whether migrations are pending.
11
+ - Active Record pool size, busy and waiting connections.
12
+ - Puma workers, threads, backlog and spare capacity.
13
+ - Job numbers for Solid Queue, Sidekiq or GoodJob: queue depth, oldest queued age, worker count, jobs processed and failed in the last 5 minutes, and when something last ran.
14
+ - Whether your cache (Solid Cache or Redis) answers.
15
+ - For the last 15 minutes, the 10 slowest `Controller#action` names per minute, with request count, error count, a 12-bucket timing histogram, and total database time.
16
+
17
+ ## What is never sent
18
+
19
+ No params, URLs or paths, SQL, IP addresses, user agents, headers, cookies, job arguments, exception classes or messages, logs, or traces. An error is only a count (a 5xx response or an unhandled exception). Job queue names and job class names are not read either.
20
+
21
+ ## Install
22
+
23
+ ```ruby
24
+ # Gemfile
25
+ gem "upstate-rails"
26
+ ```
27
+
28
+ ```sh
29
+ bundle install
30
+ bin/rails g upstate:install
31
+ ```
32
+
33
+ The endpoint is off until `UPSTATE_HEALTH_TOKEN` is set. Without it, `/upstate/health` returns 404. With a missing or wrong token it returns 401.
34
+
35
+ ## Kamal secrets
36
+
37
+ 1. In Upstate, open the environment page and generate a health token. It is shown once.
38
+ 2. Add it to `.kamal/secrets`:
39
+
40
+ ```sh
41
+ UPSTATE_HEALTH_TOKEN=<the token>
42
+ ```
43
+
44
+ 3. List it in `config/deploy.yml`:
45
+
46
+ ```yaml
47
+ env:
48
+ secret:
49
+ - UPSTATE_HEALTH_TOKEN
50
+ ```
51
+
52
+ 4. `kamal deploy`.
53
+
54
+ The generator appends a commented example to `config/deploy.yml` only when `UPSTATE_HEALTH_TOKEN` is not already in the file.
55
+
56
+ ## Verify
57
+
58
+ ```sh
59
+ curl -i -H "Authorization: Bearer $UPSTATE_HEALTH_TOKEN" https://your-app.example.com/upstate/health
60
+ ```
61
+
62
+ You should get `200` and JSON starting with `"schema": 1`. A `503` means the database is not answering (the JSON says so). Anything with a slow, missing or erroring section reports that section as `null` and still returns the rest.
63
+
64
+ ## Good to know
65
+
66
+ - The route is added ahead of your routes, so a catch-all route cannot hide it.
67
+ - The path is excluded from `config.host_authorization` and from the `force_ssl` redirect, because the observer calls the container's private IP over plain HTTP. Nothing else changes in those settings.
68
+ - Action timings are kept in memory per Puma worker and written to `Dir.tmpdir/upstate-rails/<pid>.json` about once a minute (not on every request). The endpoint merges the files of live processes. Files older than 20 minutes are ignored.
69
+ - Sidekiq keeps lifetime counters only, so `processed_last_5m` and `failed_last_5m` are `null` until the gem has seen two readings at least 30 seconds apart in one process.
70
+ - In Puma cluster mode, a worker answering the request sees the other workers through the master's last snapshot, so Puma numbers are best effort.
71
+ - Requires Ruby 3.1+ and Rails 7.1+. No other runtime gems.
72
+
73
+ ## Migrating from the v0 controller
74
+
75
+ If you copied `upstate_health_controller.rb` and its route into your app, remove both: delete `app/controllers/upstate_health_controller.rb` and the `get "/upstate/health"` line in `config/routes.rb`. Keep the same `UPSTATE_HEALTH_TOKEN`. The gem's response is a superset of the v0 response (including the same `solid_queue` block), so Upstate keeps working through the change.
76
+
77
+ ## Development
78
+
79
+ ```sh
80
+ bundle install
81
+ bundle exec rake test
82
+ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle install && BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake test
83
+ ```
84
+
85
+ CI runs Rails 7.2 and 8.0 on Ruby 3.2 to 3.4.
86
+
87
+ ## License
88
+
89
+ MIT.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Upstate
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ desc "Prints the steps to turn on Upstate Rails app health (GET /upstate/health)."
9
+
10
+ DEPLOY_EXAMPLE = <<~YAML
11
+
12
+ # Upstate app health: add UPSTATE_HEALTH_TOKEN to .kamal/secrets, then uncomment.
13
+ # env:
14
+ # secret:
15
+ # - UPSTATE_HEALTH_TOKEN
16
+ YAML
17
+
18
+ def append_deploy_example
19
+ path = File.join(destination_root, "config/deploy.yml")
20
+ return unless File.exist?(path)
21
+ return if File.read(path).include?("UPSTATE_HEALTH_TOKEN")
22
+
23
+ append_to_file "config/deploy.yml", DEPLOY_EXAMPLE
24
+ end
25
+
26
+ def print_steps
27
+ say <<~TEXT
28
+
29
+ Upstate Rails app health is installed. The endpoint is off until you set a token.
30
+
31
+ 1. In Upstate, open the environment page and generate a health token (shown once).
32
+ 2. Add it to .kamal/secrets:
33
+ UPSTATE_HEALTH_TOKEN=<the token>
34
+ 3. List it under env.secret in config/deploy.yml:
35
+ env:
36
+ secret:
37
+ - UPSTATE_HEALTH_TOKEN
38
+ 4. Deploy, then check it:
39
+ curl -H "Authorization: Bearer $UPSTATE_HEALTH_TOKEN" https://your-app.example.com/upstate/health
40
+
41
+ Without the token the path returns 404. With a wrong token it returns 401.
42
+ TEXT
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Upstate
6
+ module Rails
7
+ # Per-process, thread-safe, bounded rollup of controller actions per minute.
8
+ # Stores only: name, count, error count, duration buckets, db ms, total ms.
9
+ class ActionRollup
10
+ # Upper bounds in ms, inclusive; the 12th bucket is +inf.
11
+ BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10_000].freeze
12
+ MAX_NAMES_PER_MINUTE = 200
13
+ RETAIN_MINUTES = 20
14
+ FLUSH_INTERVAL = 5
15
+
16
+ def self.bucket_index(ms)
17
+ BUCKETS_MS.index { |upper| ms <= upper } || BUCKETS_MS.size
18
+ end
19
+
20
+ def initialize(clock: -> { Time.now }, dir: ActionStore.dir)
21
+ @clock = clock
22
+ @dir = dir
23
+ @mutex = Mutex.new
24
+ reset!
25
+ end
26
+
27
+ def record(name, duration_ms, db_ms, error)
28
+ @mutex.synchronize do
29
+ reset! if @pid != Process.pid # forked (Puma cluster, preload_app)
30
+ now = @clock.call
31
+ minute = now.to_i / 60
32
+ names = (@minutes[minute] ||= {})
33
+ entry = names[name] || (names.size < MAX_NAMES_PER_MINUTE ? (names[name] = blank) : nil)
34
+ if entry
35
+ entry[:count] += 1
36
+ entry[:errors] += 1 if error
37
+ entry[:buckets][self.class.bucket_index(duration_ms.to_f)] += 1
38
+ entry[:db_ms] += db_ms.to_f
39
+ entry[:total_ms] += duration_ms.to_f
40
+ end
41
+ @minutes.delete_if { |m, _| m < minute - RETAIN_MINUTES }
42
+ flush_locked(now, force: false)
43
+ end
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ # Writes closed minutes to disk. force: skips the interval throttle.
49
+ def flush!(force: true)
50
+ @mutex.synchronize { flush_locked(@clock.call, force: force) }
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
55
+ def snapshot_closed(now = @clock.call)
56
+ current = now.to_i / 60
57
+ @mutex.synchronize do
58
+ @minutes.select { |m, _| m < current }.transform_values { |names| top(names) }
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def reset!
65
+ @pid = Process.pid
66
+ @minutes = {}
67
+ @flushed_through = nil
68
+ @last_flush = 0
69
+ end
70
+
71
+ def blank
72
+ {count: 0, errors: 0, buckets: Array.new(ActionStore::BUCKETS, 0), db_ms: 0.0, total_ms: 0.0}
73
+ end
74
+
75
+ def top(names)
76
+ names.sort_by { |_, e| -e[:total_ms] }.first(ActionStore::TOP).to_h.transform_values do |e|
77
+ {"count" => e[:count], "errors" => e[:errors], "buckets" => e[:buckets],
78
+ "db_ms" => e[:db_ms].round(1), "total_ms" => e[:total_ms].round(1)}
79
+ end
80
+ end
81
+
82
+ # Disk IO only when a new minute has closed since the last write, and at most
83
+ # once per FLUSH_INTERVAL seconds.
84
+ def flush_locked(now, force:)
85
+ current = now.to_i / 60
86
+ closed = @minutes.keys.select { |m| m < current }
87
+ return if closed.empty?
88
+
89
+ latest = closed.max
90
+ return if !force && (@flushed_through == latest || (now.to_f - @last_flush) < FLUSH_INTERVAL)
91
+ return if force && @flushed_through == latest
92
+
93
+ payload = closed.to_h { |m| [m, top(@minutes[m])] }
94
+ ActionStore.write(Process.pid, payload, dir: @dir)
95
+ @flushed_through = latest
96
+ @last_flush = now.to_f
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upstate
4
+ module Rails
5
+ # Reads and writes per-process rollup files in Dir.tmpdir/upstate-rails/<pid>.json.
6
+ # File shape: {"schema":1,"pid":1,"minutes":{"<epoch minute>":{"Name#action":{...}}}}
7
+ module ActionStore
8
+ BUCKETS = 12
9
+ MAX_AGE = 20 * 60
10
+ MAX_FILE_BYTES = 512 * 1024
11
+ MAX_NAME = 200
12
+ TOP = 10
13
+
14
+ module_function
15
+
16
+ def dir
17
+ File.join(Dir.tmpdir, "upstate-rails")
18
+ end
19
+
20
+ # Atomic: write a temp file in the same directory, then rename over the target.
21
+ def write(pid, minutes, dir: self.dir)
22
+ FileUtils.mkdir_p(dir, mode: 0o700)
23
+ target = File.join(dir, "#{pid}.json")
24
+ tmp = "#{target}.#{SecureRandom.hex(4)}.tmp"
25
+ File.write(tmp, JSON.generate("schema" => 1, "pid" => pid, "minutes" => minutes.transform_keys(&:to_s)), perm: 0o600)
26
+ File.rename(tmp, target)
27
+ ensure
28
+ File.delete(tmp) if tmp && File.exist?(tmp)
29
+ end
30
+
31
+ # Merges every live pid file into [{minute: Time, items: [...]}], the last `window`
32
+ # closed minutes, ascending, top TOP items per minute by total time.
33
+ def merged(now: Time.now, window: 15, dir: self.dir)
34
+ current = now.to_i / 60
35
+ range = (current - window)...current
36
+ acc = Hash.new { |h, k| h[k] = {} }
37
+
38
+ each_file(now: now, dir: dir) do |data|
39
+ data["minutes"].each do |minute, items|
40
+ m = minute.to_i
41
+ next unless range.cover?(m)
42
+
43
+ items.each do |name, e|
44
+ entry = acc[m][name] ||= {count: 0, errors: 0, buckets: Array.new(BUCKETS, 0), db_ms: 0.0, total_ms: 0.0}
45
+ entry[:count] += e["count"]
46
+ entry[:errors] += e["errors"]
47
+ entry[:db_ms] += e["db_ms"]
48
+ entry[:total_ms] += e["total_ms"]
49
+ e["buckets"].each_with_index { |c, i| entry[:buckets][i] += c }
50
+ end
51
+ end
52
+ end
53
+
54
+ acc.keys.sort.map do |m|
55
+ top = acc[m].sort_by { |_, e| -e[:total_ms] }.first(TOP)
56
+ {
57
+ minute: Time.at(m * 60).utc,
58
+ items: top.map do |name, e|
59
+ {name: name, count: e[:count], errors: e[:errors], duration_buckets: e[:buckets], db_ms: e[:db_ms].round}
60
+ end
61
+ }
62
+ end
63
+ end
64
+
65
+ def each_file(now:, dir:)
66
+ Dir.glob(File.join(dir, "*.json")).each do |path|
67
+ pid = File.basename(path)[/\A(\d+)\.json\z/, 1]&.to_i
68
+ next unless pid
69
+ next if (now.to_i - File.mtime(path).to_i) > MAX_AGE
70
+ next unless alive?(pid)
71
+ next if File.size(path) > MAX_FILE_BYTES
72
+
73
+ data = sanitize(JSON.parse(File.read(path)))
74
+ yield data if data
75
+ rescue StandardError
76
+ next
77
+ end
78
+ end
79
+
80
+ def alive?(pid)
81
+ Process.kill(0, pid)
82
+ true
83
+ rescue Errno::ESRCH
84
+ false
85
+ rescue Errno::EPERM
86
+ true
87
+ end
88
+
89
+ # Defensive: files are local but not trusted. Keep only well-typed numbers and short names.
90
+ def sanitize(data)
91
+ return unless data.is_a?(Hash) && data["schema"] == 1 && data["minutes"].is_a?(Hash)
92
+
93
+ minutes = {}
94
+ data["minutes"].each do |minute, items|
95
+ next unless minute.to_s.match?(/\A\d+\z/) && items.is_a?(Hash)
96
+
97
+ clean = {}
98
+ items.each do |name, e|
99
+ next unless name.is_a?(String) && e.is_a?(Hash)
100
+
101
+ b = e["buckets"]
102
+ next unless b.is_a?(Array) && b.size == BUCKETS && b.all?(Integer)
103
+ next unless %w[count errors].all? { |k| e[k].is_a?(Integer) } && %w[db_ms total_ms].all? { |k| e[k].is_a?(Numeric) }
104
+
105
+ clean[name[0, MAX_NAME]] = e
106
+ end
107
+ minutes[minute] = clean
108
+ end
109
+ {"minutes" => minutes}
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upstate
4
+ module Rails
5
+ # Rack endpoint for GET /upstate/health.
6
+ class Endpoint
7
+ MAX_TOKEN_BYTES = 256
8
+
9
+ def call(env)
10
+ expected = ENV["UPSTATE_HEALTH_TOKEN"].to_s
11
+ return respond(404, "") if expected.empty?
12
+ return respond(401, "", "www-authenticate" => "Bearer") unless authorized?(env["HTTP_AUTHORIZATION"], expected)
13
+
14
+ status, body = Health.call
15
+ respond(status, JSON.generate(body), "content-type" => "application/json")
16
+ end
17
+
18
+ private
19
+
20
+ def authorized?(header, expected)
21
+ header = header.to_s
22
+ return false unless header.start_with?("Bearer ")
23
+
24
+ supplied = header.delete_prefix("Bearer ")
25
+ return false if supplied.empty? || supplied.bytesize > MAX_TOKEN_BYTES
26
+
27
+ ActiveSupport::SecurityUtils.secure_compare(Digest::SHA256.hexdigest(supplied), Digest::SHA256.hexdigest(expected))
28
+ end
29
+
30
+ def respond(status, body, headers = {})
31
+ [status, {"cache-control" => "no-store", "content-length" => body.bytesize.to_s}.merge(headers), [body]]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upstate
4
+ module Rails
5
+ # Builds the schema 1 response. Every section rescues its own errors and reports nil.
6
+ class Health
7
+ SCHEMA = 1
8
+
9
+ def self.call(now: Time.now)
10
+ new(now).call
11
+ end
12
+
13
+ def initialize(now)
14
+ @now = now
15
+ end
16
+
17
+ # => [http_status, body_hash]
18
+ def call
19
+ database = section { Sections.database }
20
+ jobs, solid_queue = section { Jobs.call(now: @now) } || [nil, nil]
21
+
22
+ body = {
23
+ schema: SCHEMA,
24
+ version: version,
25
+ rails: section { {version: ::Rails.version, environment: ::Rails.env.to_s} },
26
+ ruby: section { {version: RUBY_VERSION, yjit: yjit?} },
27
+ booted_at: BOOTED_AT.iso8601,
28
+ database: database,
29
+ active_record_pool: section { Sections.active_record_pool },
30
+ puma: section { Sections.puma },
31
+ jobs: jobs,
32
+ cache: section { Sections.cache },
33
+ actions: section { actions }
34
+ }
35
+ body[:solid_queue] = solid_queue if solid_queue
36
+
37
+ status = database && database[:ok] == false ? 503 : 200
38
+ [status, body]
39
+ end
40
+
41
+ private
42
+
43
+ def version
44
+ [ENV["APP_VERSION"], ENV["KAMAL_VERSION"]].find { |v| !v.to_s.strip.empty? }&.strip || "unknown"
45
+ end
46
+
47
+ def yjit?
48
+ !!(defined?(::RubyVM::YJIT) && ::RubyVM::YJIT.enabled?)
49
+ end
50
+
51
+ def actions
52
+ Upstate::Rails.rollup.flush!
53
+ ActionStore.merged(now: @now).map { |m| {minute: m[:minute].iso8601, items: m[:items]} }
54
+ end
55
+
56
+ def section
57
+ yield
58
+ rescue StandardError, NotImplementedError
59
+ nil
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upstate
4
+ module Rails
5
+ module Jobs
6
+ # Uses GoodJob's tables (GoodJob 3+/4: GoodJob::Job, GoodJob::Process).
7
+ module GoodJobAdapter
8
+ module_function
9
+
10
+ def call(now: Time.now)
11
+ queued = ::GoodJob::Job.where(finished_at: nil, performed_at: nil, scheduled_at: ..now)
12
+ oldest = queued.minimum(:scheduled_at)
13
+ since = now - 300
14
+ finished = ::GoodJob::Job.where(finished_at: since..)
15
+ recent = [::GoodJob::Job.maximum(:performed_at), ::GoodJob::Job.maximum(:finished_at)].compact.max
16
+
17
+ jobs = {
18
+ adapter: "good_job",
19
+ queue_depth: queued.count,
20
+ oldest_queued_age_seconds: oldest && [now.to_i - oldest.to_i, 0].max,
21
+ workers: ::GoodJob::Process.where(updated_at: (now - 120)..).count,
22
+ processed_last_5m: finished.count,
23
+ failed_last_5m: finished.where.not(error: nil).count,
24
+ recent_processing_at: recent&.utc&.iso8601
25
+ }
26
+ [jobs, nil]
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upstate
4
+ module Rails
5
+ module Jobs
6
+ # Sidekiq keeps lifetime counters, so the last-5-minute numbers are deltas between
7
+ # samples taken by this process. Until two samples span time, they are null.
8
+ module SidekiqAdapter
9
+ WINDOW = 300
10
+ MAX_SAMPLES = 64
11
+ MUTEX = Mutex.new
12
+ SAMPLES = []
13
+
14
+ module_function
15
+
16
+ def reset_samples!
17
+ MUTEX.synchronize { SAMPLES.clear }
18
+ end
19
+
20
+ def call(now: Time.now)
21
+ stats = ::Sidekiq::Stats.new
22
+ depth = stats.enqueued
23
+ oldest = depth.to_i.positive? ? ::Sidekiq::Queue.all.map { |q| q.latency.to_f }.max : nil
24
+ processed, failed = deltas(now, stats.processed.to_i, stats.failed.to_i)
25
+
26
+ jobs = {
27
+ adapter: "sidekiq",
28
+ queue_depth: depth.to_i,
29
+ oldest_queued_age_seconds: oldest && oldest.floor,
30
+ workers: ::Sidekiq::ProcessSet.new.size,
31
+ processed_last_5m: processed,
32
+ failed_last_5m: failed,
33
+ recent_processing_at: nil
34
+ }
35
+ [jobs, nil]
36
+ end
37
+
38
+ def deltas(now, processed, failed)
39
+ MUTEX.synchronize do
40
+ SAMPLES.reject! { |t, _, _| now.to_f - t > WINDOW + 60 }
41
+ base = SAMPLES.find { |t, _, _| now.to_f - t >= 30 }
42
+ SAMPLES << [now.to_f, processed, failed]
43
+ SAMPLES.shift while SAMPLES.size > MAX_SAMPLES
44
+ return [nil, nil] unless base && processed >= base[1] && failed >= base[2]
45
+
46
+ [processed - base[1], failed - base[2]]
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upstate
4
+ module Rails
5
+ module Jobs
6
+ # Uses the Solid Queue tables (the same queries as the v0 controller).
7
+ module SolidQueueAdapter
8
+ module_function
9
+
10
+ def call(now: Time.now)
11
+ ready = ::SolidQueue::ReadyExecution
12
+ oldest = ready.minimum(:created_at)
13
+ recent_claim = ::SolidQueue::ClaimedExecution.maximum(:created_at)
14
+ recent_finish = ::SolidQueue::Job.where.not(finished_at: nil).maximum(:finished_at)
15
+ recent = [recent_claim, recent_finish].compact.max
16
+ since = now - 300
17
+
18
+ block = {
19
+ workers: ::SolidQueue::Process.where(kind: "Worker").where(last_heartbeat_at: (now - 120)..).count,
20
+ queue_depth: ready.count,
21
+ oldest_queued_age_seconds: oldest && [now.to_i - oldest.to_i, 0].max,
22
+ recent_processing_at: recent&.utc&.iso8601
23
+ }
24
+
25
+ jobs = block.merge(
26
+ adapter: "solid_queue",
27
+ processed_last_5m: ::SolidQueue::Job.where(finished_at: since..).count,
28
+ failed_last_5m: ::SolidQueue::FailedExecution.where(created_at: since..).count
29
+ )
30
+ [jobs, block]
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upstate
4
+ module Rails
5
+ module Jobs
6
+ NO_QUEUE = %w[async inline test].freeze
7
+
8
+ module_function
9
+
10
+ # Returns [jobs_hash, solid_queue_block_or_nil]. Raises if the adapter's store is unreachable.
11
+ def call(now: Time.now)
12
+ case adapter_name
13
+ when "solid_queue" then SolidQueueAdapter.call(now: now)
14
+ when "sidekiq" then SidekiqAdapter.call(now: now)
15
+ when "good_job" then GoodJobAdapter.call(now: now)
16
+ when "none" then [empty("none"), nil]
17
+ else [empty("unsupported"), nil]
18
+ end
19
+ end
20
+
21
+ def adapter_name
22
+ return "none" unless defined?(::ActiveJob::Base)
23
+
24
+ name = ::ActiveJob::Base.queue_adapter_name.to_s
25
+ return name if %w[solid_queue sidekiq good_job].include?(name)
26
+ return "none" if NO_QUEUE.include?(name) || name.empty?
27
+
28
+ "unsupported"
29
+ rescue StandardError
30
+ "unsupported"
31
+ end
32
+
33
+ def empty(adapter)
34
+ {adapter: adapter, queue_depth: nil, oldest_queued_age_seconds: nil, workers: nil,
35
+ processed_last_5m: nil, failed_last_5m: nil, recent_processing_at: nil}
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upstate
4
+ module Rails
5
+ class Railtie < ::Rails::Railtie
6
+ # Prepended (not appended) so a catch-all route in the host app cannot shadow it.
7
+ initializer "upstate_rails.routes" do |app|
8
+ app.routes.prepend do
9
+ get Upstate::Rails::PATH, to: Upstate::Rails::Endpoint.new
10
+ end
11
+ end
12
+
13
+ # The observer calls the container IP over plain HTTP, so Host authorization and
14
+ # force_ssl redirects must not apply to the health path.
15
+ initializer "upstate_rails.exclusions", before: :build_middleware_stack do |app|
16
+ path = Upstate::Rails::PATH
17
+ wrap = lambda do |existing|
18
+ ->(request) { request.path == path || (existing.respond_to?(:call) && existing.call(request)) }
19
+ end
20
+
21
+ hosts = (app.config.host_authorization || {}).dup
22
+ hosts[:exclude] = wrap.call(hosts[:exclude])
23
+ app.config.host_authorization = hosts
24
+
25
+ ssl = (app.config.ssl_options || {}).dup
26
+ redirect = ssl[:redirect]
27
+ redirect = {} unless redirect.is_a?(Hash)
28
+ ssl[:redirect] = redirect.merge(exclude: wrap.call(redirect[:exclude]))
29
+ app.config.ssl_options = ssl
30
+ end
31
+
32
+ # Collect action rollups only when the endpoint is switched on.
33
+ config.after_initialize do
34
+ next if ENV["UPSTATE_HEALTH_TOKEN"].to_s.empty?
35
+
36
+ Upstate::Rails.subscribe!
37
+ at_exit { Upstate::Rails.rollup.flush! }
38
+ end
39
+
40
+ generators do
41
+ require "generators/upstate/install/install_generator"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upstate
4
+ module Rails
5
+ module Sections
6
+ module_function
7
+
8
+ def database
9
+ return nil unless defined?(::ActiveRecord::Base)
10
+
11
+ ok = begin
12
+ ::ActiveRecord::Base.connection_pool.with_connection { |c| c.select_value("SELECT 1") }
13
+ true
14
+ rescue StandardError
15
+ false
16
+ end
17
+ return {ok: false, pending_migrations: nil} unless ok
18
+
19
+ {ok: true, pending_migrations: pending_migrations}
20
+ end
21
+
22
+ # ConnectionPool#migration_context exists on 7.1+; older shapes fall back to the connection.
23
+ def pending_migrations
24
+ pool = ::ActiveRecord::Base.connection_pool
25
+ context = if pool.respond_to?(:migration_context)
26
+ pool.migration_context
27
+ else
28
+ pool.with_connection(&:migration_context)
29
+ end
30
+ context.needs_migration?
31
+ rescue StandardError
32
+ nil
33
+ end
34
+
35
+ def active_record_pool
36
+ return nil unless defined?(::ActiveRecord::Base)
37
+
38
+ stat = ::ActiveRecord::Base.connection_pool.stat
39
+ {size: stat[:size], busy: stat[:busy], waiting: stat[:waiting]}
40
+ end
41
+
42
+ # Sums across cluster workers. In cluster mode a worker's view of its siblings comes
43
+ # from the master's last snapshot, so values are best effort.
44
+ def puma
45
+ return nil unless defined?(::Puma) && ::Puma.respond_to?(:stats_hash)
46
+
47
+ stats = ::Puma.stats_hash
48
+ return nil unless stats.is_a?(Hash) && !stats.empty?
49
+
50
+ stats = stats.transform_keys(&:to_sym)
51
+ if stats[:worker_status]
52
+ statuses = stats[:worker_status].map { |w| (w[:last_status] || w["last_status"] || {}).transform_keys(&:to_sym) }
53
+ {
54
+ workers: stats[:workers],
55
+ booted_workers: stats[:booted_workers],
56
+ max_threads: sum(statuses, :max_threads),
57
+ backlog: sum(statuses, :backlog),
58
+ pool_capacity: sum(statuses, :pool_capacity)
59
+ }
60
+ else
61
+ {workers: 1, booted_workers: 1, max_threads: stats[:max_threads],
62
+ backlog: stats[:backlog], pool_capacity: stats[:pool_capacity]}
63
+ end
64
+ end
65
+
66
+ def sum(rows, key)
67
+ values = rows.filter_map { |r| r[key] }
68
+ values.empty? ? nil : values.sum
69
+ end
70
+
71
+ PROBED = {"SolidCache::Store" => "solid_cache", "ActiveSupport::Cache::RedisCacheStore" => "redis_cache_store"}.freeze
72
+
73
+ # Matches by class name, never by constant, so autoloaded stores (and their gems)
74
+ # are not loaded just to be compared against.
75
+ def cache
76
+ store = ::Rails.cache
77
+ name = store.class.ancestors.filter_map { |k| PROBED[k.name] }.first
78
+ return nil unless name
79
+
80
+ key = "upstate:health-probe"
81
+ store.write(key, "1", expires_in: 60)
82
+ {store: name, ok: store.read(key) == "1"}
83
+ rescue StandardError
84
+ name ? {store: name, ok: false} : nil
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upstate
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "time"
6
+ require "tmpdir"
7
+ require "securerandom"
8
+ require "active_support"
9
+ require "active_support/notifications"
10
+ require "active_support/security_utils"
11
+ require "rails/railtie"
12
+
13
+ require_relative "rails/version"
14
+
15
+ # NOTE: inside this namespace, `Rails` means Upstate::Rails. Use `::Rails` for the framework.
16
+ module Upstate
17
+ module Rails
18
+ PATH = "/upstate/health"
19
+ BOOTED_AT = Time.now.utc.freeze
20
+
21
+ class << self
22
+ # The per-process action rollup. Replaceable in tests.
23
+ attr_writer :rollup
24
+
25
+ def rollup
26
+ @rollup ||= ActionRollup.new
27
+ end
28
+
29
+ # Subscribes once to process_action.action_controller.
30
+ def subscribe!
31
+ return if @subscriber
32
+
33
+ @subscriber = ActiveSupport::Notifications.subscribe("process_action.action_controller") do |event|
34
+ record_event(event)
35
+ end
36
+ end
37
+
38
+ def unsubscribe!
39
+ ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
40
+ @subscriber = nil
41
+ end
42
+
43
+ private
44
+
45
+ # Never raises into the request. Only the controller#action name, timings and an
46
+ # error flag are kept; params, paths, SQL and exception details are not read.
47
+ def record_event(event)
48
+ payload = event.payload
49
+ status = payload[:status].to_i
50
+ error = status >= 500 || !payload[:exception].nil? || !payload[:exception_object].nil?
51
+ rollup.record("#{payload[:controller]}##{payload[:action]}", event.duration, payload[:db_runtime], error)
52
+ rescue StandardError
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ require_relative "rails/action_store"
60
+ require_relative "rails/action_rollup"
61
+ require_relative "rails/jobs/solid_queue"
62
+ require_relative "rails/jobs/sidekiq"
63
+ require_relative "rails/jobs/good_job"
64
+ require_relative "rails/jobs"
65
+ require_relative "rails/sections"
66
+ require_relative "rails/health"
67
+ require_relative "rails/endpoint"
68
+ require_relative "rails/railtie"
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: upstate-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Upstate
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: railties
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: actionpack
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.1'
55
+ description: 'Adds a bearer-protected GET /upstate/health to a Rails app. Reports
56
+ aggregate counts and fixed-bucket timings only: no params, paths, SQL, IPs, user
57
+ agents, or job arguments.'
58
+ email:
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - LICENSE.txt
65
+ - README.md
66
+ - lib/generators/upstate/install/install_generator.rb
67
+ - lib/upstate/rails.rb
68
+ - lib/upstate/rails/action_rollup.rb
69
+ - lib/upstate/rails/action_store.rb
70
+ - lib/upstate/rails/endpoint.rb
71
+ - lib/upstate/rails/health.rb
72
+ - lib/upstate/rails/jobs.rb
73
+ - lib/upstate/rails/jobs/good_job.rb
74
+ - lib/upstate/rails/jobs/sidekiq.rb
75
+ - lib/upstate/rails/jobs/solid_queue.rb
76
+ - lib/upstate/rails/railtie.rb
77
+ - lib/upstate/rails/sections.rb
78
+ - lib/upstate/rails/version.rb
79
+ homepage: https://github.com/Upstate-run/upstate-rails
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ homepage_uri: https://github.com/Upstate-run/upstate-rails
84
+ source_code_uri: https://github.com/Upstate-run/upstate-rails
85
+ changelog_uri: https://github.com/Upstate-run/upstate-rails/blob/main/CHANGELOG.md
86
+ rubygems_mfa_required: 'true'
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '3.1'
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.5.16
103
+ signing_key:
104
+ specification_version: 4
105
+ summary: 'Rails app health for Upstate: boot facts, migrations, Puma, jobs, and slowest
106
+ actions. Counts only.'
107
+ test_files: []