rails_orbit 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 +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +241 -0
- data/app/assets/javascripts/rails_orbit/application.js +232 -0
- data/app/assets/stylesheets/rails_orbit/application.css +536 -0
- data/app/controllers/rails_orbit/application_controller.rb +26 -0
- data/app/controllers/rails_orbit/dashboard_controller.rb +84 -0
- data/app/controllers/rails_orbit/stream_controller.rb +55 -0
- data/app/helpers/rails_orbit/dashboard_helper.rb +44 -0
- data/app/helpers/rails_orbit/icon_helper.rb +19 -0
- data/app/jobs/rails_orbit/application_job.rb +4 -0
- data/app/jobs/rails_orbit/retention_job.rb +22 -0
- data/app/models/rails_orbit/application_record.rb +6 -0
- data/app/models/rails_orbit/metric.rb +97 -0
- data/app/views/layouts/rails_orbit/application.html.erb +20 -0
- data/app/views/rails_orbit/dashboard/_overview_card.html.erb +17 -0
- data/app/views/rails_orbit/dashboard/cache.html.erb +58 -0
- data/app/views/rails_orbit/dashboard/errors.html.erb +54 -0
- data/app/views/rails_orbit/dashboard/jobs.html.erb +64 -0
- data/app/views/rails_orbit/dashboard/overview.html.erb +67 -0
- data/app/views/rails_orbit/shared/_delta.html.erb +14 -0
- data/app/views/rails_orbit/shared/_nav.html.erb +26 -0
- data/app/views/rails_orbit/shared/_range_picker.html.erb +5 -0
- data/app/views/rails_orbit/stream/_cache_stats.html.erb +9 -0
- data/app/views/rails_orbit/stream/_error_count.html.erb +1 -0
- data/app/views/rails_orbit/stream/_queue_stats.html.erb +8 -0
- data/app/views/rails_orbit/stream/index.turbo_stream.erb +11 -0
- data/config/routes.rb +9 -0
- data/lib/generators/rails_orbit/install_generator.rb +55 -0
- data/lib/generators/rails_orbit/templates/create_orbit_metrics.rb.erb +14 -0
- data/lib/generators/rails_orbit/templates/initializer.rb +31 -0
- data/lib/rails_orbit/configuration.rb +73 -0
- data/lib/rails_orbit/database_setup.rb +87 -0
- data/lib/rails_orbit/engine.rb +80 -0
- data/lib/rails_orbit/instrumentation.rb +83 -0
- data/lib/rails_orbit/kamal/config_reader.rb +32 -0
- data/lib/rails_orbit/kamal/poller.rb +64 -0
- data/lib/rails_orbit/kamal/stats_collector.rb +42 -0
- data/lib/rails_orbit/metric_writer.rb +37 -0
- data/lib/rails_orbit/time_range.rb +39 -0
- data/lib/rails_orbit/version.rb +3 -0
- data/lib/rails_orbit.rb +24 -0
- data/lib/tasks/rails_orbit.rake +60 -0
- data/public/assets/rails_orbit/application.css +536 -0
- data/public/assets/rails_orbit/application.js +237 -0
- metadata +264 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CreateRailsOrbitMetrics < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :rails_orbit_metrics do |t|
|
|
4
|
+
t.string :key, null: false, limit: 255
|
|
5
|
+
t.float :value, null: false
|
|
6
|
+
t.string :dimension, limit: 255
|
|
7
|
+
t.datetime :recorded_at, null: false, precision: 6
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
add_index :rails_orbit_metrics, [:key, :recorded_at]
|
|
11
|
+
add_index :rails_orbit_metrics, :recorded_at
|
|
12
|
+
add_index :rails_orbit_metrics, [:key, :recorded_at, :value], name: "idx_rails_orbit_metrics_cover"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
RailsOrbit.configure do |config|
|
|
2
|
+
# ── Storage ───────────────────────────────────────────────────────────────
|
|
3
|
+
# :sqlite → writes to db/rails_orbit.sqlite3 (default)
|
|
4
|
+
# :host_db → writes to host app's primary DB, all tables prefixed orbit_
|
|
5
|
+
# :external → provide config.storage_url below
|
|
6
|
+
config.storage_adapter = :sqlite
|
|
7
|
+
|
|
8
|
+
# Required only when storage_adapter is :external
|
|
9
|
+
# config.storage_url = ENV["ORBIT_DATABASE_URL"]
|
|
10
|
+
|
|
11
|
+
# ── Authentication ────────────────────────────────────────────────────────
|
|
12
|
+
# Provide a block that will be called as a before_action on the dashboard.
|
|
13
|
+
# Default: HTTP Basic Auth via ORBIT_USER / ORBIT_PASSWORD env vars.
|
|
14
|
+
# config.authenticate_with do |controller|
|
|
15
|
+
# controller.authenticate_or_request_with_http_basic("Orbit") do |name, password|
|
|
16
|
+
# ActiveSupport::SecurityUtils.secure_compare(name, ENV.fetch("ORBIT_USER", "orbit")) &
|
|
17
|
+
# ActiveSupport::SecurityUtils.secure_compare(password, ENV.fetch("ORBIT_PASSWORD", "changeme"))
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
|
|
21
|
+
# ── Data retention ────────────────────────────────────────────────────────
|
|
22
|
+
config.retention_days = 7
|
|
23
|
+
|
|
24
|
+
# ── Kamal integration (disabled by default) ───────────────────────────────
|
|
25
|
+
config.kamal_enabled = false
|
|
26
|
+
config.kamal_ssh_key_path = nil
|
|
27
|
+
|
|
28
|
+
# ── Dashboard ─────────────────────────────────────────────────────────────
|
|
29
|
+
config.dashboard_title = "Orbit"
|
|
30
|
+
config.poll_interval = 5
|
|
31
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module RailsOrbit
|
|
2
|
+
class Configuration
|
|
3
|
+
VALID_ADAPTERS = %i[sqlite host_db external].freeze
|
|
4
|
+
|
|
5
|
+
attr_accessor :storage_adapter, :storage_url, :retention_days,
|
|
6
|
+
:kamal_enabled, :kamal_ssh_key_path,
|
|
7
|
+
:dashboard_title, :poll_interval
|
|
8
|
+
attr_reader :auth_block
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@storage_adapter = :sqlite
|
|
12
|
+
@storage_url = nil
|
|
13
|
+
@retention_days = 7
|
|
14
|
+
@kamal_enabled = false
|
|
15
|
+
@kamal_ssh_key_path = nil
|
|
16
|
+
@dashboard_title = "Orbit"
|
|
17
|
+
@poll_interval = 5
|
|
18
|
+
@auth_block = default_auth_block
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def authenticate_with(&block)
|
|
22
|
+
@auth_block = block
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate!
|
|
26
|
+
validate_storage_adapter!
|
|
27
|
+
validate_external_url!
|
|
28
|
+
validate_kamal_ssh_key!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def validate_storage_adapter!
|
|
34
|
+
return if VALID_ADAPTERS.include?(@storage_adapter)
|
|
35
|
+
raise ArgumentError, "Unknown storage_adapter: #{@storage_adapter.inspect}. Valid options: #{VALID_ADAPTERS.join(', ')}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_external_url!
|
|
39
|
+
return unless @storage_adapter == :external && @storage_url.blank?
|
|
40
|
+
raise ArgumentError, "storage_adapter is :external but storage_url is not set."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_kamal_ssh_key!
|
|
44
|
+
return unless @kamal_enabled
|
|
45
|
+
return if @kamal_ssh_key_path || ENV["ORBIT_SSH_KEY_PATH"]
|
|
46
|
+
raise ArgumentError, "kamal_enabled is true but kamal_ssh_key_path is not set. " \
|
|
47
|
+
"Set it in the initializer or via ORBIT_SSH_KEY_PATH env var."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def default_auth_block
|
|
51
|
+
->(controller) {
|
|
52
|
+
user = ENV["ORBIT_USER"]
|
|
53
|
+
password = ENV["ORBIT_PASSWORD"]
|
|
54
|
+
|
|
55
|
+
if user.nil? || password.nil?
|
|
56
|
+
if Rails.env.production?
|
|
57
|
+
Rails.logger.error("[rails_orbit] ORBIT_USER and ORBIT_PASSWORD must be set in production. Dashboard access denied.")
|
|
58
|
+
controller.head(:forbidden)
|
|
59
|
+
return
|
|
60
|
+
else
|
|
61
|
+
user ||= "orbit"
|
|
62
|
+
password ||= "orbit"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
controller.authenticate_or_request_with_http_basic("Orbit") do |name, pwd|
|
|
67
|
+
ActiveSupport::SecurityUtils.secure_compare(name, user) &
|
|
68
|
+
ActiveSupport::SecurityUtils.secure_compare(pwd, password)
|
|
69
|
+
end
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module RailsOrbit
|
|
2
|
+
class DatabaseSetup
|
|
3
|
+
attr_reader :connection
|
|
4
|
+
|
|
5
|
+
def initialize(connection = ApplicationRecord.connection)
|
|
6
|
+
@connection = connection
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def run!
|
|
10
|
+
configure_sqlite! if sqlite?
|
|
11
|
+
create_table_unless_exists!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def sqlite?
|
|
17
|
+
adapter_name.include?("sqlite")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def postgres?
|
|
21
|
+
adapter_name.include?("postgres")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def adapter_name
|
|
25
|
+
connection.adapter_name.downcase
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def configure_sqlite!
|
|
29
|
+
%w[journal_mode=WAL busy_timeout=5000 synchronous=NORMAL].each do |pragma|
|
|
30
|
+
connection.raw_connection.execute("PRAGMA #{pragma}")
|
|
31
|
+
end
|
|
32
|
+
rescue => e
|
|
33
|
+
Rails.logger.debug("[rails_orbit] SQLite PRAGMA setup: #{e.message}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def create_table_unless_exists!
|
|
37
|
+
table = Metric.table_name
|
|
38
|
+
return if connection.table_exists?(table)
|
|
39
|
+
|
|
40
|
+
connection.execute(create_table_ddl(table))
|
|
41
|
+
add_indexes!(table)
|
|
42
|
+
Rails.logger.info("[rails_orbit] Created #{table} table (adapter: #{adapter_name})")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def create_table_ddl(table)
|
|
46
|
+
if sqlite?
|
|
47
|
+
<<~SQL
|
|
48
|
+
CREATE TABLE IF NOT EXISTS #{table} (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
key VARCHAR(255) NOT NULL,
|
|
51
|
+
value FLOAT NOT NULL,
|
|
52
|
+
dimension VARCHAR(255),
|
|
53
|
+
recorded_at DATETIME NOT NULL
|
|
54
|
+
)
|
|
55
|
+
SQL
|
|
56
|
+
elsif postgres?
|
|
57
|
+
<<~SQL
|
|
58
|
+
CREATE TABLE IF NOT EXISTS #{table} (
|
|
59
|
+
id BIGSERIAL PRIMARY KEY,
|
|
60
|
+
key VARCHAR(255) NOT NULL,
|
|
61
|
+
value DOUBLE PRECISION NOT NULL,
|
|
62
|
+
dimension VARCHAR(255),
|
|
63
|
+
recorded_at TIMESTAMP(6) NOT NULL
|
|
64
|
+
)
|
|
65
|
+
SQL
|
|
66
|
+
else
|
|
67
|
+
<<~SQL
|
|
68
|
+
CREATE TABLE IF NOT EXISTS #{table} (
|
|
69
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
70
|
+
key VARCHAR(255) NOT NULL,
|
|
71
|
+
value DOUBLE NOT NULL,
|
|
72
|
+
dimension VARCHAR(255),
|
|
73
|
+
recorded_at DATETIME(6) NOT NULL
|
|
74
|
+
)
|
|
75
|
+
SQL
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def add_indexes!(table)
|
|
80
|
+
{ key_rec: "(key, recorded_at)", rec: "(recorded_at)", cover: "(key, recorded_at, value)" }.each do |name, cols|
|
|
81
|
+
connection.execute("CREATE INDEX IF NOT EXISTS idx_#{table}_#{name} ON #{table} #{cols}")
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module RailsOrbit
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace RailsOrbit
|
|
4
|
+
|
|
5
|
+
rake_tasks do
|
|
6
|
+
load root.join("lib", "tasks", "rails_orbit.rake")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
initializer "rails_orbit.establish_connection", after: :load_active_record do
|
|
10
|
+
config.after_initialize do
|
|
11
|
+
orbit_config = RailsOrbit.configuration
|
|
12
|
+
|
|
13
|
+
conn_spec = case orbit_config.storage_adapter
|
|
14
|
+
when :sqlite
|
|
15
|
+
db_path = Rails.root.join("db", "rails_orbit.sqlite3")
|
|
16
|
+
{ adapter: "sqlite3", database: db_path.to_s, timeout: 5000 }
|
|
17
|
+
when :host_db
|
|
18
|
+
ActiveRecord::Base.connection_db_config.configuration_hash.dup
|
|
19
|
+
when :external
|
|
20
|
+
{ url: orbit_config.storage_url }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ApplicationRecord.establish_connection(conn_spec)
|
|
24
|
+
DatabaseSetup.new.run!
|
|
25
|
+
rescue => e
|
|
26
|
+
Rails.logger.warn("[rails_orbit] Database setup failed: #{e.message}. Run `bin/rails rails_orbit:setup` manually.")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
initializer "rails_orbit.assets" do |app|
|
|
31
|
+
if app.config.respond_to?(:assets)
|
|
32
|
+
app.config.assets.precompile += %w[rails_orbit/application.css rails_orbit/application.js]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
initializer "rails_orbit.static_assets" do |app|
|
|
37
|
+
app.middleware.insert_before(::ActionDispatch::Static, ::ActionDispatch::Static, root.join("public").to_s)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
initializer "rails_orbit.ephemeral_warning" do
|
|
41
|
+
next unless RailsOrbit.configuration.storage_adapter == :sqlite
|
|
42
|
+
|
|
43
|
+
signals = %w[DYNO RAILWAY_ENVIRONMENT FLY_APP_NAME]
|
|
44
|
+
next unless signals.any? { |k| ENV.key?(k) }
|
|
45
|
+
|
|
46
|
+
platform = ENV["DYNO"] ? "Heroku" : ENV["RAILWAY_ENVIRONMENT"] ? "Railway" : "Fly.io"
|
|
47
|
+
db_path = Rails.root.join("db", "rails_orbit.sqlite3")
|
|
48
|
+
msg = "[rails_orbit] WARNING: Using :sqlite on #{platform} (ephemeral filesystem). " \
|
|
49
|
+
"#{db_path} will be destroyed on deploy. Use :host_db or :external instead."
|
|
50
|
+
Rails.logger.warn(msg)
|
|
51
|
+
warn(msg)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
initializer "rails_orbit.instrumentation", after: "rails_orbit.establish_connection" do
|
|
55
|
+
ActiveSupport.on_load(:after_initialize) { Instrumentation.subscribe! }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
initializer "rails_orbit.kamal_poller", after: "rails_orbit.instrumentation" do
|
|
59
|
+
ActiveSupport.on_load(:after_initialize) do
|
|
60
|
+
next unless RailsOrbit.configuration.kamal_enabled
|
|
61
|
+
require "rails_orbit/kamal/config_reader"
|
|
62
|
+
require "rails_orbit/kamal/stats_collector"
|
|
63
|
+
require "rails_orbit/kamal/poller"
|
|
64
|
+
Kamal::Poller.start!
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
initializer "rails_orbit.puma_fork_safety" do
|
|
69
|
+
config.after_initialize do
|
|
70
|
+
next unless defined?(Puma) && Puma.respond_to?(:cli_config)
|
|
71
|
+
Puma.cli_config.options[:before_worker_boot] ||= []
|
|
72
|
+
Puma.cli_config.options[:before_worker_boot] << ->(_) { MetricWriter.reset! }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
initializer "rails_orbit.shutdown_hook" do
|
|
77
|
+
at_exit { MetricWriter.shutdown if defined?(MetricWriter) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module RailsOrbit
|
|
2
|
+
module Instrumentation
|
|
3
|
+
SUBSCRIPTIONS = [
|
|
4
|
+
"enqueue.solid_queue",
|
|
5
|
+
"perform.solid_queue",
|
|
6
|
+
"failed_execution.solid_queue",
|
|
7
|
+
"retry_execution.solid_queue",
|
|
8
|
+
"discard_job.solid_queue",
|
|
9
|
+
"cache_read.active_support",
|
|
10
|
+
"cache_write.active_support",
|
|
11
|
+
"cache_delete.active_support",
|
|
12
|
+
"cache_fetch_hit.active_support",
|
|
13
|
+
"error_recorded.solid_errors",
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def subscribe!
|
|
18
|
+
unsubscribe! if @subscriptions
|
|
19
|
+
@subscriptions = SUBSCRIPTIONS.map do |event_name|
|
|
20
|
+
ActiveSupport::Notifications.subscribe(event_name) do |event|
|
|
21
|
+
handle(event)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def unsubscribe!
|
|
27
|
+
Array(@subscriptions).each do |sub|
|
|
28
|
+
ActiveSupport::Notifications.unsubscribe(sub)
|
|
29
|
+
end
|
|
30
|
+
@subscriptions = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def subscribed?
|
|
34
|
+
@subscriptions.present?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle(event)
|
|
38
|
+
key, value, dimension = translate(event)
|
|
39
|
+
return unless key
|
|
40
|
+
MetricWriter.write(key: key, value: value, dimension: dimension)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def translate(event)
|
|
44
|
+
case event.name
|
|
45
|
+
when "enqueue.solid_queue"
|
|
46
|
+
["solid_queue.enqueued", 1, event.payload[:queue_name]]
|
|
47
|
+
|
|
48
|
+
when "perform.solid_queue"
|
|
49
|
+
duration_ms = event.duration.round(2)
|
|
50
|
+
["solid_queue.performed_ms", duration_ms, event.payload[:queue_name]]
|
|
51
|
+
|
|
52
|
+
when "failed_execution.solid_queue"
|
|
53
|
+
["solid_queue.failed", 1, event.payload[:queue_name]]
|
|
54
|
+
|
|
55
|
+
when "retry_execution.solid_queue"
|
|
56
|
+
["solid_queue.retried", 1, event.payload[:queue_name]]
|
|
57
|
+
|
|
58
|
+
when "discard_job.solid_queue"
|
|
59
|
+
["solid_queue.discarded", 1, event.payload[:queue_name]]
|
|
60
|
+
|
|
61
|
+
when "cache_read.active_support"
|
|
62
|
+
hit = event.payload[:hit] ? "hit" : "miss"
|
|
63
|
+
["solid_cache.read_#{hit}", 1, event.payload[:store]]
|
|
64
|
+
|
|
65
|
+
when "cache_write.active_support"
|
|
66
|
+
["solid_cache.write", 1, event.payload[:store]]
|
|
67
|
+
|
|
68
|
+
when "cache_delete.active_support"
|
|
69
|
+
["solid_cache.delete", 1, event.payload[:store]]
|
|
70
|
+
|
|
71
|
+
when "cache_fetch_hit.active_support"
|
|
72
|
+
["solid_cache.fetch_hit", 1, event.payload[:store]]
|
|
73
|
+
|
|
74
|
+
when "error_recorded.solid_errors"
|
|
75
|
+
["solid_errors.recorded", 1, event.payload[:exception_class]]
|
|
76
|
+
|
|
77
|
+
else
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module RailsOrbit
|
|
2
|
+
module Kamal
|
|
3
|
+
class ConfigReader
|
|
4
|
+
DEPLOY_YML = "config/deploy.yml"
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
def load
|
|
8
|
+
@config ||= begin
|
|
9
|
+
path = Rails.root.join(DEPLOY_YML)
|
|
10
|
+
raise "Kamal config not found at #{path}" unless path.exist?
|
|
11
|
+
YAML.safe_load_file(path, permitted_classes: [Symbol])
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def reload!
|
|
16
|
+
@config = nil
|
|
17
|
+
load
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def servers
|
|
21
|
+
config = load
|
|
22
|
+
Array(config.dig("servers", "web")) +
|
|
23
|
+
Array(config.dig("servers", "workers"))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ssh_user
|
|
27
|
+
load.dig("ssh", "user") || "root"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "concurrent"
|
|
2
|
+
|
|
3
|
+
module RailsOrbit
|
|
4
|
+
module Kamal
|
|
5
|
+
class Poller
|
|
6
|
+
POLL_INTERVAL = 30
|
|
7
|
+
|
|
8
|
+
def self.available?
|
|
9
|
+
return @available if defined?(@available)
|
|
10
|
+
@available = begin
|
|
11
|
+
require "sshkit"
|
|
12
|
+
require "sshkit/dsl"
|
|
13
|
+
true
|
|
14
|
+
rescue LoadError
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.start!
|
|
20
|
+
unless available?
|
|
21
|
+
Rails.logger.warn "[rails_orbit] Kamal polling requires the sshkit gem. Add it to your Gemfile."
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
new.schedule
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def schedule
|
|
28
|
+
@task = Concurrent::TimerTask.new(
|
|
29
|
+
execution_interval: POLL_INTERVAL,
|
|
30
|
+
run_now: true
|
|
31
|
+
) { run }
|
|
32
|
+
@task.execute
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def stop
|
|
36
|
+
@task&.shutdown
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run
|
|
40
|
+
config = RailsOrbit.configuration
|
|
41
|
+
servers = ConfigReader.servers
|
|
42
|
+
user = ConfigReader.ssh_user
|
|
43
|
+
key_path = config.kamal_ssh_key_path || ENV["ORBIT_SSH_KEY_PATH"]
|
|
44
|
+
|
|
45
|
+
unless key_path
|
|
46
|
+
Rails.logger.error "[rails_orbit] kamal_ssh_key_path must be set when kamal_enabled is true"
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
collector = StatsCollector.new
|
|
51
|
+
servers.each do |host|
|
|
52
|
+
stats = collector.collect(host: host, user: user, ssh_key_path: key_path)
|
|
53
|
+
stats.each do |s|
|
|
54
|
+
dimension = "#{s[:host]}/#{s[:container]}"
|
|
55
|
+
MetricWriter.write(key: "kamal.cpu_pct", value: s[:cpu_pct], dimension: dimension)
|
|
56
|
+
MetricWriter.write(key: "kamal.mem_pct", value: s[:mem_pct], dimension: dimension)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
rescue => e
|
|
60
|
+
Rails.logger.error "[rails_orbit] Kamal poller error: #{e.class} - #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module RailsOrbit
|
|
2
|
+
module Kamal
|
|
3
|
+
class StatsCollector
|
|
4
|
+
include SSHKit::DSL
|
|
5
|
+
|
|
6
|
+
def collect(host:, user:, ssh_key_path:)
|
|
7
|
+
require "sshkit"
|
|
8
|
+
require "sshkit/dsl"
|
|
9
|
+
|
|
10
|
+
output = nil
|
|
11
|
+
on(SSHKit::Host.new("#{user}@#{host}")) do
|
|
12
|
+
output = capture(:docker, "stats", "--no-stream", "--format",
|
|
13
|
+
"{{.Name}} {{.CPUPerc}} {{.MemPerc}}")
|
|
14
|
+
end
|
|
15
|
+
parse(output, host: host)
|
|
16
|
+
rescue SSHKit::Command::Failed, SocketError => e
|
|
17
|
+
Rails.logger.error "[rails_orbit] SSH stats failed for #{host}: #{e.message}"
|
|
18
|
+
[]
|
|
19
|
+
rescue => e
|
|
20
|
+
Rails.logger.error "[rails_orbit] SSH stats failed for #{host}: #{e.class} - #{e.message}"
|
|
21
|
+
[]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def parse(raw, host:)
|
|
27
|
+
raw.to_s.lines.filter_map do |line|
|
|
28
|
+
parts = line.strip.split
|
|
29
|
+
next unless parts.size == 3
|
|
30
|
+
name, cpu, mem = parts
|
|
31
|
+
{
|
|
32
|
+
host: host,
|
|
33
|
+
container: name,
|
|
34
|
+
cpu_pct: cpu.to_f,
|
|
35
|
+
mem_pct: mem.to_f,
|
|
36
|
+
at: Time.current
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require "concurrent"
|
|
2
|
+
|
|
3
|
+
module RailsOrbit
|
|
4
|
+
module MetricWriter
|
|
5
|
+
class << self
|
|
6
|
+
def executor
|
|
7
|
+
@executor ||= Concurrent::SingleThreadExecutor.new(
|
|
8
|
+
fallback_policy: :discard
|
|
9
|
+
)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def write(key:, value:, dimension: nil)
|
|
13
|
+
executor.post do
|
|
14
|
+
RailsOrbit::ApplicationRecord.connection_pool.with_connection do
|
|
15
|
+
RailsOrbit::Metric.insert(
|
|
16
|
+
{ key: key, value: value, dimension: dimension, recorded_at: Time.current },
|
|
17
|
+
returning: false
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
rescue => e
|
|
21
|
+
Rails.logger.error("[rails_orbit] MetricWriter failed: #{e.message}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def shutdown
|
|
26
|
+
return unless defined?(@executor) && @executor
|
|
27
|
+
executor.shutdown
|
|
28
|
+
executor.wait_for_termination(5)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset!
|
|
32
|
+
shutdown
|
|
33
|
+
@executor = nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module RailsOrbit
|
|
2
|
+
class TimeRange
|
|
3
|
+
OPTIONS = {
|
|
4
|
+
"1h" => { minutes: 60, bucket_minutes: 1, delta_window: 15.minutes, label: "1h" },
|
|
5
|
+
"6h" => { minutes: 360, bucket_minutes: 5, delta_window: 1.hour, label: "6h" },
|
|
6
|
+
"24h" => { minutes: 1440, bucket_minutes: 15, delta_window: 1.hour, label: "24h" },
|
|
7
|
+
"7d" => { minutes: 10080, bucket_minutes: 60, delta_window: 6.hours, label: "7d" },
|
|
8
|
+
"30d" => { minutes: 43200, bucket_minutes: 360, delta_window: 1.day, label: "30d" },
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :key
|
|
12
|
+
|
|
13
|
+
def initialize(param)
|
|
14
|
+
@key = OPTIONS.key?(param) ? param : "24h"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def since
|
|
18
|
+
config[:minutes].minutes.ago
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def bucket_minutes
|
|
22
|
+
config[:bucket_minutes]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delta_window
|
|
26
|
+
config[:delta_window]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def label
|
|
30
|
+
config[:label]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def config
|
|
36
|
+
OPTIONS[@key]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/rails_orbit.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "rails_orbit/version"
|
|
2
|
+
require "rails_orbit/configuration"
|
|
3
|
+
require "rails_orbit/time_range"
|
|
4
|
+
require "rails_orbit/database_setup"
|
|
5
|
+
require "rails_orbit/metric_writer"
|
|
6
|
+
require "rails_orbit/instrumentation"
|
|
7
|
+
require "rails_orbit/engine"
|
|
8
|
+
|
|
9
|
+
module RailsOrbit
|
|
10
|
+
class << self
|
|
11
|
+
def configuration
|
|
12
|
+
@configuration ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def configure
|
|
16
|
+
yield configuration
|
|
17
|
+
configuration.validate!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def reset_configuration!
|
|
21
|
+
@configuration = Configuration.new
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
namespace :rails_orbit do
|
|
2
|
+
desc "Create the rails_orbit_metrics table in the configured database"
|
|
3
|
+
task setup: :environment do
|
|
4
|
+
conn = RailsOrbit::ApplicationRecord.connection
|
|
5
|
+
table = RailsOrbit::Metric.table_name
|
|
6
|
+
config = RailsOrbit.configuration
|
|
7
|
+
|
|
8
|
+
puts "[rails_orbit] Storage adapter: #{config.storage_adapter}"
|
|
9
|
+
puts "[rails_orbit] Database adapter: #{conn.adapter_name.downcase}"
|
|
10
|
+
puts "[rails_orbit] Table name: #{table}"
|
|
11
|
+
|
|
12
|
+
if conn.table_exists?(table)
|
|
13
|
+
puts "[rails_orbit] Table '#{table}' already exists. Nothing to do."
|
|
14
|
+
next
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
RailsOrbit::DatabaseSetup.new(conn).run!
|
|
18
|
+
puts "[rails_orbit] Created '#{table}' table with indexes."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "Show rails_orbit configuration and table status"
|
|
22
|
+
task status: :environment do
|
|
23
|
+
config = RailsOrbit.configuration
|
|
24
|
+
conn = RailsOrbit::ApplicationRecord.connection
|
|
25
|
+
table = RailsOrbit::Metric.table_name
|
|
26
|
+
adapter = conn.adapter_name.downcase
|
|
27
|
+
|
|
28
|
+
puts ""
|
|
29
|
+
puts "rails_orbit status"
|
|
30
|
+
puts "-" * 40
|
|
31
|
+
puts " Storage adapter: #{config.storage_adapter}"
|
|
32
|
+
puts " Database adapter: #{adapter}"
|
|
33
|
+
puts " Table name: #{table}"
|
|
34
|
+
puts " Table exists: #{conn.table_exists?(table)}"
|
|
35
|
+
|
|
36
|
+
if conn.table_exists?(table)
|
|
37
|
+
count = conn.select_value("SELECT COUNT(*) FROM #{conn.quote_table_name(table)}")
|
|
38
|
+
oldest = conn.select_value("SELECT MIN(recorded_at) FROM #{conn.quote_table_name(table)}")
|
|
39
|
+
newest = conn.select_value("SELECT MAX(recorded_at) FROM #{conn.quote_table_name(table)}")
|
|
40
|
+
puts " Metric count: #{count}"
|
|
41
|
+
puts " Oldest metric: #{oldest || 'none'}"
|
|
42
|
+
puts " Newest metric: #{newest || 'none'}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if config.storage_adapter == :sqlite
|
|
46
|
+
db_path = conn.pool.db_config.configuration_hash[:database]
|
|
47
|
+
puts " SQLite path: #{db_path}"
|
|
48
|
+
if File.exist?(db_path.to_s)
|
|
49
|
+
size_kb = (File.size(db_path.to_s) / 1024.0).round(1)
|
|
50
|
+
puts " SQLite size: #{size_kb} KB"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
puts " Retention days: #{config.retention_days}"
|
|
55
|
+
puts " Poll interval: #{config.poll_interval}s"
|
|
56
|
+
puts " Dashboard title: #{config.dashboard_title}"
|
|
57
|
+
puts " Kamal enabled: #{config.kamal_enabled}"
|
|
58
|
+
puts ""
|
|
59
|
+
end
|
|
60
|
+
end
|