rorvswild 0.4.1 → 0.5.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 +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
|