render-guardian 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/README.md +40 -0
- data/lib/render_guardian/budget.rb +23 -0
- data/lib/render_guardian/helper_tracker.rb +44 -0
- data/lib/render_guardian/n_plus_one_detector.rb +47 -0
- data/lib/render_guardian/profiler.rb +67 -0
- data/lib/render_guardian/report.rb +63 -0
- data/lib/render_guardian.rb +22 -0
- metadata +51 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a2c5c9770d5548095d968a3e1b4973a20812c0ef9ba48377b0be83ff74403b0b
|
|
4
|
+
data.tar.gz: fdf4c0dd27fe96857553d6e281726735b7b32ba219a30e9d63e71be189a8b43b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 01c27d30f1a8813570bbf786277c42820f42ef886c3abdf2e0b09a8108d3b4317116c1d4c18970ed772635b8e6f59927a82e3bf2ffbcc90bd7c96643da54c5b8
|
|
7
|
+
data.tar.gz: 5ebef982c4571283cfa13299477ada423eaa4ba42c3ae659cd4346286832ab717be336f3c99a310ef66d9330ff23c7e39560f5812c8ed4505a8c8ebaf5051b1c
|
data/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Render Guardian
|
|
2
|
+
|
|
3
|
+
Render conservation guardian — template budgets, partial profiling, N+1 query detection, and helper method cost tracking for Ruby web applications.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install render-guardian
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "render_guardian"
|
|
15
|
+
|
|
16
|
+
# Analyze render events
|
|
17
|
+
report = RenderGuardian.analyze(
|
|
18
|
+
render_log: [
|
|
19
|
+
{ template: "index", partial: "_row", duration_ms: 200,
|
|
20
|
+
db_queries: ["SELECT * FROM items WHERE id=1"],
|
|
21
|
+
helpers_called: [{ name: "format_price", time_ms: 5 }] },
|
|
22
|
+
# ... more events
|
|
23
|
+
]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
puts report
|
|
27
|
+
puts report.summary
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Render Budget** — max time per template/partial
|
|
33
|
+
- **Partial Profiling** — stats by template and partial
|
|
34
|
+
- **N+1 Detection** — repeated queries and high query-per-partial ratios
|
|
35
|
+
- **Helper Cost Tracking** — find slow helper methods
|
|
36
|
+
- **Conservation Reports** — human and machine-readable
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RenderGuardian
|
|
4
|
+
class Budget
|
|
5
|
+
DEFAULTS = {
|
|
6
|
+
max_template_time_ms: 100,
|
|
7
|
+
max_partial_time_ms: 50,
|
|
8
|
+
max_helper_time_ms: 20,
|
|
9
|
+
max_db_queries_per_partial: 3,
|
|
10
|
+
n_plus_one_threshold: 5
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :limits
|
|
14
|
+
|
|
15
|
+
def initialize(limits = {})
|
|
16
|
+
@limits = DEFAULTS.merge(limits.transform_keys(&:to_sym))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def limit_for(key)
|
|
20
|
+
@limits[key.to_sym]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RenderGuardian
|
|
4
|
+
class HelperTracker
|
|
5
|
+
def initialize(profiler)
|
|
6
|
+
@profiler = profiler
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def analyze
|
|
10
|
+
helper_stats = Hash.new { |h, k| h[k] = { count: 0, total_ms: 0, templates: [] } }
|
|
11
|
+
max_ms = @profiler.budget.limit_for(:max_helper_time_ms)
|
|
12
|
+
|
|
13
|
+
# Track helper calls from events that have helper timing data
|
|
14
|
+
@profiler.events.each do |event|
|
|
15
|
+
Array(event.helpers_called).each do |h|
|
|
16
|
+
name = h.is_a?(Hash) ? h[:name].to_s : h.to_s
|
|
17
|
+
time = h.is_a?(Hash) ? h[:time_ms].to_f : 0
|
|
18
|
+
helper_stats[name][:count] += 1
|
|
19
|
+
helper_stats[name][:total_ms] += time
|
|
20
|
+
helper_stats[name][:templates] << event.template unless helper_stats[name][:templates].include?(event.template)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
findings = []
|
|
25
|
+
helper_stats.each do |name, stats|
|
|
26
|
+
avg = stats[:total_ms] / stats[:count]
|
|
27
|
+
if avg > max_ms
|
|
28
|
+
findings << {
|
|
29
|
+
type: :slow_helper,
|
|
30
|
+
severity: :medium,
|
|
31
|
+
helper: name,
|
|
32
|
+
avg_ms: avg.round(2),
|
|
33
|
+
total_ms: stats[:total_ms].round(2),
|
|
34
|
+
count: stats[:count],
|
|
35
|
+
templates: stats[:templates],
|
|
36
|
+
message: "Helper '#{name}' averages #{avg.round(2)}ms (limit: #{max_ms}ms), called #{stats[:count]} times"
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
findings
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RenderGuardian
|
|
4
|
+
class NPlusOneDetector
|
|
5
|
+
def initialize(profiler)
|
|
6
|
+
@profiler = profiler
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def detect
|
|
10
|
+
findings = []
|
|
11
|
+
threshold = @profiler.budget.limit_for(:n_plus_one_threshold)
|
|
12
|
+
|
|
13
|
+
# Check per-partial query counts
|
|
14
|
+
@profiler.stats_by_partial.each do |partial, stats|
|
|
15
|
+
if stats[:total_queries] / stats[:count] > @profiler.budget.limit_for(:max_db_queries_per_partial)
|
|
16
|
+
findings << {
|
|
17
|
+
type: :n_plus_one_suspected,
|
|
18
|
+
severity: :high,
|
|
19
|
+
partial: partial,
|
|
20
|
+
avg_queries: (stats[:total_queries].to_f / stats[:count]).round(2),
|
|
21
|
+
threshold: @profiler.budget.limit_for(:max_db_queries_per_partial),
|
|
22
|
+
render_count: stats[:count],
|
|
23
|
+
message: "Partial '#{partial}' averages #{(stats[:total_queries].to_f / stats[:count]).round(2)} DB queries per render (threshold: #{@profiler.budget.limit_for(:max_db_queries_per_partial)})"
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Detect repeated identical queries across events (classic N+1 pattern)
|
|
29
|
+
query_counts = Hash.new(0)
|
|
30
|
+
@profiler.events.each do |event|
|
|
31
|
+
event.db_queries.each { |q| query_counts[q] += 1 }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
query_counts.select { |_, count| count >= threshold }.each do |query, count|
|
|
35
|
+
findings << {
|
|
36
|
+
type: :repeated_query,
|
|
37
|
+
severity: :high,
|
|
38
|
+
query: query,
|
|
39
|
+
count: count,
|
|
40
|
+
message: "Query executed #{count} times across renders — possible N+1: '#{query[0..80]}'"
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
findings
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RenderGuardian
|
|
4
|
+
RenderEvent = Struct.new(
|
|
5
|
+
:template, :partial, :duration_ms, :db_queries, :helpers_called, :timestamp,
|
|
6
|
+
keyword_init: true
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
class Profiler
|
|
10
|
+
attr_reader :budget, :events
|
|
11
|
+
|
|
12
|
+
def initialize(budget)
|
|
13
|
+
@budget = budget
|
|
14
|
+
@events = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def ingest(events)
|
|
18
|
+
Array(events).each do |e|
|
|
19
|
+
@events << RenderEvent.new(
|
|
20
|
+
template: e[:template].to_s,
|
|
21
|
+
partial: e[:partial].to_s,
|
|
22
|
+
duration_ms: e[:duration_ms].to_f,
|
|
23
|
+
db_queries: Array(e[:db_queries]),
|
|
24
|
+
helpers_called: Array(e[:helpers_called]),
|
|
25
|
+
timestamp: e[:timestamp] || Time.now
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stats_by_template
|
|
31
|
+
@events.group_by(&:template).transform_values do |evts|
|
|
32
|
+
durations = evts.map(&:duration_ms)
|
|
33
|
+
{
|
|
34
|
+
count: durations.size,
|
|
35
|
+
total_ms: durations.sum,
|
|
36
|
+
avg_ms: durations.sum / durations.size,
|
|
37
|
+
max_ms: durations.max,
|
|
38
|
+
min_ms: durations.min,
|
|
39
|
+
total_queries: evts.sum { |e| e.db_queries.size }
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stats_by_partial
|
|
45
|
+
@events.select { |e| !e.partial.empty? }.group_by(&:partial).transform_values do |evts|
|
|
46
|
+
durations = evts.map(&:duration_ms)
|
|
47
|
+
{
|
|
48
|
+
count: durations.size,
|
|
49
|
+
total_ms: durations.sum,
|
|
50
|
+
avg_ms: durations.sum / durations.size,
|
|
51
|
+
max_ms: durations.max,
|
|
52
|
+
total_queries: evts.sum { |e| e.db_queries.size }
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def slow_templates
|
|
58
|
+
max_ms = @budget.limit_for(:max_template_time_ms)
|
|
59
|
+
@events.select { |e| e.template && e.duration_ms > max_ms }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def slow_partials
|
|
63
|
+
max_ms = @budget.limit_for(:max_partial_time_ms)
|
|
64
|
+
@events.select { |e| !e.partial.empty? && e.duration_ms > max_ms }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RenderGuardian
|
|
4
|
+
class Report
|
|
5
|
+
attr_reader :budget, :profiler, :n_plus_one_findings, :helper_findings
|
|
6
|
+
|
|
7
|
+
def initialize(budget, profiler, n_plus_one_findings, helper_findings)
|
|
8
|
+
@budget = budget
|
|
9
|
+
@profiler = profiler
|
|
10
|
+
@n_plus_one_findings = n_plus_one_findings
|
|
11
|
+
@helper_findings = helper_findings
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def summary
|
|
15
|
+
{
|
|
16
|
+
total_renders: @profiler.events.size,
|
|
17
|
+
templates: @profiler.stats_by_template.size,
|
|
18
|
+
slow_templates: @profiler.slow_templates.size,
|
|
19
|
+
slow_partials: @profiler.slow_partials.size,
|
|
20
|
+
n_plus_one_issues: @n_plus_one_findings.size,
|
|
21
|
+
slow_helpers: @helper_findings.size
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
summary: summary,
|
|
28
|
+
template_stats: @profiler.stats_by_template,
|
|
29
|
+
partial_stats: @profiler.stats_by_partial,
|
|
30
|
+
n_plus_one: @n_plus_one_findings,
|
|
31
|
+
helpers: @helper_findings
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s
|
|
36
|
+
lines = []
|
|
37
|
+
lines << "=== Render Guardian Report ==="
|
|
38
|
+
s = summary
|
|
39
|
+
lines << "Renders: #{s[:total_renders]} | Templates: #{s[:templates]}"
|
|
40
|
+
lines << "Slow templates: #{s[:slow_templates]} | Slow partials: #{s[:slow_partials]}"
|
|
41
|
+
lines << "N+1 issues: #{s[:n_plus_one_issues]} | Slow helpers: #{s[:slow_helpers]}"
|
|
42
|
+
lines << ""
|
|
43
|
+
|
|
44
|
+
if @n_plus_one_findings.any?
|
|
45
|
+
lines << "--- N+1 Issues ---"
|
|
46
|
+
@n_plus_one_findings.each { |f| lines << " [#{f[:severity].upcase}] #{f[:message]}" }
|
|
47
|
+
lines << ""
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if @helper_findings.any?
|
|
51
|
+
lines << "--- Slow Helpers ---"
|
|
52
|
+
@helper_findings.each { |f| lines << " [#{f[:severity].upcase}] #{f[:message]}" }
|
|
53
|
+
lines << ""
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if s[:slow_templates].zero? && s[:n_plus_one_issues].zero? && s[:slow_helpers].zero?
|
|
57
|
+
lines << "✅ All renders within budget!"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
lines.join("\n")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "render_guardian/budget"
|
|
4
|
+
require_relative "render_guardian/profiler"
|
|
5
|
+
require_relative "render_guardian/n_plus_one_detector"
|
|
6
|
+
require_relative "render_guardian/helper_tracker"
|
|
7
|
+
require_relative "render_guardian/report"
|
|
8
|
+
|
|
9
|
+
module RenderGuardian
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
def self.analyze(render_log:, budget: nil)
|
|
13
|
+
budget ||= Budget.new
|
|
14
|
+
profiler = Profiler.new(budget)
|
|
15
|
+
profiler.ingest(render_log)
|
|
16
|
+
|
|
17
|
+
n_plus_one = NPlusOneDetector.new(profiler).detect
|
|
18
|
+
helpers = HelperTracker.new(profiler).analyze
|
|
19
|
+
|
|
20
|
+
Report.new(budget, profiler, n_plus_one, helpers)
|
|
21
|
+
end
|
|
22
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: render-guardian
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- SuperInstance
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Track render budgets, profile partial performance, detect N+1 queries
|
|
14
|
+
in views, and measure helper method costs for Ruby web applications.
|
|
15
|
+
email:
|
|
16
|
+
- team@superinstance.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/render_guardian.rb
|
|
23
|
+
- lib/render_guardian/budget.rb
|
|
24
|
+
- lib/render_guardian/helper_tracker.rb
|
|
25
|
+
- lib/render_guardian/n_plus_one_detector.rb
|
|
26
|
+
- lib/render_guardian/profiler.rb
|
|
27
|
+
- lib/render_guardian/report.rb
|
|
28
|
+
homepage: https://github.com/SuperInstance/gem-render-guardian
|
|
29
|
+
licenses:
|
|
30
|
+
- MIT
|
|
31
|
+
metadata: {}
|
|
32
|
+
post_install_message:
|
|
33
|
+
rdoc_options: []
|
|
34
|
+
require_paths:
|
|
35
|
+
- lib
|
|
36
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.7'
|
|
41
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0'
|
|
46
|
+
requirements: []
|
|
47
|
+
rubygems_version: 3.3.5
|
|
48
|
+
signing_key:
|
|
49
|
+
specification_version: 4
|
|
50
|
+
summary: Render conservation guardian — template budgets, partial profiling, N+1 detection
|
|
51
|
+
test_files: []
|