lantern-rails 0.1.0 → 0.2.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 +4 -4
- data/README.md +33 -1
- data/lib/lantern/rails/query_aggregator.rb +71 -0
- data/lib/lantern/rails/query_fingerprinter.rb +29 -0
- data/lib/lantern/rails/railtie.rb +9 -0
- data/lib/lantern/rails/request_tracker.rb +73 -0
- data/lib/lantern/rails/runner.rb +4 -0
- data/lib/lantern/rails/version.rb +1 -1
- data/lib/lantern-rails.rb +7 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93b5aad3eb2484e765586589b5cb3e7cbd931d25fb20d1e249e21b521b336d91
|
|
4
|
+
data.tar.gz: f8739f45b42a61c0ece6db30ae517dba6c30fe9ed4382ef315342a00c4cecfcb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 89caa7f682946b0143c7bc2f2917c4070549bb0d178ea87c3574a0a6324d3289aa8feb100dcadf42ee300443876cec5d8c2101ab0ec291383f461d56b7a6579f
|
|
7
|
+
data.tar.gz: 2e1536b62d7292e113279e807bc61d59a166052a37cb9b8b8d834c6a733f0a24f0f3393db28e21598766e60ab4b2fbebfe2fb578c77fc91185114d31761595a2
|
data/README.md
CHANGED
|
@@ -18,12 +18,44 @@ bundle install
|
|
|
18
18
|
|
|
19
19
|
## Setup
|
|
20
20
|
|
|
21
|
-
Generate an API key at [uselantern.dev](https://uselantern.dev)
|
|
21
|
+
1. Generate an API key at [uselantern.dev](https://uselantern.dev)
|
|
22
|
+
2. Store the key using one of the options below
|
|
23
|
+
3. Create an initializer
|
|
24
|
+
|
|
25
|
+
### Store your API key
|
|
26
|
+
|
|
27
|
+
**Option A: Environment variable**
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
LANTERN_API_KEY=lnt_your_key_here
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Option B: Rails credentials**
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
bin/rails credentials:edit
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Flat:
|
|
40
|
+
```yaml
|
|
41
|
+
lantern_api_key: lnt_your_key_here
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or nested:
|
|
45
|
+
```yaml
|
|
46
|
+
lantern:
|
|
47
|
+
api_key: lnt_your_key_here
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Create the initializer
|
|
22
51
|
|
|
23
52
|
```ruby
|
|
24
53
|
# config/initializers/lantern.rb
|
|
25
54
|
Lantern::Rails.configure do |config|
|
|
55
|
+
# Match how you stored the key above:
|
|
26
56
|
config.api_key = ENV["LANTERN_API_KEY"]
|
|
57
|
+
# Or: config.api_key = Rails.application.credentials.lantern_api_key
|
|
58
|
+
# Or: config.api_key = Rails.application.credentials.dig(:lantern, :api_key)
|
|
27
59
|
end
|
|
28
60
|
```
|
|
29
61
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "monitor"
|
|
2
|
+
|
|
3
|
+
module Lantern
|
|
4
|
+
module Rails
|
|
5
|
+
class QueryAggregator
|
|
6
|
+
include MonitorMixin
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
super() # MonitorMixin requires this
|
|
10
|
+
@requests = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Called at the end of each request with the queries captured during it.
|
|
14
|
+
# request_data is a Hash:
|
|
15
|
+
# { controller_action: "orders#index", queries: { fingerprint => { count: N, sample_sql: "..." } } }
|
|
16
|
+
def record_request(request_data)
|
|
17
|
+
synchronize { @requests << request_data }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Drain the buffer and return aggregated N+1 candidates.
|
|
21
|
+
# Returns an Array of Hashes ready to send as query_patterns in the snapshot payload.
|
|
22
|
+
def drain
|
|
23
|
+
captured = synchronize do
|
|
24
|
+
data = @requests
|
|
25
|
+
@requests = []
|
|
26
|
+
data
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
aggregate(captured)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def aggregate(requests)
|
|
35
|
+
return [] if requests.empty?
|
|
36
|
+
|
|
37
|
+
# Group by (controller_action, fingerprint)
|
|
38
|
+
grouped = Hash.new { |h, k| h[k] = { counts: [], sample_sql: nil } }
|
|
39
|
+
|
|
40
|
+
requests.each do |req|
|
|
41
|
+
action = req[:controller_action]
|
|
42
|
+
next if action.nil? || action.empty?
|
|
43
|
+
|
|
44
|
+
req[:queries]&.each do |fingerprint, data|
|
|
45
|
+
key = "#{action}||#{fingerprint}"
|
|
46
|
+
grouped[key][:counts] << data[:count]
|
|
47
|
+
grouped[key][:sample_sql] ||= data[:sample_sql]
|
|
48
|
+
grouped[key][:controller_action] = action
|
|
49
|
+
grouped[key][:fingerprint] = fingerprint
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Filter to N+1 candidates (avg > 2 calls per request) and build output
|
|
54
|
+
grouped.filter_map do |_key, data|
|
|
55
|
+
counts = data[:counts]
|
|
56
|
+
avg = counts.sum.to_f / counts.size
|
|
57
|
+
next if avg <= 2.0
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
query_fingerprint: data[:fingerprint],
|
|
61
|
+
controller_action: data[:controller_action],
|
|
62
|
+
calls_per_request_avg: avg.round(1),
|
|
63
|
+
calls_per_request_max: counts.max,
|
|
64
|
+
requests_sampled: counts.size,
|
|
65
|
+
sample_sql: data[:sample_sql]&.then { |s| s.length > 2000 ? s[0, 2000] : s }
|
|
66
|
+
}
|
|
67
|
+
end.sort_by { |qp| -qp[:calls_per_request_avg] }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Lantern
|
|
2
|
+
module Rails
|
|
3
|
+
class QueryFingerprinter
|
|
4
|
+
# Normalize SQL into a fingerprint by replacing literal values with placeholders.
|
|
5
|
+
# "SELECT * FROM users WHERE id = 42 AND name = 'Eric'" becomes
|
|
6
|
+
# "SELECT * FROM users WHERE id = $1 AND name = $1"
|
|
7
|
+
def self.fingerprint(sql)
|
|
8
|
+
return "" if sql.nil?
|
|
9
|
+
|
|
10
|
+
normalized = sql.dup
|
|
11
|
+
|
|
12
|
+
# Replace quoted strings (single-quoted, with escaped quotes handled)
|
|
13
|
+
normalized.gsub!(/'(?:[^'\\]|\\.)*'/, "$1")
|
|
14
|
+
|
|
15
|
+
# Replace numeric literals (integers and floats, not part of identifiers)
|
|
16
|
+
normalized.gsub!(/\b\d+(?:\.\d+)?\b/, "$1")
|
|
17
|
+
|
|
18
|
+
# Replace IN lists: IN ($1, $1, $1) -> IN ($1)
|
|
19
|
+
normalized.gsub!(/IN\s*\(\s*(\$1(?:\s*,\s*\$1)*)\s*\)/i, "IN ($1)")
|
|
20
|
+
|
|
21
|
+
# Collapse whitespace
|
|
22
|
+
normalized.gsub!(/\s+/, " ")
|
|
23
|
+
normalized.strip!
|
|
24
|
+
|
|
25
|
+
normalized
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
module Lantern
|
|
2
2
|
module Rails
|
|
3
3
|
class Railtie < ::Rails::Railtie
|
|
4
|
+
initializer "lantern.request_tracker" do |app|
|
|
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
|
+
app.middleware.use Lantern::Rails::RequestTracker, Lantern::Rails.query_aggregator
|
|
11
|
+
end
|
|
12
|
+
|
|
4
13
|
config.after_initialize do
|
|
5
14
|
config = Lantern::Rails.configuration
|
|
6
15
|
next unless config.valid?
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Lantern
|
|
2
|
+
module Rails
|
|
3
|
+
class RequestTracker
|
|
4
|
+
IGNORED_SQL = /\A\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|SET|SHOW|PRAGMA)/i
|
|
5
|
+
SCHEMA_SQL = /\A\s*(CREATE|ALTER|DROP|INSERT INTO "schema_migrations")/i
|
|
6
|
+
|
|
7
|
+
def initialize(app, aggregator)
|
|
8
|
+
@app = app
|
|
9
|
+
@aggregator = aggregator
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
# Only track actual controller requests, skip assets/healthchecks
|
|
14
|
+
return @app.call(env) unless trackable_request?(env)
|
|
15
|
+
|
|
16
|
+
request_queries = {}
|
|
17
|
+
|
|
18
|
+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
19
|
+
sql = payload[:sql]
|
|
20
|
+
next if sql.blank?
|
|
21
|
+
next if payload[:name] == "SCHEMA" || payload[:cached]
|
|
22
|
+
next if IGNORED_SQL.match?(sql)
|
|
23
|
+
next if SCHEMA_SQL.match?(sql)
|
|
24
|
+
|
|
25
|
+
fingerprint = QueryFingerprinter.fingerprint(sql)
|
|
26
|
+
next if fingerprint.blank?
|
|
27
|
+
|
|
28
|
+
entry = (request_queries[fingerprint] ||= { count: 0, sample_sql: nil })
|
|
29
|
+
entry[:count] += 1
|
|
30
|
+
entry[:sample_sql] ||= sql.truncate(2000)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
status, headers, response = @app.call(env)
|
|
34
|
+
|
|
35
|
+
[status, headers, response]
|
|
36
|
+
ensure
|
|
37
|
+
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
|
|
38
|
+
|
|
39
|
+
if request_queries&.any?
|
|
40
|
+
controller_action = extract_controller_action(env)
|
|
41
|
+
if controller_action
|
|
42
|
+
@aggregator.record_request(
|
|
43
|
+
controller_action: controller_action,
|
|
44
|
+
queries: request_queries
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def trackable_request?(env)
|
|
53
|
+
# Skip asset pipeline, action cable, health checks
|
|
54
|
+
path = env["PATH_INFO"].to_s
|
|
55
|
+
return false if path.start_with?("/assets", "/cable", "/up")
|
|
56
|
+
return false if path.match?(/\.\w+\z/) # static files
|
|
57
|
+
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_controller_action(env)
|
|
62
|
+
params = env["action_dispatch.request.parameters"]
|
|
63
|
+
return nil unless params
|
|
64
|
+
|
|
65
|
+
controller = params["controller"]
|
|
66
|
+
action = params["action"]
|
|
67
|
+
return nil unless controller && action
|
|
68
|
+
|
|
69
|
+
"#{controller}##{action}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/lantern/rails/runner.rb
CHANGED
|
@@ -35,6 +35,10 @@ module Lantern
|
|
|
35
35
|
payload = Collector.new.collect
|
|
36
36
|
return unless payload
|
|
37
37
|
|
|
38
|
+
# Drain accumulated N+1 query data from the request tracker
|
|
39
|
+
query_patterns = Lantern::Rails.query_aggregator.drain
|
|
40
|
+
payload[:query_patterns] = query_patterns if query_patterns.any?
|
|
41
|
+
|
|
38
42
|
Reporter.new(@config).report_snapshot(payload)
|
|
39
43
|
rescue => e
|
|
40
44
|
::Rails.logger.error("[Lantern] Runner error: #{e.message}")
|
data/lib/lantern-rails.rb
CHANGED
|
@@ -2,6 +2,9 @@ require "lantern/rails/version"
|
|
|
2
2
|
require "lantern/rails/configuration"
|
|
3
3
|
require "lantern/rails/git_metadata"
|
|
4
4
|
require "lantern/rails/collector"
|
|
5
|
+
require "lantern/rails/query_fingerprinter"
|
|
6
|
+
require "lantern/rails/query_aggregator"
|
|
7
|
+
require "lantern/rails/request_tracker"
|
|
5
8
|
require "lantern/rails/reporter"
|
|
6
9
|
require "lantern/rails/runner"
|
|
7
10
|
require "lantern/rails/railtie" if defined?(::Rails)
|
|
@@ -17,6 +20,10 @@ module Lantern
|
|
|
17
20
|
yield configuration
|
|
18
21
|
end
|
|
19
22
|
|
|
23
|
+
def query_aggregator
|
|
24
|
+
@query_aggregator ||= QueryAggregator.new
|
|
25
|
+
end
|
|
26
|
+
|
|
20
27
|
# Manually report a deploy event. Call this from your CI/CD pipeline
|
|
21
28
|
# or a deploy hook in your Rails app.
|
|
22
29
|
#
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lantern-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Mumbower
|
|
@@ -33,14 +33,18 @@ files:
|
|
|
33
33
|
- lib/lantern/rails/collector.rb
|
|
34
34
|
- lib/lantern/rails/configuration.rb
|
|
35
35
|
- lib/lantern/rails/git_metadata.rb
|
|
36
|
+
- lib/lantern/rails/query_aggregator.rb
|
|
37
|
+
- lib/lantern/rails/query_fingerprinter.rb
|
|
36
38
|
- lib/lantern/rails/railtie.rb
|
|
37
39
|
- lib/lantern/rails/reporter.rb
|
|
40
|
+
- lib/lantern/rails/request_tracker.rb
|
|
38
41
|
- lib/lantern/rails/runner.rb
|
|
39
42
|
- lib/lantern/rails/version.rb
|
|
40
43
|
homepage: https://uselantern.dev
|
|
41
44
|
licenses:
|
|
42
45
|
- MIT
|
|
43
|
-
metadata:
|
|
46
|
+
metadata:
|
|
47
|
+
source_code_uri: https://github.com/emum/lantern-rails
|
|
44
48
|
rdoc_options: []
|
|
45
49
|
require_paths:
|
|
46
50
|
- lib
|