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 +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +89 -0
- data/lib/generators/upstate/install/install_generator.rb +46 -0
- data/lib/upstate/rails/action_rollup.rb +100 -0
- data/lib/upstate/rails/action_store.rb +113 -0
- data/lib/upstate/rails/endpoint.rb +35 -0
- data/lib/upstate/rails/health.rb +63 -0
- data/lib/upstate/rails/jobs/good_job.rb +31 -0
- data/lib/upstate/rails/jobs/sidekiq.rb +52 -0
- data/lib/upstate/rails/jobs/solid_queue.rb +35 -0
- data/lib/upstate/rails/jobs.rb +39 -0
- data/lib/upstate/rails/railtie.rb +45 -0
- data/lib/upstate/rails/sections.rb +88 -0
- data/lib/upstate/rails/version.rb +7 -0
- data/lib/upstate/rails.rb +68 -0
- metadata +107 -0
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
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,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: []
|