dead_bro 0.2.17 → 0.2.18

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: 0e9e839180ccf5281f99f831a4d437bdadd74a835c7e8f80fe321efb8bb1c9f7
4
- data.tar.gz: c85de05b244a13bac3a2786624bd67f2f18d3b08c3ab3bcaa511857f8c276b90
3
+ metadata.gz: aa43ba99a6fb0fd4007b856a24ac5844236f2d0102f9ec71175c81981a91bb17
4
+ data.tar.gz: c91f7fee25858bafd2edabfb162239ea8d8833405a3b4965f55e403a06dbfd05
5
5
  SHA512:
6
- metadata.gz: 87c69d57f3c712fc993663e4f8df46ad49ec584f0ccdd3f5f1a6c5eededbc8a9542f2b0d4cee446fb049288c5d7f086528fa91f1467aa778dea4cfd4b09302e4
7
- data.tar.gz: 1aed3c8185b4b1f14b7707c97c9d38baa5cb19da9c268d7f6ae11024563e4f9452048a61fdbe616a6adadb4847559f1459a0c5b695306a31a0d579a66e2b5825
6
+ metadata.gz: b436b94d8d36d0b3407b09481326e443aa342c4398d4973fb517016bd3ee1f4a159a163d12a76b2294f2e4de36505b0cb568010bd8e0aecbbd0e3b4dc544069e
7
+ data.tar.gz: 7981b5ea511dad34ce67c0a71afe8a09f12045a0ae778311fa42ae0426f3a689d38377528939c6f757192bd973c3d3c782f9f2124831cfac5505d4bf1ab2d177
@@ -14,8 +14,18 @@ module DeadBro
14
14
  THREAD_LOCAL_ALLOC_RESULTS_KEY = :dead_bro_sql_alloc_results
15
15
  THREAD_LOCAL_BACKTRACE_KEY = :dead_bro_sql_backtraces
16
16
  THREAD_LOCAL_EXPLAIN_PENDING_KEY = :dead_bro_explain_pending
17
+ THREAD_LOCAL_CALL_COUNTS_KEY = :dead_bro_sql_call_counts
18
+ THREAD_LOCAL_AGGREGATES_KEY = :dead_bro_sql_aggregates
17
19
  MAX_TRACKED_QUERIES = 1000
18
20
 
21
+ # Number of identical queries within one request that triggers N+1 detection.
22
+ N_PLUS_ONE_THRESHOLD = 5
23
+
24
+ NORMALIZE_PARAM_RE = /\$\d+/.freeze
25
+ NORMALIZE_NUMERIC_RE = /\b\d+(\.\d+)?\b/.freeze
26
+ NORMALIZE_IN_RE = /\bIN\s*\([^)]+\)/i.freeze
27
+ NORMALIZE_SPACE_RE = /\s+/.freeze
28
+
19
29
  # Precompiled regexes used by sanitize_sql. Dynamic /.../i literals inside
20
30
  # a hot-path method allocate a fresh Regexp on every call — pinning them
21
31
  # here removes that allocation entirely.
@@ -57,6 +67,20 @@ module DeadBro
57
67
  true
58
68
  end
59
69
 
70
+ def self.normalize_for_n_plus_one(sql)
71
+ return "" unless sql.is_a?(String)
72
+ s = sql.dup
73
+ s.gsub!(NORMALIZE_PARAM_RE, "?")
74
+ s.gsub!(NORMALIZE_NUMERIC_RE, "?")
75
+ s.gsub!(NORMALIZE_IN_RE, "IN (?)")
76
+ s.gsub!(NORMALIZE_SPACE_RE, " ")
77
+ s.strip!
78
+ s.downcase!
79
+ s
80
+ rescue
81
+ sql.to_s.downcase
82
+ end
83
+
60
84
  def self.subscribe!
61
85
  # Subscribe with a start/finish listener to measure allocations per query
62
86
  if ActiveSupport::Notifications.notifier.respond_to?(:subscribe)
@@ -82,19 +106,31 @@ module DeadBro
82
106
  duration_ms = ((finished - started) * 1000.0).round(2)
83
107
  original_sql = data[:sql]
84
108
 
85
- # Only capture a backtrace for queries we actually care about tracing
86
- # (slow). This skips the ~O(stack-depth) allocation on the 99% of queries
87
- # that are fast. An N+1 of 100 x 1ms queries no longer eats a thousand
88
- # frame allocations for traces nobody will read.
89
109
  threshold = begin
90
110
  DeadBro.configuration.slow_query_threshold_ms
91
111
  rescue
92
112
  500
93
113
  end
114
+ sanitized_sql = sanitize_sql(original_sql)
94
115
  captured_trace = (duration_ms >= threshold.to_f) ? capture_app_backtrace : []
95
116
 
117
+ cc_stack = Thread.current[THREAD_LOCAL_CALL_COUNTS_KEY]
118
+ agg_stack = Thread.current[THREAD_LOCAL_AGGREGATES_KEY]
119
+ call_counts = cc_stack.is_a?(Array) ? cc_stack.last : nil
120
+ aggregates_h = agg_stack.is_a?(Array) ? agg_stack.last : nil
121
+ normalized_key = nil
122
+
123
+ if call_counts && aggregates_h
124
+ normalized_key = normalize_for_n_plus_one(sanitized_sql)
125
+ call_counts[normalized_key] = (call_counts[normalized_key] || 0) + 1
126
+ # Capture backtrace exactly at the N+1 boundary — the stack is still meaningful here.
127
+ if call_counts[normalized_key] == N_PLUS_ONE_THRESHOLD && captured_trace.empty?
128
+ captured_trace = capture_app_backtrace
129
+ end
130
+ end
131
+
96
132
  query_info = {
97
- sql: sanitize_sql(original_sql),
133
+ sql: sanitized_sql,
98
134
  name: data[:name],
99
135
  duration_ms: duration_ms,
100
136
  cached: data[:cached] || false,
@@ -103,16 +139,40 @@ module DeadBro
103
139
  allocations: allocations
104
140
  }
105
141
 
106
- # Run EXPLAIN ANALYZE for slow queries in the background
107
142
  if should_explain_query?(duration_ms, original_sql)
108
- # Store reference to query_info so we can update it when EXPLAIN completes
109
- query_info[:explain_plan] = nil # Placeholder
110
- # Capture binds if available (type_casted_binds is preferred as they are ready for quoting)
143
+ query_info[:explain_plan] = nil
111
144
  binds = data[:type_casted_binds] || data[:binds]
112
145
  start_explain_analyze_background(original_sql, data[:connection_id], query_info, binds)
113
146
  end
114
147
 
115
- # Add to current context (top of stack), but only if we haven't exceeded the limits
148
+ if aggregates_h && normalized_key
149
+ call_count = call_counts[normalized_key]
150
+ if (agg = aggregates_h[normalized_key])
151
+ agg[:count] += 1
152
+ agg[:total_duration_ms] = (agg[:total_duration_ms] + duration_ms).round(2)
153
+ agg[:max_duration_ms] = [agg[:max_duration_ms], duration_ms].max
154
+ agg[:min_duration_ms] = [agg[:min_duration_ms], duration_ms].min
155
+ agg[:total_allocations] += (allocations || 0)
156
+ agg[:cached_count] += 1 if query_info[:cached]
157
+ agg[:n_plus_one] = true if call_count >= N_PLUS_ONE_THRESHOLD
158
+ agg[:backtrace] = captured_trace if agg[:backtrace].empty? && !captured_trace.empty?
159
+ else
160
+ aggregates_h[normalized_key] = {
161
+ sql: sanitized_sql,
162
+ name: data[:name],
163
+ count: 1,
164
+ total_duration_ms: duration_ms,
165
+ max_duration_ms: duration_ms,
166
+ min_duration_ms: duration_ms,
167
+ total_allocations: allocations || 0,
168
+ cached_count: (data[:cached] ? 1 : 0),
169
+ n_plus_one: false,
170
+ backtrace: captured_trace,
171
+ explain_plan: nil
172
+ }
173
+ end
174
+ end
175
+
116
176
  if should_continue_tracking?(current, MAX_TRACKED_QUERIES)
117
177
  current << query_info
118
178
  end
@@ -126,25 +186,40 @@ module DeadBro
126
186
  Thread.current[THREAD_LOCAL_ALLOC_RESULTS_KEY] = {}
127
187
  Thread.current[THREAD_LOCAL_BACKTRACE_KEY] = {}
128
188
  Thread.current[THREAD_LOCAL_EXPLAIN_PENDING_KEY] = []
189
+ (Thread.current[THREAD_LOCAL_CALL_COUNTS_KEY] ||= []) << {}
190
+ (Thread.current[THREAD_LOCAL_AGGREGATES_KEY] ||= []) << {}
129
191
  end
130
192
 
131
193
  def self.stop_request_tracking
132
- # Wait for any pending EXPLAIN ANALYZE queries to complete (with timeout)
133
- # This must happen BEFORE we get the queries array reference to ensure
134
- # all explain_plan fields are populated
135
194
  wait_for_pending_explains(EXPLAIN_WAIT_TIMEOUT_SECONDS)
136
195
 
137
196
  stack = Thread.current[THREAD_LOCAL_KEY]
138
- queries = (stack.is_a?(Array) && stack.any?) ? stack.pop : []
139
- # Clear thread locals when stack is empty so "tracking not started" behaves correctly
197
+ raw_queries = (stack.is_a?(Array) && stack.any?) ? stack.pop : []
198
+
199
+ agg_stack = Thread.current[THREAD_LOCAL_AGGREGATES_KEY]
200
+ aggregates_h = (agg_stack.is_a?(Array) && agg_stack.any?) ? agg_stack.pop : {}
201
+ cc_stack = Thread.current[THREAD_LOCAL_CALL_COUNTS_KEY]
202
+ cc_stack.pop if cc_stack.is_a?(Array) && cc_stack.any?
203
+
204
+ # Fold any completed EXPLAIN plans from raw queries into their aggregate entry
205
+ raw_queries.each do |q|
206
+ next unless q[:explain_plan]
207
+ key = normalize_for_n_plus_one(q[:sql].to_s)
208
+ agg = aggregates_h[key]
209
+ agg[:explain_plan] ||= q[:explain_plan] if agg
210
+ end
211
+
140
212
  if stack.nil? || stack.empty?
141
213
  Thread.current[THREAD_LOCAL_KEY] = nil
142
214
  Thread.current[THREAD_LOCAL_ALLOC_START_KEY] = nil
143
215
  Thread.current[THREAD_LOCAL_ALLOC_RESULTS_KEY] = nil
144
216
  Thread.current[THREAD_LOCAL_BACKTRACE_KEY] = nil
145
217
  Thread.current[THREAD_LOCAL_EXPLAIN_PENDING_KEY] = nil
218
+ Thread.current[THREAD_LOCAL_CALL_COUNTS_KEY] = nil
219
+ Thread.current[THREAD_LOCAL_AGGREGATES_KEY] = nil
146
220
  end
147
- queries
221
+
222
+ aggregates_h.values.sort_by { |a| -a[:total_duration_ms] }
148
223
  end
149
224
 
150
225
  # Upper bound on pending EXPLAIN threads per request — stops a slow-query
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.17"
4
+ VERSION = "0.2.18"
5
5
  end
@@ -4,167 +4,147 @@ require "active_support/notifications"
4
4
 
5
5
  module DeadBro
6
6
  class ViewRenderingSubscriber
7
- # Rails view rendering events
8
- RENDER_TEMPLATE_EVENT = "render_template.action_view"
9
- RENDER_PARTIAL_EVENT = "render_partial.action_view"
7
+ RENDER_TEMPLATE_EVENT = "render_template.action_view"
8
+ RENDER_PARTIAL_EVENT = "render_partial.action_view"
10
9
  RENDER_COLLECTION_EVENT = "render_collection.action_view"
11
10
 
12
- THREAD_LOCAL_KEY = :dead_bro_view_events
13
- MAX_TRACKED_EVENTS = 1000
11
+ THREAD_LOCAL_KEY = :dead_bro_view_events
12
+ THREAD_LOCAL_AGGREGATES_KEY = :dead_bro_view_aggregates
13
+ MAX_TRACKED_EVENTS = 500
14
14
 
15
15
  def self.subscribe!(client: Client.new)
16
- # Track template rendering
17
- ActiveSupport::Notifications.subscribe(RENDER_TEMPLATE_EVENT) do |name, started, finished, _unique_id, data|
18
- duration_ms = ((finished - started) * 1000.0).round(2)
19
-
20
- view_info = {
21
- type: "template",
22
- identifier: safe_identifier(data[:identifier]),
23
- layout: data[:layout],
24
- duration_ms: duration_ms,
25
- virtual_path: data[:virtual_path],
26
- rendered_at: Time.now.utc.to_i
27
- }
28
-
29
- add_view_event(view_info)
16
+ ActiveSupport::Notifications.subscribe(RENDER_TEMPLATE_EVENT) do |_name, started, finished, _uid, data|
17
+ add_view_event(type: "template", identifier: safe_identifier(data[:identifier]),
18
+ duration_ms: ((finished - started) * 1000.0).round(2),
19
+ rendered_at: Time.now.utc.to_i)
30
20
  end
31
21
 
32
- # Track partial rendering
33
- ActiveSupport::Notifications.subscribe(RENDER_PARTIAL_EVENT) do |name, started, finished, _unique_id, data|
34
- duration_ms = ((finished - started) * 1000.0).round(2)
35
-
36
- view_info = {
37
- type: "partial",
38
- identifier: safe_identifier(data[:identifier]),
39
- layout: data[:layout],
40
- duration_ms: duration_ms,
41
- virtual_path: data[:virtual_path],
42
- cache_key: data[:cache_key],
43
- rendered_at: Time.now.utc.to_i
44
- }
45
-
46
- add_view_event(view_info)
22
+ ActiveSupport::Notifications.subscribe(RENDER_PARTIAL_EVENT) do |_name, started, finished, _uid, data|
23
+ add_view_event(type: "partial", identifier: safe_identifier(data[:identifier]),
24
+ duration_ms: ((finished - started) * 1000.0).round(2),
25
+ cache_key: data[:cache_key],
26
+ rendered_at: Time.now.utc.to_i)
47
27
  end
48
28
 
49
- # Track collection rendering (for partials rendered in loops)
50
- ActiveSupport::Notifications.subscribe(RENDER_COLLECTION_EVENT) do |name, started, finished, _unique_id, data|
51
- duration_ms = ((finished - started) * 1000.0).round(2)
52
-
53
- view_info = {
54
- type: "collection",
55
- identifier: safe_identifier(data[:identifier]),
56
- layout: data[:layout],
57
- duration_ms: duration_ms,
58
- virtual_path: data[:virtual_path],
59
- cache_key: data[:cache_key],
60
- count: data[:count],
61
- cached_count: data[:cached_count],
62
- rendered_at: Time.now.utc.to_i
63
- }
64
-
65
- add_view_event(view_info)
29
+ ActiveSupport::Notifications.subscribe(RENDER_COLLECTION_EVENT) do |_name, started, finished, _uid, data|
30
+ add_view_event(type: "collection", identifier: safe_identifier(data[:identifier]),
31
+ duration_ms: ((finished - started) * 1000.0).round(2),
32
+ collection_count: (data[:count] || 0).to_i,
33
+ collection_cached_count: (data[:cached_count] || 0).to_i,
34
+ rendered_at: Time.now.utc.to_i)
66
35
  end
67
36
  rescue
68
- # Never raise from instrumentation install
69
37
  end
70
38
 
71
39
  def self.start_request_tracking
72
- Thread.current[THREAD_LOCAL_KEY] = []
40
+ Thread.current[THREAD_LOCAL_KEY] = true
41
+ Thread.current[THREAD_LOCAL_AGGREGATES_KEY] = {}
73
42
  end
74
43
 
75
44
  def self.stop_request_tracking
76
- events = Thread.current[THREAD_LOCAL_KEY]
77
45
  Thread.current[THREAD_LOCAL_KEY] = nil
78
- events || []
46
+ aggregates = Thread.current[THREAD_LOCAL_AGGREGATES_KEY] || {}
47
+ Thread.current[THREAD_LOCAL_AGGREGATES_KEY] = nil
48
+ aggregates.values.sort_by { |a| [-a[:count], -a[:total_duration_ms]] }
79
49
  end
80
50
 
81
51
  def self.add_view_event(view_info)
82
- if Thread.current[THREAD_LOCAL_KEY] && should_continue_tracking?
83
- Thread.current[THREAD_LOCAL_KEY] << view_info
52
+ return unless Thread.current[THREAD_LOCAL_KEY]
53
+ aggregates = Thread.current[THREAD_LOCAL_AGGREGATES_KEY]
54
+ return unless aggregates
55
+
56
+ key = view_info[:identifier].to_s
57
+ dur = view_info[:duration_ms].to_f
58
+ rendered_at = view_info[:rendered_at]
59
+
60
+ if (agg = aggregates[key])
61
+ agg[:count] += 1
62
+ agg[:total_duration_ms] = (agg[:total_duration_ms] + dur).round(2)
63
+ agg[:max_duration_ms] = [agg[:max_duration_ms], dur].max
64
+ agg[:min_duration_ms] = [agg[:min_duration_ms], dur].min
65
+ agg[:rendered_at_min] = [agg[:rendered_at_min], rendered_at].compact.min if rendered_at
66
+ agg[:rendered_at_max] = [agg[:rendered_at_max], rendered_at].compact.max if rendered_at
67
+ agg[:cache_hit_count] += 1 if view_info[:cache_key]
68
+ agg[:collection_count] += view_info[:collection_count].to_i
69
+ agg[:collection_cached_count] += view_info[:collection_cached_count].to_i
70
+ else
71
+ return if aggregates.size >= MAX_TRACKED_EVENTS
72
+ aggregates[key] = {
73
+ identifier: key,
74
+ type: view_info[:type],
75
+ count: 1,
76
+ total_duration_ms: dur,
77
+ max_duration_ms: dur,
78
+ min_duration_ms: dur,
79
+ rendered_at_min: rendered_at,
80
+ rendered_at_max: rendered_at,
81
+ cache_hit_count: (view_info[:cache_key] ? 1 : 0),
82
+ collection_count: view_info[:collection_count].to_i,
83
+ collection_cached_count: view_info[:collection_cached_count].to_i
84
+ }
84
85
  end
85
86
  end
86
87
 
87
- # Check if we should continue tracking based on count and time limits
88
- def self.should_continue_tracking?
89
- events = Thread.current[THREAD_LOCAL_KEY]
90
- return false unless events
91
-
92
- # Check count limit
93
- return false if events.length >= MAX_TRACKED_EVENTS
94
-
95
- # Check time limit
96
- start_time = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
97
- if start_time
98
- elapsed_seconds = Time.now - start_time
99
- return false if elapsed_seconds >= DeadBro::MAX_TRACKING_DURATION_SECONDS
100
- end
101
-
102
- true
103
- end
104
-
105
88
  def self.safe_identifier(identifier)
106
89
  return "" unless identifier.is_a?(String)
107
-
108
- # Extract meaningful parts of the file path
109
- # e.g., "/app/views/users/show.html.erb" -> "users/show.html.erb"
110
90
  identifier.split("/").last(3).join("/")
111
91
  rescue
112
92
  identifier.to_s
113
93
  end
114
94
 
115
- # Analyze view rendering performance
116
95
  def self.analyze_view_performance(view_events)
117
96
  return {} if view_events.empty?
118
97
 
119
- total_duration = view_events.sum { |event| event[:duration_ms] }
98
+ total_renders = view_events.sum { |e| e_int(e, :count, 1) }
99
+ total_duration = view_events.sum { |e| e_flt(e, :total_duration_ms) }
120
100
 
121
- # Group by view type
122
- by_type = view_events.group_by { |event| event[:type] }
101
+ by_type = Hash.new(0)
102
+ view_events.each { |e| by_type[e_str(e, :type)] += e_int(e, :count, 1) }
123
103
 
124
- # Find slowest views
125
- slowest_views = view_events.sort_by { |event| -event[:duration_ms] }.first(5)
104
+ slowest = view_events.sort_by { |e| -e_flt(e, :max_duration_ms) }.first(5).map do |e|
105
+ { identifier: e_str(e, :identifier), duration_ms: e_flt(e, :max_duration_ms), type: e_str(e, :type) }
106
+ end
126
107
 
127
- # Find most frequently rendered views
128
- view_frequency = view_events.group_by { |event| event[:identifier] }
129
- .transform_values(&:count)
130
- .sort_by { |_, count| -count }
131
- .first(5)
108
+ most_frequent = view_events.sort_by { |e| -e_int(e, :count, 1) }.first(5).map do |e|
109
+ { identifier: e_str(e, :identifier), count: e_int(e, :count, 1) }
110
+ end
132
111
 
133
- # Calculate cache hit rates for partials
134
- partials = view_events.select { |event| event[:type] == "partial" }
135
- cache_hits = partials.count { |event| event[:cache_key] }
136
- cache_hit_rate = partials.any? ? (cache_hits.to_f / partials.count * 100).round(2) : 0
112
+ partials = view_events.select { |e| e_str(e, :type) == "partial" }
113
+ total_partial_renders = partials.sum { |e| e_int(e, :count, 1) }
114
+ total_cache_hits = partials.sum { |e| e_int(e, :cache_hit_count) }
115
+ partial_cache_hit_rate = total_partial_renders > 0 ? (total_cache_hits.to_f / total_partial_renders * 100).round(2) : 0
137
116
 
138
- # Collection rendering analysis
139
- collections = view_events.select { |event| event[:type] == "collection" }
140
- total_collection_items = collections.sum { |event| event[:count] || 0 }
141
- total_cached_items = collections.sum { |event| event[:cached_count] || 0 }
142
- collection_cache_hit_rate = (total_collection_items > 0) ?
143
- (total_cached_items.to_f / total_collection_items * 100).round(2) : 0
117
+ collections = view_events.select { |e| e_str(e, :type) == "collection" }
118
+ total_collection_items = collections.sum { |e| e_int(e, :collection_count) }
119
+ total_cached_items = collections.sum { |e| e_int(e, :collection_cached_count) }
120
+ collection_cache_hit_rate = total_collection_items > 0 ? (total_cached_items.to_f / total_collection_items * 100).round(2) : 0
144
121
 
145
122
  {
146
- total_views_rendered: view_events.count,
147
- total_view_duration_ms: total_duration.round(2),
148
- average_view_duration_ms: (total_duration / view_events.count).round(2),
149
- by_type: by_type.transform_values(&:count),
150
- slowest_views: slowest_views.map { |view|
151
- {
152
- identifier: view[:identifier],
153
- duration_ms: view[:duration_ms],
154
- type: view[:type]
155
- }
156
- },
157
- most_frequent_views: view_frequency.map { |identifier, count|
158
- {
159
- identifier: identifier,
160
- count: count
161
- }
162
- },
163
- partial_cache_hit_rate: cache_hit_rate,
164
- collection_cache_hit_rate: collection_cache_hit_rate,
165
- total_collection_items: total_collection_items,
166
- total_cached_collection_items: total_cached_items
123
+ total_views_rendered: total_renders,
124
+ total_view_duration_ms: total_duration.round(2),
125
+ average_view_duration_ms: total_renders > 0 ? (total_duration / total_renders).round(2) : 0,
126
+ by_type: by_type,
127
+ slowest_views: slowest,
128
+ most_frequent_views: most_frequent,
129
+ partial_cache_hit_rate: partial_cache_hit_rate,
130
+ collection_cache_hit_rate: collection_cache_hit_rate,
131
+ total_collection_items: total_collection_items,
132
+ total_cached_collection_items: total_cached_items
167
133
  }
168
134
  end
135
+
136
+ private
137
+
138
+ def self.e_int(e, key, default = 0)
139
+ (e[key] || e[key.to_s] || default).to_i
140
+ end
141
+
142
+ def self.e_flt(e, key, default = 0.0)
143
+ (e[key] || e[key.to_s] || default).to_f
144
+ end
145
+
146
+ def self.e_str(e, key, default = "")
147
+ (e[key] || e[key.to_s] || default).to_s
148
+ end
169
149
  end
170
150
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dead_bro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.17
4
+ version: 0.2.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa