rails-profiler 0.1.4

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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/builds/profiler-toolbar.js +1191 -0
  3. data/app/assets/builds/profiler.css +2668 -0
  4. data/app/assets/builds/profiler.js +2772 -0
  5. data/app/controllers/profiler/api/ajax_controller.rb +36 -0
  6. data/app/controllers/profiler/api/jobs_controller.rb +39 -0
  7. data/app/controllers/profiler/api/outbound_http_controller.rb +36 -0
  8. data/app/controllers/profiler/api/profiles_controller.rb +60 -0
  9. data/app/controllers/profiler/api/toolbar_controller.rb +44 -0
  10. data/app/controllers/profiler/application_controller.rb +19 -0
  11. data/app/controllers/profiler/assets_controller.rb +29 -0
  12. data/app/controllers/profiler/profiles_controller.rb +107 -0
  13. data/app/views/layouts/profiler/application.html.erb +16 -0
  14. data/app/views/layouts/profiler/embedded.html.erb +34 -0
  15. data/app/views/profiler/profiles/index.html.erb +1 -0
  16. data/app/views/profiler/profiles/show.html.erb +4 -0
  17. data/config/routes.rb +36 -0
  18. data/exe/profiler-mcp +8 -0
  19. data/lib/profiler/collectors/ajax_collector.rb +109 -0
  20. data/lib/profiler/collectors/base_collector.rb +92 -0
  21. data/lib/profiler/collectors/cache_collector.rb +96 -0
  22. data/lib/profiler/collectors/database_collector.rb +113 -0
  23. data/lib/profiler/collectors/dump_collector.rb +98 -0
  24. data/lib/profiler/collectors/flamegraph_collector.rb +182 -0
  25. data/lib/profiler/collectors/http_collector.rb +112 -0
  26. data/lib/profiler/collectors/job_collector.rb +50 -0
  27. data/lib/profiler/collectors/performance_collector.rb +103 -0
  28. data/lib/profiler/collectors/request_collector.rb +80 -0
  29. data/lib/profiler/collectors/view_collector.rb +79 -0
  30. data/lib/profiler/configuration.rb +81 -0
  31. data/lib/profiler/engine.rb +17 -0
  32. data/lib/profiler/instrumentation/active_job_instrumentation.rb +22 -0
  33. data/lib/profiler/instrumentation/net_http_instrumentation.rb +153 -0
  34. data/lib/profiler/instrumentation/sidekiq_middleware.rb +18 -0
  35. data/lib/profiler/job_profiler.rb +118 -0
  36. data/lib/profiler/mcp/resources/n1_patterns.rb +62 -0
  37. data/lib/profiler/mcp/resources/recent_jobs.rb +39 -0
  38. data/lib/profiler/mcp/resources/recent_requests.rb +35 -0
  39. data/lib/profiler/mcp/resources/slow_queries.rb +47 -0
  40. data/lib/profiler/mcp/server.rb +217 -0
  41. data/lib/profiler/mcp/tools/analyze_queries.rb +124 -0
  42. data/lib/profiler/mcp/tools/clear_profiles.rb +22 -0
  43. data/lib/profiler/mcp/tools/get_profile_ajax.rb +66 -0
  44. data/lib/profiler/mcp/tools/get_profile_detail.rb +326 -0
  45. data/lib/profiler/mcp/tools/get_profile_dumps.rb +51 -0
  46. data/lib/profiler/mcp/tools/get_profile_http.rb +104 -0
  47. data/lib/profiler/mcp/tools/query_jobs.rb +60 -0
  48. data/lib/profiler/mcp/tools/query_profiles.rb +66 -0
  49. data/lib/profiler/middleware/cors_middleware.rb +55 -0
  50. data/lib/profiler/middleware/profiler_middleware.rb +151 -0
  51. data/lib/profiler/middleware/toolbar_injector.rb +378 -0
  52. data/lib/profiler/models/profile.rb +182 -0
  53. data/lib/profiler/models/sql_query.rb +48 -0
  54. data/lib/profiler/models/timeline_event.rb +40 -0
  55. data/lib/profiler/railtie.rb +75 -0
  56. data/lib/profiler/storage/base_store.rb +41 -0
  57. data/lib/profiler/storage/blob_store.rb +46 -0
  58. data/lib/profiler/storage/file_store.rb +119 -0
  59. data/lib/profiler/storage/memory_store.rb +94 -0
  60. data/lib/profiler/storage/redis_store.rb +98 -0
  61. data/lib/profiler/storage/sqlite_store.rb +272 -0
  62. data/lib/profiler/tasks/profiler.rake +79 -0
  63. data/lib/profiler/version.rb +5 -0
  64. data/lib/profiler.rb +68 -0
  65. metadata +194 -0
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class CacheCollector < BaseCollector
8
+ def initialize(profile)
9
+ super
10
+ @cache_reads = []
11
+ @cache_writes = []
12
+ @cache_deletes = []
13
+ @subscriptions = []
14
+ end
15
+
16
+ def icon
17
+ "💾"
18
+ end
19
+
20
+ def priority
21
+ 50
22
+ end
23
+
24
+ def tab_config
25
+ {
26
+ key: "cache",
27
+ label: "Cache",
28
+ icon: icon,
29
+ priority: priority,
30
+ enabled: true,
31
+ default_active: false
32
+ }
33
+ end
34
+
35
+ def subscribe
36
+ return unless defined?(ActiveSupport::Notifications)
37
+
38
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_read.active_support") do |name, started, finished, unique_id, payload|
39
+ duration = ((finished - started) * 1000).round(2)
40
+ @cache_reads << {
41
+ key: payload[:key],
42
+ hit: payload[:hit],
43
+ duration: duration
44
+ }
45
+ end
46
+
47
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_write.active_support") do |name, started, finished, unique_id, payload|
48
+ duration = ((finished - started) * 1000).round(2)
49
+ @cache_writes << {
50
+ key: payload[:key],
51
+ duration: duration
52
+ }
53
+ end
54
+
55
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_delete.active_support") do |name, started, finished, unique_id, payload|
56
+ duration = ((finished - started) * 1000).round(2)
57
+ @cache_deletes << {
58
+ key: payload[:key],
59
+ duration: duration
60
+ }
61
+ end
62
+ end
63
+
64
+ def collect
65
+ @subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
66
+
67
+ hits = @cache_reads.count { |r| r[:hit] }
68
+ misses = @cache_reads.count { |r| !r[:hit] }
69
+
70
+ data = {
71
+ reads: @cache_reads,
72
+ writes: @cache_writes,
73
+ deletes: @cache_deletes,
74
+ total_reads: @cache_reads.size,
75
+ total_writes: @cache_writes.size,
76
+ total_deletes: @cache_deletes.size,
77
+ hits: hits,
78
+ misses: misses,
79
+ hit_rate: @cache_reads.empty? ? 0 : (hits.to_f / @cache_reads.size * 100).round(2)
80
+ }
81
+
82
+ store_data(data)
83
+ end
84
+
85
+ def toolbar_summary
86
+ hits = @cache_reads.count { |r| r[:hit] }
87
+ total = @cache_reads.size
88
+
89
+ {
90
+ text: "#{hits}/#{total} hits",
91
+ color: "cyan"
92
+ }
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+ require_relative "../models/sql_query"
5
+
6
+ module Profiler
7
+ module Collectors
8
+ class DatabaseCollector < BaseCollector
9
+ def initialize(profile)
10
+ super
11
+ @queries = []
12
+ @subscription = nil
13
+ end
14
+
15
+ def icon
16
+ "🗄️"
17
+ end
18
+
19
+ def priority
20
+ 20
21
+ end
22
+
23
+ def tab_config
24
+ {
25
+ key: "database",
26
+ label: "Database",
27
+ icon: icon,
28
+ priority: priority,
29
+ enabled: true,
30
+ default_active: true
31
+ }
32
+ end
33
+
34
+ def subscribe
35
+ return unless defined?(ActiveSupport::Notifications)
36
+
37
+ @subscription = ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |name, started, finished, unique_id, payload|
38
+ duration = ((finished - started) * 1000).round(2) # milliseconds
39
+
40
+ # Skip schema queries and internal Rails queries
41
+ next if payload[:name] == "SCHEMA"
42
+ next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
43
+
44
+ query = Models::SqlQuery.new(
45
+ sql: payload[:sql],
46
+ duration: duration,
47
+ binds: extract_binds(payload[:binds]),
48
+ name: payload[:name],
49
+ connection: payload[:connection],
50
+ backtrace: extract_backtrace
51
+ )
52
+
53
+ @queries << query
54
+ end
55
+ end
56
+
57
+ def collect
58
+ # Unsubscribe from notifications
59
+ ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
60
+
61
+ data = {
62
+ total_queries: @queries.size,
63
+ total_duration: @queries.sum(&:duration).round(2),
64
+ slow_queries: @queries.select { |q| q.slow?(Profiler.configuration.slow_query_threshold) }.size,
65
+ cached_queries: @queries.count(&:cached?),
66
+ queries: @queries.map(&:to_h)
67
+ }
68
+
69
+ store_data(data)
70
+ end
71
+
72
+ def toolbar_summary
73
+ total = @queries.size
74
+ slow = @queries.select { |q| q.slow?(Profiler.configuration.slow_query_threshold) }.size
75
+ duration = @queries.sum(&:duration).round(2)
76
+
77
+ color = if slow > 0
78
+ "red"
79
+ elsif total > Profiler.configuration.max_queries_warning
80
+ "orange"
81
+ else
82
+ "green"
83
+ end
84
+
85
+ {
86
+ text: "#{total} queries (#{duration}ms)",
87
+ color: color,
88
+ slow_queries: slow
89
+ }
90
+ end
91
+
92
+ private
93
+
94
+ def extract_binds(binds)
95
+ return [] unless binds
96
+
97
+ binds.map do |bind|
98
+ if bind.respond_to?(:value)
99
+ bind.value
100
+ else
101
+ bind
102
+ end
103
+ end
104
+ end
105
+
106
+ def extract_backtrace
107
+ caller_locations(5, 10)
108
+ .reject { |loc| loc.path.include?("active_record") }
109
+ .map { |loc| "#{loc.path}:#{loc.lineno}:in `#{loc.label}`" }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+ require "pp"
5
+
6
+ module Profiler
7
+ module Collectors
8
+ class DumpCollector < BaseCollector
9
+ def icon
10
+ "🔍"
11
+ end
12
+
13
+ def priority
14
+ 15
15
+ end
16
+
17
+ def name
18
+ "dump"
19
+ end
20
+
21
+ def tab_config
22
+ {
23
+ key: "dump",
24
+ label: "Dumps",
25
+ icon: icon,
26
+ priority: priority,
27
+ enabled: true,
28
+ default_active: false
29
+ }
30
+ end
31
+
32
+ def collect
33
+ dumps = Thread.current[:profiler_dumps] || []
34
+
35
+ formatted_dumps = dumps.map do |dump|
36
+ {
37
+ value: dump[:value],
38
+ formatted: format_value(dump[:value]),
39
+ file: dump[:file],
40
+ line: dump[:line],
41
+ label: dump[:label],
42
+ timestamp: dump[:timestamp]
43
+ }
44
+ end
45
+
46
+ store_data({
47
+ count: formatted_dumps.size,
48
+ dumps: formatted_dumps
49
+ })
50
+
51
+ # Clear dumps for next request
52
+ Thread.current[:profiler_dumps] = []
53
+ end
54
+
55
+ def toolbar_summary
56
+ count = @data[:count] || 0
57
+ color = count > 0 ? "blue" : "gray"
58
+
59
+ {
60
+ text: "#{count} dump#{count != 1 ? 's' : ''}",
61
+ color: color
62
+ }
63
+ end
64
+
65
+ private
66
+
67
+ def format_value(value)
68
+ case value
69
+ when String
70
+ value.inspect
71
+ when Array, Hash
72
+ PP.pp(value, +"", 120).strip
73
+ when NilClass
74
+ "nil"
75
+ when TrueClass, FalseClass
76
+ value.to_s
77
+ when Numeric
78
+ value.to_s
79
+ else
80
+ # For objects, show class and instance variables
81
+ formatted = "#<#{value.class}:0x#{value.object_id.to_s(16)}"
82
+ ivars = value.instance_variables
83
+ if ivars.any?
84
+ formatted += " "
85
+ formatted += ivars.map do |ivar|
86
+ ivar_value = value.instance_variable_get(ivar)
87
+ "#{ivar}=#{ivar_value.inspect}"
88
+ end.join(", ")
89
+ end
90
+ formatted += ">"
91
+ formatted
92
+ end
93
+ rescue => e
94
+ "[Error formatting value: #{e.message}]"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+ require_relative "../models/timeline_event"
5
+
6
+ module Profiler
7
+ module Collectors
8
+ class FlameGraphCollector < BaseCollector
9
+ def initialize(profile)
10
+ super
11
+ @events = []
12
+ @subscriptions = []
13
+ end
14
+
15
+ def icon
16
+ "🔥"
17
+ end
18
+
19
+ def priority
20
+ 30
21
+ end
22
+
23
+ def tab_config
24
+ {
25
+ key: "flamegraph",
26
+ label: "Flame Graph",
27
+ icon: icon,
28
+ priority: priority,
29
+ enabled: true,
30
+ default_active: false
31
+ }
32
+ end
33
+
34
+ def subscribe
35
+ return unless defined?(ActiveSupport::Notifications)
36
+
37
+ Thread.current[:profiler_flamegraph_collector] = self
38
+
39
+ # Controller action
40
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process_action.action_controller") do |_name, started, finished, _unique_id, payload|
41
+ @events << Models::TimelineEvent.new(
42
+ name: "#{payload[:controller]}##{payload[:action]}",
43
+ started_at: started,
44
+ finished_at: finished,
45
+ category: "controller",
46
+ payload: {
47
+ controller: payload[:controller],
48
+ action: payload[:action],
49
+ format: payload[:format],
50
+ method: payload[:method],
51
+ path: payload[:path],
52
+ status: payload[:status]
53
+ }
54
+ )
55
+ end
56
+
57
+ # Template rendering
58
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_template.action_view") do |_name, started, finished, _unique_id, payload|
59
+ identifier = short_identifier(payload[:identifier])
60
+ @events << Models::TimelineEvent.new(
61
+ name: "Render: #{identifier}",
62
+ started_at: started,
63
+ finished_at: finished,
64
+ category: "view",
65
+ payload: { identifier: identifier, layout: payload[:layout] }
66
+ )
67
+ end
68
+
69
+ # Partial rendering
70
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_partial.action_view") do |_name, started, finished, _unique_id, payload|
71
+ identifier = short_identifier(payload[:identifier])
72
+ @events << Models::TimelineEvent.new(
73
+ name: "Partial: #{identifier}",
74
+ started_at: started,
75
+ finished_at: finished,
76
+ category: "partial",
77
+ payload: { identifier: identifier }
78
+ )
79
+ end
80
+
81
+ # SQL queries
82
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |_name, started, finished, _unique_id, payload|
83
+ next if payload[:name] == "SCHEMA"
84
+ next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
85
+
86
+ sql = payload[:sql].to_s
87
+ @events << Models::TimelineEvent.new(
88
+ name: sql.length > 80 ? "#{sql[0, 80]}..." : sql,
89
+ started_at: started,
90
+ finished_at: finished,
91
+ category: "sql",
92
+ payload: { sql: sql, name: payload[:name] }
93
+ )
94
+ end
95
+
96
+ # Cache operations
97
+ %w[cache_read.active_support cache_write.active_support cache_delete.active_support].each do |event_name|
98
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe(event_name) do |name, started, finished, _unique_id, payload|
99
+ op = name.split(".").first.sub("cache_", "")
100
+ key = payload[:key].to_s
101
+ @events << Models::TimelineEvent.new(
102
+ name: "cache_#{op}: #{key.length > 60 ? "#{key[0, 60]}..." : key}",
103
+ started_at: started,
104
+ finished_at: finished,
105
+ category: "cache",
106
+ payload: { operation: op, key: key, hit: payload[:hit] }
107
+ )
108
+ end
109
+ end
110
+ end
111
+
112
+ # Called by NetHttpInstrumentation to record outbound HTTP events
113
+ def record_http_event(started_at:, finished_at:, url:, method:, status:)
114
+ @events << Models::TimelineEvent.new(
115
+ name: "HTTP #{method} #{url}",
116
+ started_at: started_at,
117
+ finished_at: finished_at,
118
+ category: "http",
119
+ payload: { url: url, method: method, status: status }
120
+ )
121
+ end
122
+
123
+ def collect
124
+ @subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
125
+ Thread.current[:profiler_flamegraph_collector] = nil
126
+
127
+ root_events = build_hierarchy(@events)
128
+
129
+ store_data({
130
+ total_events: @events.size,
131
+ total_duration: @events.empty? ? 0 : @events.map(&:duration).sum.round(2),
132
+ root_events: root_events.map(&:to_h)
133
+ })
134
+ end
135
+
136
+ def toolbar_summary
137
+ {
138
+ text: "#{@events.size} events",
139
+ color: "blue"
140
+ }
141
+ end
142
+
143
+ private
144
+
145
+ def build_hierarchy(events)
146
+ return [] if events.empty?
147
+
148
+ # Sort by started_at ASC, then by duration DESC (longest first = parents first)
149
+ sorted = events.sort_by { |e| [e.started_at, -e.duration] }
150
+
151
+ # Stack-based nesting: each stack entry is a potential parent
152
+ roots = []
153
+ stack = []
154
+
155
+ sorted.each do |event|
156
+ # Pop stack entries that have finished before this event starts
157
+ stack.pop while stack.any? && stack.last.finished_at <= event.started_at
158
+
159
+ # Pop stack entries where this event doesn't fit inside
160
+ stack.pop while stack.any? && event.finished_at > stack.last.finished_at
161
+
162
+ if stack.any?
163
+ stack.last.add_child(event)
164
+ else
165
+ roots << event
166
+ end
167
+
168
+ stack.push(event)
169
+ end
170
+
171
+ roots
172
+ end
173
+
174
+ def short_identifier(identifier)
175
+ return identifier.to_s unless identifier.to_s.include?("/")
176
+
177
+ parts = identifier.to_s.split("/")
178
+ parts.last(2).join("/")
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+ require_relative "../instrumentation/net_http_instrumentation"
5
+
6
+ module Profiler
7
+ module Collectors
8
+ class HttpCollector < BaseCollector
9
+ def initialize(profile)
10
+ super
11
+ @requests = []
12
+ end
13
+
14
+ def icon
15
+ "🔗"
16
+ end
17
+
18
+ def priority
19
+ 35
20
+ end
21
+
22
+ def tab_config
23
+ {
24
+ key: "http",
25
+ label: "Outbound HTTP",
26
+ icon: icon,
27
+ priority: priority,
28
+ enabled: true,
29
+ default_active: false
30
+ }
31
+ end
32
+
33
+ def subscribe
34
+ return unless Profiler.configuration.track_http
35
+
36
+ Profiler::Instrumentation::NetHttpInstrumentation.install!
37
+ Thread.current[:profiler_http_collector] = self
38
+ end
39
+
40
+ def collect
41
+ Thread.current[:profiler_http_collector] = nil
42
+
43
+ threshold = Profiler.configuration.slow_http_threshold
44
+
45
+ store_data(
46
+ total_requests: @requests.size,
47
+ total_duration: @requests.sum { |r| r[:duration] }.round(2),
48
+ slow_requests: @requests.count { |r| r[:duration] >= threshold },
49
+ error_requests: @requests.count { |r| r[:status] >= 400 || r[:status] == 0 },
50
+ by_host: group_by_host,
51
+ by_status: group_by_status,
52
+ requests: @requests.map { |r| r.transform_keys(&:to_s) }
53
+ )
54
+ end
55
+
56
+ def record_request(payload)
57
+ @requests << payload
58
+ end
59
+
60
+ def toolbar_summary
61
+ total = @requests.size
62
+ return { text: "0 HTTP", color: "green" } if total == 0
63
+
64
+ threshold = Profiler.configuration.slow_http_threshold
65
+ errors = @requests.count { |r| r[:status] >= 400 || r[:status] == 0 }
66
+ slow = @requests.count { |r| r[:duration] >= threshold }
67
+ duration = @requests.sum { |r| r[:duration] }.round(2)
68
+
69
+ color = if errors > 0 || slow > 0
70
+ "red"
71
+ elsif total > 10
72
+ "orange"
73
+ else
74
+ "green"
75
+ end
76
+
77
+ { text: "#{total} HTTP (#{duration}ms)", color: color }
78
+ end
79
+
80
+ private
81
+
82
+ def group_by_host
83
+ @requests.each_with_object(Hash.new(0)) do |req, h|
84
+ host = begin
85
+ URI.parse(req[:url]).host || "unknown"
86
+ rescue URI::InvalidURIError
87
+ "unknown"
88
+ end
89
+ h[host] += 1
90
+ end
91
+ end
92
+
93
+ def group_by_status
94
+ @requests.each_with_object(Hash.new(0)) do |req, h|
95
+ status = req[:status]
96
+ key = if status == 0
97
+ "error"
98
+ elsif status < 300
99
+ "2xx"
100
+ elsif status < 400
101
+ "3xx"
102
+ elsif status < 500
103
+ "4xx"
104
+ else
105
+ "5xx"
106
+ end
107
+ h[key] += 1
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class JobCollector < BaseCollector
8
+ def initialize(profile, job_data = {})
9
+ super(profile)
10
+ @job_data = job_data.merge(status: "running")
11
+ end
12
+
13
+ def icon
14
+ "⚙️"
15
+ end
16
+
17
+ def priority
18
+ 5
19
+ end
20
+
21
+ def tab_config
22
+ {
23
+ key: "job",
24
+ label: "Job",
25
+ icon: icon,
26
+ priority: priority,
27
+ enabled: true,
28
+ default_active: true
29
+ }
30
+ end
31
+
32
+ def update_status(status, error_message = nil)
33
+ @job_data[:status] = status
34
+ @job_data[:error] = error_message if error_message
35
+ end
36
+
37
+ def collect
38
+ store_data(@job_data)
39
+ end
40
+
41
+ def has_data?
42
+ @job_data.any?
43
+ end
44
+
45
+ def toolbar_summary
46
+ { text: @job_data[:job_class].to_s, color: @job_data[:status] == "failed" ? "red" : "green" }
47
+ end
48
+ end
49
+ end
50
+ end