lantern-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: b544123b390d3e7fee816bd3f27fdd485334fb5688f45754a52551294d39ef98
4
+ data.tar.gz: 74b280feb07f0d4bdac527c56d206c47b0fd009d293348c7924df76d46c900f8
5
+ SHA512:
6
+ metadata.gz: a14c62f42e4b69f19ae193061a17d981f005e110c691f1f6b13200020cbb7bb1e50182d2c471814244a06b68d69bef54bab6f51230e5291226f3c3653f8bf788
7
+ data.tar.gz: d0f5344237d2684d8a8891a2ce9941669a26d455a3a05fdf01b6102c9c26322410f505db446c76b8043400e39b0b86491af165e5341d0a0b9366066ec92ffb48
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eric Mumbower
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,83 @@
1
+ # lantern-rails
2
+
3
+ Postgres health monitoring for Rails apps. Collects database metrics and sends them to [Lantern](https://uselantern.dev) — a hosted dashboard that scores your database health and surfaces actionable recommendations.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "lantern-rails"
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```
16
+ bundle install
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ Generate an API key at [uselantern.dev](https://uselantern.dev), then create an initializer:
22
+
23
+ ```ruby
24
+ # config/initializers/lantern.rb
25
+ Lantern::Rails.configure do |config|
26
+ config.api_key = ENV["LANTERN_API_KEY"]
27
+ end
28
+ ```
29
+
30
+ That's it. The collector starts automatically when your Rails app boots in production.
31
+
32
+ ## Configuration
33
+
34
+ ```ruby
35
+ Lantern::Rails.configure do |config|
36
+ config.api_key = ENV["LANTERN_API_KEY"]
37
+ config.host = "https://uselantern.dev" # default
38
+ config.interval = 300 # seconds, default 5 min
39
+ config.collect_in_environments = %w[production staging] # default
40
+ end
41
+ ```
42
+
43
+ ## Deploy tracking
44
+
45
+ To correlate deploys with health score changes, call this from your deploy pipeline or a Rails initializer that runs after deploy:
46
+
47
+ ```ruby
48
+ Lantern::Rails.report_deploy
49
+ ```
50
+
51
+ Or pass explicit values:
52
+
53
+ ```ruby
54
+ Lantern::Rails.report_deploy(
55
+ git_sha: ENV["GIT_SHA"],
56
+ git_author: ENV["GIT_AUTHOR"],
57
+ deployer: "github-actions"
58
+ )
59
+ ```
60
+
61
+ ## What gets collected
62
+
63
+ Every collection interval, the gem queries your Postgres instance for:
64
+
65
+ - Buffer and index cache hit ratios
66
+ - Unused index count and total size
67
+ - Table bloat ratio
68
+ - Long-running queries (> 30 seconds)
69
+ - Dead tuple counts and vacuum status
70
+ - Active connections vs. max_connections
71
+ - pg_stat_bgwriter stats_reset timestamp (to detect false positives)
72
+
73
+ No query text, no table data, no PII. Only aggregate pg_stat_* metrics.
74
+
75
+ ## Requirements
76
+
77
+ - Ruby >= 3.1
78
+ - Rails >= 7.0
79
+ - PostgreSQL (any version with pg_stat_statements)
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,192 @@
1
+ module Lantern
2
+ module Rails
3
+ class Collector
4
+ def collect
5
+ connection = ActiveRecord::Base.connection
6
+
7
+ cache = collect_cache_metrics(connection)
8
+ indexes = collect_index_metrics(connection)
9
+ bloat = collect_bloat_metrics(connection)
10
+ queries = collect_query_metrics(connection)
11
+ vacuum = collect_vacuum_metrics(connection)
12
+ conns = collect_connection_metrics(connection)
13
+ git = GitMetadata.collect
14
+
15
+ {
16
+ collected_at: Time.current.iso8601,
17
+
18
+ shared_buffer_hit_ratio: cache[:shared_buffer_hit_ratio],
19
+ index_hit_ratio: cache[:index_hit_ratio],
20
+
21
+ unused_index_count: indexes[:unused_index_count],
22
+ unused_index_size_bytes: indexes[:unused_index_size_bytes],
23
+ stats_reset_at: indexes[:stats_reset_at],
24
+
25
+ estimated_bloat_bytes: bloat[:estimated_bloat_bytes],
26
+ bloat_ratio: bloat[:bloat_ratio],
27
+
28
+ long_running_query_count: queries[:long_running_query_count],
29
+ longest_query_duration_ms: queries[:longest_query_duration_ms],
30
+
31
+ total_dead_tuples: vacuum[:total_dead_tuples],
32
+ total_live_tuples: vacuum[:total_live_tuples],
33
+ tables_needing_vacuum: vacuum[:tables_needing_vacuum],
34
+ tables_never_vacuumed: vacuum[:tables_never_vacuumed],
35
+ oldest_vacuum_age_seconds: vacuum[:oldest_vacuum_age_seconds],
36
+
37
+ active_connections: conns[:active_connections],
38
+ max_connections: conns[:max_connections],
39
+ connection_utilization: conns[:connection_utilization],
40
+
41
+ git_sha: git[:sha],
42
+ git_message: git[:message]
43
+ }
44
+ rescue => e
45
+ ::Rails.logger.error("[Lantern] Collection failed: #{e.message}")
46
+ nil
47
+ end
48
+
49
+ private
50
+
51
+ def collect_cache_metrics(conn)
52
+ row = conn.select_one(<<~SQL)
53
+ SELECT
54
+ round(
55
+ sum(heap_blks_hit)::numeric /
56
+ nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) * 100,
57
+ 4
58
+ ) AS shared_buffer_hit_ratio,
59
+ round(
60
+ sum(idx_blks_hit)::numeric /
61
+ nullif(sum(idx_blks_hit) + sum(idx_blks_read), 0) * 100,
62
+ 4
63
+ ) AS index_hit_ratio
64
+ FROM pg_statio_user_tables
65
+ SQL
66
+
67
+ {
68
+ shared_buffer_hit_ratio: row["shared_buffer_hit_ratio"]&.to_f,
69
+ index_hit_ratio: row["index_hit_ratio"]&.to_f
70
+ }
71
+ end
72
+
73
+ def collect_index_metrics(conn)
74
+ row = conn.select_one(<<~SQL)
75
+ SELECT
76
+ count(*) AS unused_index_count,
77
+ coalesce(sum(pg_relation_size(indexrelid)), 0)::bigint AS unused_index_size_bytes,
78
+ (SELECT stats_reset FROM pg_stat_bgwriter) AS stats_reset_at
79
+ FROM pg_stat_user_indexes
80
+ JOIN pg_index USING (indexrelid)
81
+ WHERE idx_scan = 0
82
+ AND NOT indisprimary
83
+ AND NOT indisunique
84
+ SQL
85
+
86
+ {
87
+ unused_index_count: row["unused_index_count"].to_i,
88
+ unused_index_size_bytes: row["unused_index_size_bytes"].to_i,
89
+ stats_reset_at: row["stats_reset_at"]
90
+ }
91
+ end
92
+
93
+ def collect_bloat_metrics(conn)
94
+ row = conn.select_one(<<~SQL)
95
+ SELECT
96
+ coalesce(
97
+ sum(
98
+ CASE
99
+ WHEN n_dead_tup > 0 AND (n_live_tup + n_dead_tup) > 0
100
+ THEN (n_dead_tup::numeric / (n_live_tup + n_dead_tup) * pg_total_relation_size(relid))
101
+ ELSE 0
102
+ END
103
+ )::bigint,
104
+ 0
105
+ ) AS estimated_bloat_bytes,
106
+ coalesce(
107
+ round(
108
+ sum(n_dead_tup)::numeric /
109
+ nullif(sum(n_live_tup + n_dead_tup), 0) * 100,
110
+ 2
111
+ ),
112
+ 0
113
+ ) AS bloat_ratio
114
+ FROM pg_stat_user_tables
115
+ SQL
116
+
117
+ {
118
+ estimated_bloat_bytes: row["estimated_bloat_bytes"].to_i,
119
+ bloat_ratio: row["bloat_ratio"].to_f
120
+ }
121
+ end
122
+
123
+ def collect_query_metrics(conn)
124
+ row = conn.select_one(<<~SQL)
125
+ SELECT
126
+ count(*) AS long_running_query_count,
127
+ coalesce(
128
+ max(extract(epoch from now() - query_start) * 1000)::bigint,
129
+ 0
130
+ ) AS longest_query_duration_ms
131
+ FROM pg_stat_activity
132
+ WHERE state = 'active'
133
+ AND query_start < now() - interval '30 seconds'
134
+ AND query NOT ILIKE '%pg_stat_activity%'
135
+ SQL
136
+
137
+ {
138
+ long_running_query_count: row["long_running_query_count"].to_i,
139
+ longest_query_duration_ms: row["longest_query_duration_ms"].to_i
140
+ }
141
+ end
142
+
143
+ def collect_vacuum_metrics(conn)
144
+ row = conn.select_one(<<~SQL)
145
+ SELECT
146
+ coalesce(sum(n_dead_tup), 0)::bigint AS total_dead_tuples,
147
+ coalesce(sum(n_live_tup), 0)::bigint AS total_live_tuples,
148
+ count(*) FILTER (
149
+ WHERE n_dead_tup > n_live_tup * 0.1 AND n_live_tup > 0
150
+ ) AS tables_needing_vacuum,
151
+ count(*) FILTER (
152
+ WHERE last_vacuum IS NULL AND last_autovacuum IS NULL AND n_live_tup > 0
153
+ ) AS tables_never_vacuumed,
154
+ coalesce(
155
+ max(extract(epoch from now() - greatest(last_vacuum, last_autovacuum)))::int,
156
+ 0
157
+ ) AS oldest_vacuum_age_seconds
158
+ FROM pg_stat_user_tables
159
+ SQL
160
+
161
+ {
162
+ total_dead_tuples: row["total_dead_tuples"].to_i,
163
+ total_live_tuples: row["total_live_tuples"].to_i,
164
+ tables_needing_vacuum: row["tables_needing_vacuum"].to_i,
165
+ tables_never_vacuumed: row["tables_never_vacuumed"].to_i,
166
+ oldest_vacuum_age_seconds: row["oldest_vacuum_age_seconds"].to_i
167
+ }
168
+ end
169
+
170
+ def collect_connection_metrics(conn)
171
+ row = conn.select_one(<<~SQL)
172
+ SELECT
173
+ count(*) FILTER (WHERE state IS NOT NULL) AS active_connections,
174
+ current_setting('max_connections')::int AS max_connections,
175
+ round(
176
+ count(*) FILTER (WHERE state IS NOT NULL)::numeric /
177
+ current_setting('max_connections')::numeric * 100,
178
+ 2
179
+ ) AS connection_utilization
180
+ FROM pg_stat_activity
181
+ WHERE datname = current_database()
182
+ SQL
183
+
184
+ {
185
+ active_connections: row["active_connections"].to_i,
186
+ max_connections: row["max_connections"].to_i,
187
+ connection_utilization: row["connection_utilization"].to_f
188
+ }
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,25 @@
1
+ module Lantern
2
+ module Rails
3
+ class Configuration
4
+ attr_accessor :api_key, :host, :interval, :environment, :enabled, :collect_in_environments
5
+
6
+ def initialize
7
+ @host = "https://uselantern.dev"
8
+ @interval = 300 # 5 minutes
9
+ @environment = detect_environment
10
+ @enabled = true
11
+ @collect_in_environments = %w[production staging]
12
+ end
13
+
14
+ def valid?
15
+ api_key.present?
16
+ end
17
+
18
+ private
19
+
20
+ def detect_environment
21
+ defined?(::Rails) ? ::Rails.env.to_s : "production"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ module Lantern
2
+ module Rails
3
+ module GitMetadata
4
+ def self.collect
5
+ sha = run("git rev-parse --short HEAD")
6
+ message = run("git log -1 --pretty=%s")
7
+ author = run("git log -1 --pretty=%ae")
8
+ { sha: sha, message: message, author: author }
9
+ rescue => e
10
+ ::Rails.logger.debug("[Lantern] Git metadata unavailable: #{e.message}")
11
+ { sha: nil, message: nil, author: nil }
12
+ end
13
+
14
+ def self.run(cmd)
15
+ result = `#{cmd} 2>/dev/null`.strip
16
+ result.empty? ? nil : result
17
+ end
18
+ private_class_method :run
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ module Lantern
2
+ module Rails
3
+ class Railtie < ::Rails::Railtie
4
+ config.after_initialize do
5
+ config = Lantern::Rails.configuration
6
+ next unless config.valid?
7
+ next unless config.enabled
8
+ next unless config.collect_in_environments.include?(::Rails.env.to_s)
9
+
10
+ runner = Lantern::Rails::Runner.new(config)
11
+
12
+ at_exit { runner.stop }
13
+
14
+ runner.start
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,48 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module Lantern
6
+ module Rails
7
+ class Reporter
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def report_snapshot(payload)
13
+ post("/api/v1/snapshots", payload)
14
+ end
15
+
16
+ def report_deploy(payload)
17
+ post("/api/v1/deploy_events", payload)
18
+ end
19
+
20
+ private
21
+
22
+ def post(path, payload)
23
+ uri = URI("#{@config.host}#{path}")
24
+ http = Net::HTTP.new(uri.host, uri.port)
25
+ http.use_ssl = uri.scheme == "https"
26
+ http.open_timeout = 5
27
+ http.read_timeout = 10
28
+
29
+ request = Net::HTTP::Post.new(uri)
30
+ request["Authorization"] = "Bearer #{@config.api_key}"
31
+ request["Content-Type"] = "application/json"
32
+ request["Accept"] = "application/json"
33
+ request.body = payload.to_json
34
+
35
+ response = http.request(request)
36
+
37
+ unless response.is_a?(Net::HTTPSuccess)
38
+ ::Rails.logger.warn("[Lantern] API returned #{response.code}: #{response.body}")
39
+ end
40
+
41
+ response
42
+ rescue => e
43
+ ::Rails.logger.error("[Lantern] Failed to report to #{path}: #{e.message}")
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,44 @@
1
+ module Lantern
2
+ module Rails
3
+ class Runner
4
+ def initialize(config)
5
+ @config = config
6
+ @thread = nil
7
+ @stopping = false
8
+ end
9
+
10
+ def start
11
+ return unless @config.valid? && @config.enabled
12
+
13
+ @thread = Thread.new do
14
+ ::Rails.logger.info("[Lantern] Collector started (interval: #{@config.interval}s)")
15
+ loop do
16
+ break if @stopping
17
+ collect_and_report
18
+ sleep @config.interval
19
+ break if @stopping
20
+ end
21
+ end
22
+
23
+ @thread.abort_on_exception = false
24
+ @thread.name = "lantern-collector"
25
+ end
26
+
27
+ def stop
28
+ @stopping = true
29
+ @thread&.join(5)
30
+ end
31
+
32
+ private
33
+
34
+ def collect_and_report
35
+ payload = Collector.new.collect
36
+ return unless payload
37
+
38
+ Reporter.new(@config).report_snapshot(payload)
39
+ rescue => e
40
+ ::Rails.logger.error("[Lantern] Runner error: #{e.message}")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ module Lantern
2
+ module Rails
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,43 @@
1
+ require "lantern/rails/version"
2
+ require "lantern/rails/configuration"
3
+ require "lantern/rails/git_metadata"
4
+ require "lantern/rails/collector"
5
+ require "lantern/rails/reporter"
6
+ require "lantern/rails/runner"
7
+ require "lantern/rails/railtie" if defined?(::Rails)
8
+
9
+ module Lantern
10
+ module Rails
11
+ class << self
12
+ def configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield configuration
18
+ end
19
+
20
+ # Manually report a deploy event. Call this from your CI/CD pipeline
21
+ # or a deploy hook in your Rails app.
22
+ #
23
+ # Example (in a Rake task or initializer after deploy):
24
+ # Lantern::Rails.report_deploy
25
+ def report_deploy(git_sha: nil, git_message: nil, git_author: nil, deployer: nil)
26
+ return unless configuration.valid?
27
+
28
+ git = GitMetadata.collect
29
+
30
+ payload = {
31
+ git_sha: git_sha || git[:sha],
32
+ git_message: git_message || git[:message],
33
+ git_author: git_author || git[:author],
34
+ deployer: deployer,
35
+ environment: configuration.environment,
36
+ deployed_at: Time.current.iso8601
37
+ }
38
+
39
+ Reporter.new(configuration).report_deploy(payload)
40
+ end
41
+ end
42
+ end
43
+ end
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lantern-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Eric Mumbower
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ executables: []
27
+ extensions: []
28
+ extra_rdoc_files: []
29
+ files:
30
+ - LICENSE
31
+ - README.md
32
+ - lib/lantern-rails.rb
33
+ - lib/lantern/rails/collector.rb
34
+ - lib/lantern/rails/configuration.rb
35
+ - lib/lantern/rails/git_metadata.rb
36
+ - lib/lantern/rails/railtie.rb
37
+ - lib/lantern/rails/reporter.rb
38
+ - lib/lantern/rails/runner.rb
39
+ - lib/lantern/rails/version.rb
40
+ homepage: https://uselantern.dev
41
+ licenses:
42
+ - MIT
43
+ metadata: {}
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '3.1'
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubygems_version: 3.6.9
59
+ specification_version: 4
60
+ summary: Postgres monitoring collector for Rails apps — sends health metrics to Lantern.
61
+ test_files: []