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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b544123b390d3e7fee816bd3f27fdd485334fb5688f45754a52551294d39ef98
4
- data.tar.gz: 74b280feb07f0d4bdac527c56d206c47b0fd009d293348c7924df76d46c900f8
3
+ metadata.gz: 93b5aad3eb2484e765586589b5cb3e7cbd931d25fb20d1e249e21b521b336d91
4
+ data.tar.gz: f8739f45b42a61c0ece6db30ae517dba6c30fe9ed4382ef315342a00c4cecfcb
5
5
  SHA512:
6
- metadata.gz: a14c62f42e4b69f19ae193061a17d981f005e110c691f1f6b13200020cbb7bb1e50182d2c471814244a06b68d69bef54bab6f51230e5291226f3c3653f8bf788
7
- data.tar.gz: d0f5344237d2684d8a8891a2ce9941669a26d455a3a05fdf01b6102c9c26322410f505db446c76b8043400e39b0b86491af165e5341d0a0b9366066ec92ffb48
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), then create an initializer:
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
@@ -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}")
@@ -1,5 +1,5 @@
1
1
  module Lantern
2
2
  module Rails
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
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.1.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