rorvswild 0.4.1 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +15 -2
- data/bin/rorvswild-install +11 -0
- data/lib/rorvswild/client.rb +310 -0
- data/lib/rorvswild/installer.rb +33 -0
- data/lib/rorvswild/location.rb +42 -0
- data/lib/rorvswild/plugin/resque.rb +13 -0
- data/lib/rorvswild/plugin/sidekiq.rb +18 -0
- data/lib/rorvswild/rails_loader.rb +26 -0
- data/lib/rorvswild/version.rb +1 -1
- data/lib/rorvswild.rb +16 -391
- data/test/ror_vs_wild_test.rb +9 -9
- metadata +11 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4149e3a909cd273aa8aa37ad8e59b77755382ad1
|
4
|
+
data.tar.gz: a99acd4ecbae3fa5b6a2d800e54a612aee0e628d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9fee4cf6ba25541d6f3cb2723bfa427005fdc953688ba6984d0af8910c4c3ff7cd0cac170d7455d7e9c4eecef4f5d16c10409b690a5dd999ca09212be3ba0ca9
|
7
|
+
data.tar.gz: 29cc6945e10e4600423d7e1162d85b0a67f28dc67bcd050562cbca06059c2a55a161d66d9e63aba2d80a32ecfff8464a17e7212d04769b690d0c2e1d3e59a703
|
data/README.md
CHANGED
@@ -1,10 +1,23 @@
|
|
1
1
|
# RorVsWild
|
2
2
|
|
3
|
-
|
3
|
+
Ruby on Rails app monitoring: performances & quality insights for rails developers.
|
4
4
|
|
5
5
|
## Installation
|
6
6
|
|
7
|
-
Signup
|
7
|
+
First you need an API key. Signup here https://www.rorvswild.com to get one and a 14 day free trial.
|
8
|
+
|
9
|
+
1. Add in your Gemfile `gem "rorvswild"`
|
10
|
+
2. Run `bundle install`
|
11
|
+
3. Run `rorvswild-setup API_KEY`
|
12
|
+
4. Restart / deploy your app !
|
13
|
+
|
14
|
+
The `rorvswild-setup` create a `config/rorvswild.yml` file.
|
15
|
+
For those who prefer to manually use an initializer, they can do the following.
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
# config/initializers/rorvswild.rb
|
19
|
+
RorVsWild::Client.new(api_key: API_KEY)
|
20
|
+
```
|
8
21
|
|
9
22
|
## Contributing
|
10
23
|
|
@@ -0,0 +1,310 @@
|
|
1
|
+
require "json/ext"
|
2
|
+
require "net/http"
|
3
|
+
require "logger"
|
4
|
+
require "uri"
|
5
|
+
require "set"
|
6
|
+
|
7
|
+
module RorVsWild
|
8
|
+
class Client
|
9
|
+
include RorVsWild::Location
|
10
|
+
|
11
|
+
def self.default_config
|
12
|
+
{
|
13
|
+
api_url: "https://www.rorvswild.com/api",
|
14
|
+
explain_sql_threshold: 500,
|
15
|
+
ignored_exceptions: [],
|
16
|
+
}
|
17
|
+
end
|
18
|
+
|
19
|
+
attr_reader :api_url, :api_key, :app_id, :explain_sql_threshold, :app_root, :ignored_exceptions
|
20
|
+
|
21
|
+
attr_reader :threads, :app_root_regex
|
22
|
+
|
23
|
+
def initialize(config)
|
24
|
+
config = self.class.default_config.merge(config)
|
25
|
+
@explain_sql_threshold = config[:explain_sql_threshold]
|
26
|
+
@ignored_exceptions = config[:ignored_exceptions]
|
27
|
+
@app_root = config[:app_root]
|
28
|
+
@api_url = config[:api_url]
|
29
|
+
@api_key = config[:api_key]
|
30
|
+
@app_id = config[:app_id]
|
31
|
+
@logger = config[:logger]
|
32
|
+
@threads = Set.new
|
33
|
+
@data = {}
|
34
|
+
|
35
|
+
if defined?(Rails)
|
36
|
+
@logger ||= Rails.logger
|
37
|
+
@app_root ||= Rails.root.to_s
|
38
|
+
config = Rails.application.config
|
39
|
+
@parameter_filter = ActionDispatch::Http::ParameterFilter.new(config.filter_parameters)
|
40
|
+
@ignored_exceptions ||= %w[ActionController::RoutingError] + config.action_dispatch.rescue_responses.map { |(key,value)| key }
|
41
|
+
end
|
42
|
+
|
43
|
+
@logger ||= Logger.new(STDERR)
|
44
|
+
@app_root_regex = app_root ? /\A#{app_root}/ : nil
|
45
|
+
|
46
|
+
setup_callbacks
|
47
|
+
RorVsWild.register_client(self)
|
48
|
+
end
|
49
|
+
|
50
|
+
def setup_callbacks
|
51
|
+
client = self
|
52
|
+
if defined?(ActiveSupport::Notifications)
|
53
|
+
ActiveSupport::Notifications.subscribe("sql.active_record", &method(:after_sql_query))
|
54
|
+
ActiveSupport::Notifications.subscribe("render_partial.action_view", &method(:after_view_rendering))
|
55
|
+
ActiveSupport::Notifications.subscribe("render_template.action_view", &method(:after_view_rendering))
|
56
|
+
ActiveSupport::Notifications.subscribe("process_action.action_controller", &method(:after_http_request))
|
57
|
+
ActiveSupport::Notifications.subscribe("start_processing.action_controller", &method(:before_http_request))
|
58
|
+
ActionController::Base.rescue_from(StandardError) { |exception| client.after_exception(exception, self) }
|
59
|
+
end
|
60
|
+
|
61
|
+
Plugin::Resque.setup
|
62
|
+
Plugin::Sidekiq.setup
|
63
|
+
Kernel.at_exit(&method(:at_exit))
|
64
|
+
ActiveJob::Base.around_perform(&method(:around_active_job)) if defined?(ActiveJob::Base)
|
65
|
+
Delayed::Worker.lifecycle.around(:invoke_job, &method(:around_delayed_job)) if defined?(Delayed::Worker)
|
66
|
+
end
|
67
|
+
|
68
|
+
def before_http_request(name, start, finish, id, payload)
|
69
|
+
request.merge!(controller: payload[:controller], action: payload[:action], path: payload[:path], queries: [], views: {})
|
70
|
+
end
|
71
|
+
|
72
|
+
def after_http_request(name, start, finish, id, payload)
|
73
|
+
request[:db_runtime] = (payload[:db_runtime] || 0).round
|
74
|
+
request[:view_runtime] = (payload[:view_runtime] || 0).round
|
75
|
+
request[:other_runtime] = compute_duration(start, finish) - request[:db_runtime] - request[:view_runtime]
|
76
|
+
request[:error][:parameters] = filter_sensitive_data(payload[:params]) if request[:error]
|
77
|
+
post_request
|
78
|
+
rescue => exception
|
79
|
+
log_error(exception)
|
80
|
+
end
|
81
|
+
|
82
|
+
IGNORED_QUERIES = %w[EXPLAIN SCHEMA].freeze
|
83
|
+
|
84
|
+
def after_sql_query(name, start, finish, id, payload)
|
85
|
+
return if !queries || IGNORED_QUERIES.include?(payload[:name])
|
86
|
+
file, line, method = extract_most_relevant_location(caller)
|
87
|
+
runtime, sql = compute_duration(start, finish), payload[:sql]
|
88
|
+
plan = runtime >= explain_sql_threshold ? explain(payload[:sql], payload[:binds]) : nil
|
89
|
+
push_query(file: file, line: line, method: method, sql: sql, plan: plan, runtime: runtime)
|
90
|
+
rescue => exception
|
91
|
+
log_error(exception)
|
92
|
+
end
|
93
|
+
|
94
|
+
def after_view_rendering(name, start, finish, id, payload)
|
95
|
+
if views
|
96
|
+
if view = views[file = relative_path(payload[:identifier])]
|
97
|
+
view[:runtime] += compute_duration(start, finish)
|
98
|
+
view[:times] += 1
|
99
|
+
else
|
100
|
+
views[file] = {file: file, runtime: compute_duration(start, finish), times: 1}
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def after_exception(exception, controller)
|
106
|
+
if !ignored_exception?(exception)
|
107
|
+
file, line = exception.backtrace.first.split(":")
|
108
|
+
request[:error] = exception_to_hash(exception).merge(
|
109
|
+
session: controller.session.to_hash,
|
110
|
+
environment_variables: filter_sensitive_data(filter_environment_variables(controller.request.env))
|
111
|
+
)
|
112
|
+
end
|
113
|
+
raise exception
|
114
|
+
end
|
115
|
+
|
116
|
+
def around_active_job(job, block)
|
117
|
+
measure_block(job.class.name, &block)
|
118
|
+
end
|
119
|
+
|
120
|
+
def around_delayed_job(job, &block)
|
121
|
+
measure_block(job.name) { block.call(job) }
|
122
|
+
end
|
123
|
+
|
124
|
+
def measure_code(code)
|
125
|
+
measure_block(code) { eval(code) }
|
126
|
+
end
|
127
|
+
|
128
|
+
def measure_block(name, &block)
|
129
|
+
return block.call if job[:name] # Prevent from recursive jobs
|
130
|
+
job[:name] = name
|
131
|
+
job[:queries] = []
|
132
|
+
started_at = Time.now
|
133
|
+
cpu_time_offset = cpu_time
|
134
|
+
begin
|
135
|
+
block.call
|
136
|
+
rescue Exception => ex
|
137
|
+
job[:error] = exception_to_hash(ex) if !ignored_exception?(ex)
|
138
|
+
raise
|
139
|
+
ensure
|
140
|
+
job[:runtime] = (Time.now - started_at) * 1000
|
141
|
+
job[:cpu_runtime] = (cpu_time - cpu_time_offset) * 1000
|
142
|
+
post_job
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def catch_error(extra_details = nil, &block)
|
147
|
+
begin
|
148
|
+
block.call
|
149
|
+
rescue Exception => ex
|
150
|
+
record_error(ex, extra_details) if !ignored_exception?(ex)
|
151
|
+
ex
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def record_error(exception, extra_details = nil)
|
156
|
+
post_error(exception_to_hash(exception, extra_details))
|
157
|
+
end
|
158
|
+
|
159
|
+
def cpu_time
|
160
|
+
time = Process.times
|
161
|
+
time.utime + time.stime + time.cutime + time.cstime
|
162
|
+
end
|
163
|
+
|
164
|
+
#######################
|
165
|
+
### Private methods ###
|
166
|
+
#######################
|
167
|
+
|
168
|
+
private
|
169
|
+
|
170
|
+
def queries
|
171
|
+
data[:queries]
|
172
|
+
end
|
173
|
+
|
174
|
+
def views
|
175
|
+
data[:views]
|
176
|
+
end
|
177
|
+
|
178
|
+
def job
|
179
|
+
data
|
180
|
+
end
|
181
|
+
|
182
|
+
def request
|
183
|
+
data
|
184
|
+
end
|
185
|
+
|
186
|
+
def data
|
187
|
+
@data[Thread.current.object_id] ||= {}
|
188
|
+
end
|
189
|
+
|
190
|
+
def cleanup_data
|
191
|
+
@data.delete(Thread.current.object_id)
|
192
|
+
end
|
193
|
+
|
194
|
+
MEANINGLESS_QUERIES = %w[BEGIN COMMIT].freeze
|
195
|
+
|
196
|
+
def push_query(query)
|
197
|
+
hash = queries.find { |hash| hash[:line] == query[:line] && hash[:file] == query[:file] }
|
198
|
+
queries << hash = {file: query[:file], line: query[:line], runtime: 0, times: 0} if !hash
|
199
|
+
hash[:runtime] += query[:runtime]
|
200
|
+
if !MEANINGLESS_QUERIES.include?(query[:sql])
|
201
|
+
hash[:times] += 1
|
202
|
+
hash[:sql] ||= query[:sql]
|
203
|
+
hash[:plan] ||= query[:plan] if query[:plan]
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def slowest_views
|
208
|
+
views.values.sort { |h1, h2| h2[:runtime] <=> h1[:runtime] }[0, 25]
|
209
|
+
end
|
210
|
+
|
211
|
+
def slowest_queries
|
212
|
+
queries.sort { |h1, h2| h2[:runtime] <=> h1[:runtime] }[0, 25]
|
213
|
+
end
|
214
|
+
|
215
|
+
SELECT_REGEX = /\Aselect/i.freeze
|
216
|
+
|
217
|
+
def explain(sql, binds)
|
218
|
+
ActiveRecord::Base.connection.explain(sql, binds) if sql =~ SELECT_REGEX
|
219
|
+
end
|
220
|
+
|
221
|
+
def post_request
|
222
|
+
attributes = request.merge(queries: slowest_queries, views: slowest_views)
|
223
|
+
post_async("/requests".freeze, request: attributes)
|
224
|
+
ensure
|
225
|
+
cleanup_data
|
226
|
+
end
|
227
|
+
|
228
|
+
def post_job
|
229
|
+
attributes = job.merge(queries: slowest_queries)
|
230
|
+
post_async("/jobs".freeze, job: attributes)
|
231
|
+
rescue => exception
|
232
|
+
log_error(exception)
|
233
|
+
ensure
|
234
|
+
cleanup_data
|
235
|
+
end
|
236
|
+
|
237
|
+
def post_error(hash)
|
238
|
+
post_async("/errors".freeze, error: hash)
|
239
|
+
end
|
240
|
+
|
241
|
+
def compute_duration(start, finish)
|
242
|
+
((finish - start) * 1000)
|
243
|
+
end
|
244
|
+
|
245
|
+
def exception_to_hash(exception, extra_details = nil)
|
246
|
+
file, line, method = extract_most_relevant_location(exception.backtrace)
|
247
|
+
{
|
248
|
+
method: method,
|
249
|
+
line: line.to_i,
|
250
|
+
file: relative_path(file),
|
251
|
+
message: exception.message,
|
252
|
+
backtrace: exception.backtrace,
|
253
|
+
exception: exception.class.to_s,
|
254
|
+
extra_details: extra_details,
|
255
|
+
}
|
256
|
+
end
|
257
|
+
|
258
|
+
HTTPS = "https".freeze
|
259
|
+
CERTIFICATE_AUTHORITIES_PATH = File.expand_path("../../../cacert.pem", __FILE__)
|
260
|
+
|
261
|
+
def post(path, data)
|
262
|
+
uri = URI(api_url + path)
|
263
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
264
|
+
|
265
|
+
if uri.scheme == HTTPS
|
266
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
267
|
+
http.ca_file = CERTIFICATE_AUTHORITIES_PATH
|
268
|
+
http.use_ssl = true
|
269
|
+
end
|
270
|
+
|
271
|
+
post = Net::HTTP::Post.new(uri.path)
|
272
|
+
post.content_type = "application/json".freeze
|
273
|
+
post.basic_auth(app_id, api_key)
|
274
|
+
post.body = data.to_json
|
275
|
+
http.request(post)
|
276
|
+
end
|
277
|
+
|
278
|
+
def post_async(path, data)
|
279
|
+
Thread.new do
|
280
|
+
begin
|
281
|
+
threads.add(Thread.current)
|
282
|
+
post(path, data)
|
283
|
+
ensure
|
284
|
+
threads.delete(Thread.current)
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
def at_exit
|
290
|
+
threads.each(&:join)
|
291
|
+
end
|
292
|
+
|
293
|
+
def filter_sensitive_data(hash)
|
294
|
+
@parameter_filter ? @parameter_filter.filter(hash) : hash
|
295
|
+
end
|
296
|
+
|
297
|
+
def filter_environment_variables(hash)
|
298
|
+
hash.clone.keep_if { |key,value| key == key.upcase }
|
299
|
+
end
|
300
|
+
|
301
|
+
def ignored_exception?(exception)
|
302
|
+
ignored_exceptions.include?(exception.class.to_s)
|
303
|
+
end
|
304
|
+
|
305
|
+
def log_error(exception)
|
306
|
+
@logger.error("[RorVsWild] " + exception.inspect)
|
307
|
+
@logger.error("[RorVsWild] " + exception.backtrace.join("\n[RorVsWild] "))
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module RorVsWild
|
2
|
+
class Installer
|
3
|
+
PATH = "config/rorvswild.yml"
|
4
|
+
|
5
|
+
def self.create_rails_config(api_key)
|
6
|
+
if File.directory?("config")
|
7
|
+
if !File.exists?(PATH)
|
8
|
+
File.write(PATH, template(api_key))
|
9
|
+
puts "File #{PATH} has been created. Restart / deploy your app to start collecting data."
|
10
|
+
else
|
11
|
+
puts "File #{PATH} already exists."
|
12
|
+
end
|
13
|
+
else
|
14
|
+
puts "There is no config directory to create #{PATH}."
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.template(api_key)
|
19
|
+
<<YAML
|
20
|
+
# Keep the development block for testing on your local machine only.
|
21
|
+
development:
|
22
|
+
api_key: #{api_key}
|
23
|
+
|
24
|
+
production:
|
25
|
+
api_key: #{api_key}
|
26
|
+
# explain_sql_threshold: 500 # Execute EXPLAIN for queries above the specified time in ms.
|
27
|
+
# ignored_exceptions:
|
28
|
+
# - ActionController::RoutingError
|
29
|
+
# - UncommentToIgnoreAnyExceptionNameListedHere
|
30
|
+
YAML
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module RorVsWild
|
2
|
+
module Location
|
3
|
+
def self.cleanup_method_name(method)
|
4
|
+
method.sub!("block in ".freeze, "".freeze)
|
5
|
+
method.sub!("in `".freeze, "".freeze)
|
6
|
+
method.sub!("'".freeze, "".freeze)
|
7
|
+
method.index("_app_views_".freeze) == 0 ? nil : method
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.split_file_location(location)
|
11
|
+
file, line, method = location.split(":")
|
12
|
+
method = cleanup_method_name(method) if method
|
13
|
+
[file, line, method]
|
14
|
+
end
|
15
|
+
|
16
|
+
def extract_most_relevant_location(stack)
|
17
|
+
location = stack.find { |str| str =~ app_root_regex && !(str =~ gem_home_regex) } if app_root_regex
|
18
|
+
location ||= stack.find { |str| !(str =~ gem_home_regex) } if gem_home_regex
|
19
|
+
RorVsWild::Location.split_file_location(relative_path(location || stack.first))
|
20
|
+
end
|
21
|
+
|
22
|
+
def app_root_regex
|
23
|
+
@app_root_regex ||= RorVsWild.default_client.app_root ? /\A#{RorVsWild.default_client.app_root}/ : nil
|
24
|
+
end
|
25
|
+
|
26
|
+
def gem_home_regex
|
27
|
+
@gem_home_regex ||= gem_home ? /\A#{gem_home}/.freeze : /\/gems\//.freeze
|
28
|
+
end
|
29
|
+
|
30
|
+
def gem_home
|
31
|
+
if ENV["GEM_HOME"] && !ENV["GEM_HOME"].empty?
|
32
|
+
ENV["GEM_HOME"]
|
33
|
+
elsif ENV["GEM_PATH"] && !(first_gem_path = ENV["GEM_PATH"].split(":").first)
|
34
|
+
first_gem_path if first_gem_path && !first_gem_path.empty?
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def relative_path(path)
|
39
|
+
app_root_regex ? path.sub(app_root_regex, "".freeze) : path
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module RorVsWild
|
2
|
+
module Plugin
|
3
|
+
class Sidekiq
|
4
|
+
def self.setup
|
5
|
+
if defined?(::Sidekiq)
|
6
|
+
::Sidekiq.configure_server do |config|
|
7
|
+
config.server_middleware { |chain| chain.add(SidekiqPlugin) }
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(worker, item, queue, &block)
|
13
|
+
# Wrapped contains the real class name of the ActiveJob wrapper
|
14
|
+
RorVsWild.measure_block(item["wrapped".freeze] || item["class".freeze], &block)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module RorVsWild
|
2
|
+
class RailsLoader
|
3
|
+
@started = false
|
4
|
+
|
5
|
+
def self.start_on_rails_initialization
|
6
|
+
return if !defined?(Rails)
|
7
|
+
Rails::Railtie.initializer "rorvswild.detect_config_file" do
|
8
|
+
RorVsWild::RailsLoader.start
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.start
|
13
|
+
return if @started
|
14
|
+
if (path = Rails.root.join("config/rorvswild.yml")).exist?
|
15
|
+
if config = RorVsWild::RailsLoader.load_config_file(path)[Rails.env]
|
16
|
+
RorVsWild::Client.new(config.symbolize_keys)
|
17
|
+
@started = true
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.load_config_file(path)
|
23
|
+
YAML.load(ERB.new(path.read).result)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
data/lib/rorvswild/version.rb
CHANGED
data/lib/rorvswild.rb
CHANGED
@@ -1,411 +1,36 @@
|
|
1
1
|
require "rorvswild/version"
|
2
|
-
require "
|
3
|
-
require "
|
4
|
-
require "
|
5
|
-
require "
|
6
|
-
require "set"
|
2
|
+
require "rorvswild/location"
|
3
|
+
require "rorvswild/plugin/resque"
|
4
|
+
require "rorvswild/plugin/sidekiq"
|
5
|
+
require "rorvswild/client"
|
7
6
|
|
8
7
|
module RorVsWild
|
9
|
-
def self.new(*args)
|
10
|
-
warn "WARNING: RorVsWild.new is deprecated. Use RorVsWild::Client.new instead."
|
11
|
-
Client.new(*args) # Compatibility with 0.0.1
|
12
|
-
end
|
13
|
-
|
14
|
-
def self.detect_config_file
|
15
|
-
return if !defined?(Rails)
|
16
|
-
Rails::Railtie.initializer "rorvswild.detect_config_file" do
|
17
|
-
if !RorVsWild.default_client && (path = Rails.root.join("config/rorvswild.yml")).exist?
|
18
|
-
if config = RorVsWild.load_config_file(path)[Rails.env]
|
19
|
-
RorVsWild::Client.new(config.symbolize_keys)
|
20
|
-
end
|
21
|
-
end
|
22
|
-
end
|
23
|
-
end
|
24
|
-
|
25
|
-
def self.load_config_file(path)
|
26
|
-
YAML.load(ERB.new(path.read).result)
|
27
|
-
end
|
28
|
-
|
29
|
-
def self.register_default_client(client)
|
30
|
-
@default_client = client
|
31
|
-
end
|
32
|
-
|
33
|
-
def self.default_client
|
34
|
-
@default_client
|
35
|
-
end
|
36
|
-
|
37
|
-
def self.measure_job(code)
|
38
|
-
default_client ? default_client.measure_job(code) : eval(code)
|
39
|
-
end
|
40
|
-
|
41
8
|
def self.measure_code(code)
|
42
|
-
|
9
|
+
client ? client.measure_code(code) : eval(code)
|
43
10
|
end
|
44
11
|
|
45
12
|
def self.measure_block(name, &block)
|
46
|
-
|
13
|
+
client ? client.measure_block(name , &block) : block.call
|
47
14
|
end
|
48
15
|
|
49
16
|
def self.catch_error(extra_details = nil, &block)
|
50
|
-
|
17
|
+
client ? client.catch_error(extra_details, &block) : block.call
|
51
18
|
end
|
52
19
|
|
53
20
|
def self.record_error(exception, extra_details = nil)
|
54
|
-
|
55
|
-
end
|
56
|
-
|
57
|
-
class Client
|
58
|
-
def self.default_config
|
59
|
-
{
|
60
|
-
api_url: "https://www.rorvswild.com/api",
|
61
|
-
explain_sql_threshold: 500,
|
62
|
-
ignored_exceptions: [],
|
63
|
-
}
|
64
|
-
end
|
65
|
-
|
66
|
-
attr_reader :api_url, :api_key, :app_id, :explain_sql_threshold, :app_root, :ignored_exceptions
|
67
|
-
|
68
|
-
attr_reader :threads, :app_root_regex
|
69
|
-
|
70
|
-
def initialize(config)
|
71
|
-
config = self.class.default_config.merge(config)
|
72
|
-
@explain_sql_threshold = config[:explain_sql_threshold]
|
73
|
-
@ignored_exceptions = config[:ignored_exceptions]
|
74
|
-
@app_root = config[:app_root]
|
75
|
-
@api_url = config[:api_url]
|
76
|
-
@api_key = config[:api_key]
|
77
|
-
@app_id = config[:app_id]
|
78
|
-
@logger = config[:logger]
|
79
|
-
@threads = Set.new
|
80
|
-
@data = {}
|
81
|
-
|
82
|
-
if defined?(Rails)
|
83
|
-
@logger ||= Rails.logger
|
84
|
-
@app_root ||= Rails.root.to_s
|
85
|
-
config = Rails.application.config
|
86
|
-
@parameter_filter = ActionDispatch::Http::ParameterFilter.new(config.filter_parameters)
|
87
|
-
@ignored_exceptions ||= %w[ActionController::RoutingError] + config.action_dispatch.rescue_responses.map { |(key,value)| key }
|
88
|
-
end
|
89
|
-
|
90
|
-
@logger ||= Logger.new(STDERR)
|
91
|
-
@app_root_regex = app_root ? /\A#{app_root}/ : nil
|
92
|
-
|
93
|
-
setup_callbacks
|
94
|
-
RorVsWild.register_default_client(self)
|
95
|
-
end
|
96
|
-
|
97
|
-
def setup_callbacks
|
98
|
-
client = self
|
99
|
-
if defined?(ActiveSupport::Notifications)
|
100
|
-
ActiveSupport::Notifications.subscribe("sql.active_record", &method(:after_sql_query))
|
101
|
-
ActiveSupport::Notifications.subscribe("render_partial.action_view", &method(:after_view_rendering))
|
102
|
-
ActiveSupport::Notifications.subscribe("render_template.action_view", &method(:after_view_rendering))
|
103
|
-
ActiveSupport::Notifications.subscribe("process_action.action_controller", &method(:after_http_request))
|
104
|
-
ActiveSupport::Notifications.subscribe("start_processing.action_controller", &method(:before_http_request))
|
105
|
-
ActionController::Base.rescue_from(StandardError) { |exception| client.after_exception(exception, self) }
|
106
|
-
end
|
107
|
-
|
108
|
-
Kernel.at_exit(&method(:at_exit))
|
109
|
-
Resque::Job.send(:extend, ResquePlugin) if defined?(Resque::Job)
|
110
|
-
ActiveJob::Base.around_perform(&method(:around_active_job)) if defined?(ActiveJob::Base)
|
111
|
-
Delayed::Worker.lifecycle.around(:invoke_job, &method(:around_delayed_job)) if defined?(Delayed::Worker)
|
112
|
-
Sidekiq.configure_server { |config| config.server_middleware { |chain| chain.add(SidekiqPlugin) } } if defined?(Sidekiq)
|
113
|
-
end
|
114
|
-
|
115
|
-
def before_http_request(name, start, finish, id, payload)
|
116
|
-
request.merge!(controller: payload[:controller], action: payload[:action], path: payload[:path], queries: [], views: {})
|
117
|
-
end
|
118
|
-
|
119
|
-
def after_http_request(name, start, finish, id, payload)
|
120
|
-
request[:db_runtime] = (payload[:db_runtime] || 0).round
|
121
|
-
request[:view_runtime] = (payload[:view_runtime] || 0).round
|
122
|
-
request[:other_runtime] = compute_duration(start, finish) - request[:db_runtime] - request[:view_runtime]
|
123
|
-
request[:error][:parameters] = filter_sensitive_data(payload[:params]) if request[:error]
|
124
|
-
post_request
|
125
|
-
rescue => exception
|
126
|
-
log_error(exception)
|
127
|
-
end
|
128
|
-
|
129
|
-
IGNORED_QUERIES = %w[EXPLAIN SCHEMA].freeze
|
130
|
-
|
131
|
-
def after_sql_query(name, start, finish, id, payload)
|
132
|
-
return if !queries || IGNORED_QUERIES.include?(payload[:name])
|
133
|
-
file, line, method = extract_most_relevant_location(caller)
|
134
|
-
runtime, sql = compute_duration(start, finish), payload[:sql]
|
135
|
-
plan = runtime >= explain_sql_threshold ? explain(payload[:sql], payload[:binds]) : nil
|
136
|
-
push_query(file: file, line: line, method: method, sql: sql, plan: plan, runtime: runtime)
|
137
|
-
rescue => exception
|
138
|
-
log_error(exception)
|
139
|
-
end
|
140
|
-
|
141
|
-
def after_view_rendering(name, start, finish, id, payload)
|
142
|
-
if views
|
143
|
-
if view = views[file = relative_path(payload[:identifier])]
|
144
|
-
view[:runtime] += compute_duration(start, finish)
|
145
|
-
view[:times] += 1
|
146
|
-
else
|
147
|
-
views[file] = {file: file, runtime: compute_duration(start, finish), times: 1}
|
148
|
-
end
|
149
|
-
end
|
150
|
-
end
|
151
|
-
|
152
|
-
def after_exception(exception, controller)
|
153
|
-
if !ignored_exception?(exception)
|
154
|
-
file, line = exception.backtrace.first.split(":")
|
155
|
-
request[:error] = exception_to_hash(exception).merge(
|
156
|
-
session: controller.session.to_hash,
|
157
|
-
environment_variables: filter_sensitive_data(filter_environment_variables(controller.request.env))
|
158
|
-
)
|
159
|
-
end
|
160
|
-
raise exception
|
161
|
-
end
|
162
|
-
|
163
|
-
def around_active_job(job, block)
|
164
|
-
measure_block(job.class.name, &block)
|
165
|
-
end
|
166
|
-
|
167
|
-
def around_delayed_job(job, &block)
|
168
|
-
measure_block(job.name) { block.call(job) }
|
169
|
-
end
|
170
|
-
|
171
|
-
def measure_job(code)
|
172
|
-
warn "WARNING: RorVsWild.measure_job is deprecated. Use RorVsWild.measure_code instead."
|
173
|
-
measure_block(code) { eval(code) }
|
174
|
-
end
|
175
|
-
|
176
|
-
def measure_code(code)
|
177
|
-
measure_block(code) { eval(code) }
|
178
|
-
end
|
179
|
-
|
180
|
-
def measure_block(name, &block)
|
181
|
-
return block.call if job[:name] # Prevent from recursive jobs
|
182
|
-
job[:name] = name
|
183
|
-
job[:queries] = []
|
184
|
-
started_at = Time.now
|
185
|
-
cpu_time_offset = cpu_time
|
186
|
-
begin
|
187
|
-
block.call
|
188
|
-
rescue Exception => ex
|
189
|
-
job[:error] = exception_to_hash(ex) if !ignored_exception?(ex)
|
190
|
-
raise
|
191
|
-
ensure
|
192
|
-
job[:runtime] = (Time.now - started_at) * 1000
|
193
|
-
job[:cpu_runtime] = (cpu_time - cpu_time_offset) * 1000
|
194
|
-
post_job
|
195
|
-
end
|
196
|
-
end
|
197
|
-
|
198
|
-
def catch_error(extra_details = nil, &block)
|
199
|
-
begin
|
200
|
-
block.call
|
201
|
-
rescue Exception => ex
|
202
|
-
record_error(ex, extra_details) if !ignored_exception?(ex)
|
203
|
-
ex
|
204
|
-
end
|
205
|
-
end
|
206
|
-
|
207
|
-
def record_error(exception, extra_details = nil)
|
208
|
-
post_error(exception_to_hash(exception, extra_details))
|
209
|
-
end
|
210
|
-
|
211
|
-
def cpu_time
|
212
|
-
time = Process.times
|
213
|
-
time.utime + time.stime + time.cutime + time.cstime
|
214
|
-
end
|
215
|
-
|
216
|
-
#######################
|
217
|
-
### Private methods ###
|
218
|
-
#######################
|
219
|
-
|
220
|
-
private
|
221
|
-
|
222
|
-
def queries
|
223
|
-
data[:queries]
|
224
|
-
end
|
225
|
-
|
226
|
-
def views
|
227
|
-
data[:views]
|
228
|
-
end
|
229
|
-
|
230
|
-
def job
|
231
|
-
data
|
232
|
-
end
|
233
|
-
|
234
|
-
def request
|
235
|
-
data
|
236
|
-
end
|
237
|
-
|
238
|
-
def data
|
239
|
-
@data[Thread.current.object_id] ||= {}
|
240
|
-
end
|
241
|
-
|
242
|
-
def cleanup_data
|
243
|
-
@data.delete(Thread.current.object_id)
|
244
|
-
end
|
245
|
-
|
246
|
-
MEANINGLESS_QUERIES = %w[BEGIN COMMIT].freeze
|
247
|
-
|
248
|
-
def push_query(query)
|
249
|
-
hash = queries.find { |hash| hash[:line] == query[:line] && hash[:file] == query[:file] }
|
250
|
-
queries << hash = {file: query[:file], line: query[:line], runtime: 0, times: 0} if !hash
|
251
|
-
hash[:runtime] += query[:runtime]
|
252
|
-
if !MEANINGLESS_QUERIES.include?(query[:sql])
|
253
|
-
hash[:times] += 1
|
254
|
-
hash[:sql] ||= query[:sql]
|
255
|
-
hash[:plan] ||= query[:plan] if query[:plan]
|
256
|
-
end
|
257
|
-
end
|
258
|
-
|
259
|
-
def slowest_views
|
260
|
-
views.values.sort { |h1, h2| h2[:runtime] <=> h1[:runtime] }[0, 25]
|
261
|
-
end
|
262
|
-
|
263
|
-
def slowest_queries
|
264
|
-
queries.sort { |h1, h2| h2[:runtime] <=> h1[:runtime] }[0, 25]
|
265
|
-
end
|
266
|
-
|
267
|
-
SELECT_REGEX = /\Aselect/i.freeze
|
268
|
-
|
269
|
-
def explain(sql, binds)
|
270
|
-
ActiveRecord::Base.connection.explain(sql, binds) if sql =~ SELECT_REGEX
|
271
|
-
end
|
272
|
-
|
273
|
-
def post_request
|
274
|
-
attributes = request.merge(queries: slowest_queries, views: slowest_views)
|
275
|
-
post_async("/requests".freeze, request: attributes)
|
276
|
-
ensure
|
277
|
-
cleanup_data
|
278
|
-
end
|
279
|
-
|
280
|
-
def post_job
|
281
|
-
attributes = job.merge(queries: slowest_queries)
|
282
|
-
post_async("/jobs".freeze, job: attributes)
|
283
|
-
rescue => exception
|
284
|
-
log_error(exception)
|
285
|
-
ensure
|
286
|
-
cleanup_data
|
287
|
-
end
|
288
|
-
|
289
|
-
def post_error(hash)
|
290
|
-
post_async("/errors".freeze, error: hash)
|
291
|
-
end
|
292
|
-
|
293
|
-
def gem_home
|
294
|
-
if ENV["GEM_HOME"] && !ENV["GEM_HOME"].empty?
|
295
|
-
ENV["GEM_HOME"]
|
296
|
-
elsif ENV["GEM_PATH"] && !(first_gem_path = ENV["GEM_PATH"].split(":").first)
|
297
|
-
first_gem_path if first_gem_path && !first_gem_path.empty?
|
298
|
-
end
|
299
|
-
end
|
300
|
-
|
301
|
-
def gem_home_regex
|
302
|
-
@gem_home_regex ||= gem_home ? /\A#{gem_home}/.freeze : /\/gems\//.freeze
|
303
|
-
end
|
304
|
-
|
305
|
-
def extract_most_relevant_location(stack)
|
306
|
-
location = stack.find { |str| str =~ app_root_regex && !(str =~ gem_home_regex) } if app_root_regex
|
307
|
-
location ||= stack.find { |str| !(str =~ gem_home_regex) } if gem_home_regex
|
308
|
-
split_file_location(relative_path(location || stack.first))
|
309
|
-
end
|
310
|
-
|
311
|
-
def split_file_location(location)
|
312
|
-
file, line, method = location.split(":")
|
313
|
-
method = cleanup_method_name(method) if method
|
314
|
-
[file, line, method]
|
315
|
-
end
|
316
|
-
|
317
|
-
def cleanup_method_name(method)
|
318
|
-
method.sub!("block in ".freeze, "".freeze)
|
319
|
-
method.sub!("in `".freeze, "".freeze)
|
320
|
-
method.sub!("'".freeze, "".freeze)
|
321
|
-
method.index("_app_views_".freeze) == 0 ? nil : method
|
322
|
-
end
|
323
|
-
|
324
|
-
def compute_duration(start, finish)
|
325
|
-
((finish - start) * 1000)
|
326
|
-
end
|
327
|
-
|
328
|
-
def relative_path(path)
|
329
|
-
app_root_regex ? path.sub(app_root_regex, "".freeze) : path
|
330
|
-
end
|
331
|
-
|
332
|
-
def exception_to_hash(exception, extra_details = nil)
|
333
|
-
file, line, method = extract_most_relevant_location(exception.backtrace)
|
334
|
-
{
|
335
|
-
method: method,
|
336
|
-
line: line.to_i,
|
337
|
-
file: relative_path(file),
|
338
|
-
message: exception.message,
|
339
|
-
backtrace: exception.backtrace,
|
340
|
-
exception: exception.class.to_s,
|
341
|
-
extra_details: extra_details,
|
342
|
-
}
|
343
|
-
end
|
344
|
-
|
345
|
-
HTTPS = "https".freeze
|
346
|
-
CERTIFICATE_AUTHORITIES_PATH = File.expand_path("../../cacert.pem", __FILE__)
|
347
|
-
|
348
|
-
def post(path, data)
|
349
|
-
uri = URI(api_url + path)
|
350
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
351
|
-
|
352
|
-
if uri.scheme == HTTPS
|
353
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
354
|
-
http.ca_file = CERTIFICATE_AUTHORITIES_PATH
|
355
|
-
http.use_ssl = true
|
356
|
-
end
|
357
|
-
|
358
|
-
post = Net::HTTP::Post.new(uri.path)
|
359
|
-
post.content_type = "application/json".freeze
|
360
|
-
post.basic_auth(app_id, api_key)
|
361
|
-
post.body = data.to_json
|
362
|
-
http.request(post)
|
363
|
-
end
|
364
|
-
|
365
|
-
def post_async(path, data)
|
366
|
-
Thread.new do
|
367
|
-
begin
|
368
|
-
threads.add(Thread.current)
|
369
|
-
post(path, data)
|
370
|
-
ensure
|
371
|
-
threads.delete(Thread.current)
|
372
|
-
end
|
373
|
-
end
|
374
|
-
end
|
375
|
-
|
376
|
-
def at_exit
|
377
|
-
threads.each(&:join)
|
378
|
-
end
|
379
|
-
|
380
|
-
def filter_sensitive_data(hash)
|
381
|
-
@parameter_filter ? @parameter_filter.filter(hash) : hash
|
382
|
-
end
|
383
|
-
|
384
|
-
def filter_environment_variables(hash)
|
385
|
-
hash.clone.keep_if { |key,value| key == key.upcase }
|
386
|
-
end
|
387
|
-
|
388
|
-
def ignored_exception?(exception)
|
389
|
-
ignored_exceptions.include?(exception.class.to_s)
|
390
|
-
end
|
391
|
-
|
392
|
-
def log_error(exception)
|
393
|
-
@logger.error("[RorVsWild] " + exception.inspect)
|
394
|
-
@logger.error("[RorVsWild] " + exception.backtrace.join("\n[RorVsWild] "))
|
395
|
-
end
|
21
|
+
client.record_error(exception, extra_details) if client
|
396
22
|
end
|
397
23
|
|
398
|
-
|
399
|
-
|
400
|
-
RorVsWild.measure_block(to_s, &block)
|
401
|
-
end
|
24
|
+
def self.register_client(client)
|
25
|
+
@client = client
|
402
26
|
end
|
403
27
|
|
404
|
-
|
405
|
-
|
406
|
-
RorVsWild.measure_block(item["wrapped".freeze] || item["class".freeze], &block)
|
407
|
-
end
|
28
|
+
def self.client
|
29
|
+
@client
|
408
30
|
end
|
409
31
|
end
|
410
32
|
|
411
|
-
|
33
|
+
if defined?(Rails)
|
34
|
+
require "rorvswild/rails_loader"
|
35
|
+
RorVsWild::RailsLoader.start_on_rails_initialization
|
36
|
+
end
|
data/test/ror_vs_wild_test.rb
CHANGED
@@ -35,13 +35,13 @@ class RorVsWildTest < Minitest::Test
|
|
35
35
|
end
|
36
36
|
|
37
37
|
def test_measure_code_when_no_client
|
38
|
-
RorVsWild.
|
38
|
+
RorVsWild.register_client(nil)
|
39
39
|
RorVsWild::Client.any_instance.expects(:post_job).never
|
40
40
|
assert_equal(2, RorVsWild.measure_code("1+1"))
|
41
41
|
end
|
42
42
|
|
43
43
|
def test_measure_block_when_no_client
|
44
|
-
RorVsWild.
|
44
|
+
RorVsWild.register_client(nil)
|
45
45
|
RorVsWild::Client.any_instance.expects(:post_job).never
|
46
46
|
assert_equal(2, RorVsWild.measure_block("1+1") { 1+1 })
|
47
47
|
end
|
@@ -73,25 +73,25 @@ class RorVsWildTest < Minitest::Test
|
|
73
73
|
|
74
74
|
def test_extract_most_relevant_location
|
75
75
|
callstack = ["#{ENV["GEM_HOME"]}/lib/sql.rb:1:in `method1'", "/usr/lib/ruby/net/http.rb:2:in `method2'", "/rails/root/app/models/user.rb:3:in `method3'"]
|
76
|
-
assert_equal(%w[/app/models/user.rb 3 method3], client.
|
76
|
+
assert_equal(%w[/app/models/user.rb 3 method3], client.extract_most_relevant_location(callstack))
|
77
77
|
|
78
|
-
assert_equal(["#{ENV["GEM_HOME"]}/lib/sql.rb", "1", "method1"], client.
|
78
|
+
assert_equal(["#{ENV["GEM_HOME"]}/lib/sql.rb", "1", "method1"], client.extract_most_relevant_location(["#{ENV["GEM_HOME"]}/lib/sql.rb:1:in `method1'"]))
|
79
79
|
end
|
80
80
|
|
81
81
|
def test_extract_most_relevant_location_when_there_is_not_app_root
|
82
82
|
client = initialize_client
|
83
83
|
callstack = ["#{ENV["GEM_HOME"]}/lib/sql.rb:1:in `method1'", "/usr/lib/ruby/net/http.rb:2:in `method2'", "/rails/root/app/models/user.rb:3:in `method3'"]
|
84
|
-
assert_equal(%w[/usr/lib/ruby/net/http.rb 2 method2], client.
|
84
|
+
assert_equal(%w[/usr/lib/ruby/net/http.rb 2 method2], client.extract_most_relevant_location(callstack))
|
85
85
|
end
|
86
86
|
|
87
87
|
def test_extract_most_relevant_location_when_there_is_no_method_name
|
88
|
-
assert_equal(["/foo/bar.rb", "123", nil], client.
|
88
|
+
assert_equal(["/foo/bar.rb", "123", nil], client.extract_most_relevant_location(["/foo/bar.rb:123"]))
|
89
89
|
end
|
90
90
|
|
91
91
|
def test_extract_most_relevant_location_when_gem_home_is_in_heroku_app_root
|
92
92
|
client = initialize_client(app_root: app_root = File.dirname(gem_home = ENV["GEM_HOME"]))
|
93
93
|
callstack = ["#{gem_home}/lib/sql.rb:1:in `method1'", "/usr/lib/ruby/net/http.rb:2:in `method2'", "#{app_root}/app/models/user.rb:3:in `method3'"]
|
94
|
-
assert_equal(["/app/models/user.rb", "3", "method3"], client.
|
94
|
+
assert_equal(["/app/models/user.rb", "3", "method3"], client.extract_most_relevant_location(callstack))
|
95
95
|
end
|
96
96
|
|
97
97
|
def test_extract_most_relevant_location_when_gem_path_is_set_instead_of_gem_home
|
@@ -99,7 +99,7 @@ class RorVsWildTest < Minitest::Test
|
|
99
99
|
ENV["GEM_HOME"], ENV["GEM_PATH"] = "", "/gem/path"
|
100
100
|
|
101
101
|
callstack = ["/gem/path/lib/sql.rb:1:in `method1'", "/usr/lib/ruby/net/http.rb:2:in `method2'", "/rails/root/app/models/user.rb:3:in `method3'"]
|
102
|
-
assert_equal(%w[/app/models/user.rb 3 method3], client.
|
102
|
+
assert_equal(%w[/app/models/user.rb 3 method3], client.extract_most_relevant_location(callstack))
|
103
103
|
ensure
|
104
104
|
ENV["GEM_HOME"], ENV["GEM_PATH"] = original_gem_home, original_gem_path
|
105
105
|
end
|
@@ -109,7 +109,7 @@ class RorVsWildTest < Minitest::Test
|
|
109
109
|
ENV["GEM_HOME"], ENV["GEM_PATH"] = "", ""
|
110
110
|
|
111
111
|
callstack = ["/gem/path/lib/sql.rb:1:in `method1'", "/usr/lib/ruby/net/http.rb:2:in `method2'", "/rails/root/app/models/user.rb:3:in `method3'"]
|
112
|
-
assert_equal(%w[/app/models/user.rb 3 method3], client.
|
112
|
+
assert_equal(%w[/app/models/user.rb 3 method3], client.extract_most_relevant_location(callstack))
|
113
113
|
ensure
|
114
114
|
ENV["GEM_HOME"], ENV["GEM_PATH"] = original_gem_home, original_gem_path
|
115
115
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rorvswild
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Alexis Bernard
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2017-02-
|
11
|
+
date: 2017-02-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -27,7 +27,8 @@ dependencies:
|
|
27
27
|
description: Performances & quality insights for rails developers.
|
28
28
|
email:
|
29
29
|
- alexis@bernard.io
|
30
|
-
executables:
|
30
|
+
executables:
|
31
|
+
- rorvswild-install
|
31
32
|
extensions: []
|
32
33
|
extra_rdoc_files: []
|
33
34
|
files:
|
@@ -36,8 +37,15 @@ files:
|
|
36
37
|
- LICENSE.txt
|
37
38
|
- README.md
|
38
39
|
- Rakefile
|
40
|
+
- bin/rorvswild-install
|
39
41
|
- cacert.pem
|
40
42
|
- lib/rorvswild.rb
|
43
|
+
- lib/rorvswild/client.rb
|
44
|
+
- lib/rorvswild/installer.rb
|
45
|
+
- lib/rorvswild/location.rb
|
46
|
+
- lib/rorvswild/plugin/resque.rb
|
47
|
+
- lib/rorvswild/plugin/sidekiq.rb
|
48
|
+
- lib/rorvswild/rails_loader.rb
|
41
49
|
- lib/rorvswild/version.rb
|
42
50
|
- rorvswild.gemspec
|
43
51
|
- test/ror_vs_wild_test.rb
|