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 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: []