spartan_apm 0.0.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/MIT-LICENSE +20 -0
- data/README.md +55 -0
- data/VERSION +1 -0
- data/app/assets/flatpickr-4.6.9/LICENSE.md +21 -0
- data/app/assets/flatpickr-4.6.9/flatpickr.min.css +13 -0
- data/app/assets/flatpickr-4.6.9/flatpickr.min.js +2 -0
- data/app/assets/nice-select2-2.0.0/LICENSE +21 -0
- data/app/assets/nice-select2-2.0.0/nice-select2.min.css +1 -0
- data/app/assets/nice-select2-2.0.0/nice-select2.min.js +1 -0
- data/app/assets/spartan.svg +5 -0
- data/app/views/_help.html.erb +147 -0
- data/app/views/index.html.erb +231 -0
- data/app/views/scripts.js +911 -0
- data/app/views/styles.css +332 -0
- data/config.ru +36 -0
- data/lib/spartan_apm/engine.rb +45 -0
- data/lib/spartan_apm/error_info.rb +17 -0
- data/lib/spartan_apm/instrumentation/active_record.rb +13 -0
- data/lib/spartan_apm/instrumentation/base.rb +36 -0
- data/lib/spartan_apm/instrumentation/bunny.rb +24 -0
- data/lib/spartan_apm/instrumentation/cassandra.rb +13 -0
- data/lib/spartan_apm/instrumentation/curb.rb +13 -0
- data/lib/spartan_apm/instrumentation/dalli.rb +13 -0
- data/lib/spartan_apm/instrumentation/elasticsearch.rb +18 -0
- data/lib/spartan_apm/instrumentation/excon.rb +13 -0
- data/lib/spartan_apm/instrumentation/http.rb +13 -0
- data/lib/spartan_apm/instrumentation/httpclient.rb +13 -0
- data/lib/spartan_apm/instrumentation/net_http.rb +13 -0
- data/lib/spartan_apm/instrumentation/redis.rb +13 -0
- data/lib/spartan_apm/instrumentation/typhoeus.rb +13 -0
- data/lib/spartan_apm/instrumentation.rb +71 -0
- data/lib/spartan_apm/measure.rb +172 -0
- data/lib/spartan_apm/metric.rb +26 -0
- data/lib/spartan_apm/middleware/rack/end_middleware.rb +29 -0
- data/lib/spartan_apm/middleware/rack/start_middleware.rb +57 -0
- data/lib/spartan_apm/middleware/sidekiq/end_middleware.rb +25 -0
- data/lib/spartan_apm/middleware/sidekiq/start_middleware.rb +34 -0
- data/lib/spartan_apm/middleware.rb +16 -0
- data/lib/spartan_apm/persistence.rb +648 -0
- data/lib/spartan_apm/report.rb +436 -0
- data/lib/spartan_apm/string_cache.rb +27 -0
- data/lib/spartan_apm/web/api_request.rb +133 -0
- data/lib/spartan_apm/web/helpers.rb +88 -0
- data/lib/spartan_apm/web/router.rb +90 -0
- data/lib/spartan_apm/web.rb +10 -0
- data/lib/spartan_apm.rb +399 -0
- data/spartan_apm.gemspec +39 -0
- metadata +161 -0
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpartanAPM
|
4
|
+
# Code for instrumenting other classes with capture blocks to capture how long
|
5
|
+
# calling specified methods take.
|
6
|
+
module Instrumentation
|
7
|
+
class << self
|
8
|
+
# Apply all the bundled instrumenters.
|
9
|
+
def auto_instrument!
|
10
|
+
ActiveRecord.new.tap { |instance| instance.instrument! if instance.valid? }
|
11
|
+
Bunny.new.tap { |instance| instance.instrument! if instance.valid? }
|
12
|
+
Cassandra.new.tap { |instance| instance.instrument! if instance.valid? }
|
13
|
+
Curb.new.tap { |instance| instance.instrument! if instance.valid? }
|
14
|
+
Dalli.new.tap { |instance| instance.instrument! if instance.valid? }
|
15
|
+
Elasticsearch.new.tap { |instance| instance.instrument! if instance.valid? }
|
16
|
+
Excon.new.tap { |instance| instance.instrument! if instance.valid? }
|
17
|
+
HTTPClient.new.tap { |instance| instance.instrument! if instance.valid? }
|
18
|
+
HTTP.new.tap { |instance| instance.instrument! if instance.valid? }
|
19
|
+
NetHTTP.new.tap { |instance| instance.instrument! if instance.valid? }
|
20
|
+
Redis.new.tap { |instance| instance.instrument! if instance.valid? }
|
21
|
+
Typhoeus.new.tap { |instance| instance.instrument! if instance.valid? }
|
22
|
+
end
|
23
|
+
|
24
|
+
# Instrument a class by surrounding specified instance methods with capture blocks.
|
25
|
+
def instrument!(klass, name, methods, exclusive: false, module_name: nil)
|
26
|
+
# Create a module that will be prepended to the specified class.
|
27
|
+
unless module_name
|
28
|
+
camelized_name = name.to_s.gsub(/[^a-z0-9]+([a-z0-9])/i) { |m| m[m.length - 1, m.length].upcase }
|
29
|
+
camelized_name = "#{camelized_name[0].upcase}#{camelized_name[1, camelized_name.length]}"
|
30
|
+
module_name = "#{klass.name.split("::").join}#{camelized_name}Instrumentation"
|
31
|
+
end
|
32
|
+
if const_defined?(module_name)
|
33
|
+
raise ArgumentError.new("#{name} has alrady been instrumented in #{klass.name}")
|
34
|
+
end
|
35
|
+
|
36
|
+
# The method of overriding kwargs changed in ruby 2.7
|
37
|
+
ruby_major, ruby_minor, _ = RUBY_VERSION.split(".").collect(&:to_i)
|
38
|
+
ruby_3_args = (ruby_major >= 3 || (ruby_major == 2 && ruby_minor >= 7))
|
39
|
+
splat_args = (ruby_3_args ? "..." : "*args, &block")
|
40
|
+
|
41
|
+
# Dark arts & witchery to dynamically generate the module methods.
|
42
|
+
instrumentation_module = const_set(module_name, Module.new)
|
43
|
+
Array(methods).each do |method_name|
|
44
|
+
instrumentation_module.class_eval <<~RUBY, __FILE__, __LINE__ + 1
|
45
|
+
def #{method_name}(#{splat_args})
|
46
|
+
SpartanAPM.capture(#{name.to_sym.inspect}, exclusive: #{exclusive.inspect}) do
|
47
|
+
super(#{splat_args})
|
48
|
+
end
|
49
|
+
end
|
50
|
+
RUBY
|
51
|
+
end
|
52
|
+
|
53
|
+
klass.prepend(instrumentation_module)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
require_relative "instrumentation/base"
|
60
|
+
require_relative "instrumentation/active_record"
|
61
|
+
require_relative "instrumentation/bunny"
|
62
|
+
require_relative "instrumentation/cassandra"
|
63
|
+
require_relative "instrumentation/curb"
|
64
|
+
require_relative "instrumentation/dalli"
|
65
|
+
require_relative "instrumentation/elasticsearch"
|
66
|
+
require_relative "instrumentation/excon"
|
67
|
+
require_relative "instrumentation/httpclient"
|
68
|
+
require_relative "instrumentation/http"
|
69
|
+
require_relative "instrumentation/net_http"
|
70
|
+
require_relative "instrumentation/redis"
|
71
|
+
require_relative "instrumentation/typhoeus"
|
@@ -0,0 +1,172 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpartanAPM
|
4
|
+
# This class holds the metrics captured during a request. Metrics are captured
|
5
|
+
# in one minute increments and then written to Redis.
|
6
|
+
class Measure
|
7
|
+
attr_reader :app, :action, :timers, :counts, :error, :error_message, :error_backtrace
|
8
|
+
|
9
|
+
@mutex = Mutex.new
|
10
|
+
@current_measures = nil
|
11
|
+
@last_bucket = nil
|
12
|
+
@string_cache = StringCache.new
|
13
|
+
@error_cache = Concurrent::Map.new
|
14
|
+
|
15
|
+
class << self
|
16
|
+
# Get the list of Measures stored for the current minute time bucket. Every minute
|
17
|
+
# this list is started anew. This method will also automatically kick off the process
|
18
|
+
# to persist Measures from the previous time bucket to Redis if necessary.
|
19
|
+
# @return [Concurrent::Array<Measure>]
|
20
|
+
def current_measures
|
21
|
+
bucket = SpartanAPM.bucket(Time.now)
|
22
|
+
last_bucket = @last_bucket
|
23
|
+
if bucket != last_bucket
|
24
|
+
persist_measures = nil
|
25
|
+
@mutex.synchronize do
|
26
|
+
# This check is made again within the mutex block so that we don't have
|
27
|
+
# to lock the mutex every time we make the check if the bucket has changed.
|
28
|
+
if bucket != @last_bucket
|
29
|
+
persist_measures = @current_measures
|
30
|
+
@last_bucket = bucket
|
31
|
+
@current_measures = Concurrent::Array.new
|
32
|
+
@string_cache = StringCache.new
|
33
|
+
@error_cache = {}
|
34
|
+
end
|
35
|
+
end
|
36
|
+
if persist_measures && !persist_measures.empty?
|
37
|
+
if SpartanAPM.persist_asynchronously?
|
38
|
+
Thread.new { Persistence.store!(last_bucket, persist_measures) }
|
39
|
+
else
|
40
|
+
Persistence.store!(last_bucket, persist_measures)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
@current_measures
|
46
|
+
end
|
47
|
+
|
48
|
+
# @api private
|
49
|
+
# Used for consistency in test cases.
|
50
|
+
def clear_current_measures!
|
51
|
+
@mutex.synchronize do
|
52
|
+
@current_measures = nil
|
53
|
+
@last_bucket = nil
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Flush all currently enqueued Measures to Redis.
|
58
|
+
def flush
|
59
|
+
return if @last_bucket.nil?
|
60
|
+
bucket = nil
|
61
|
+
measures = nil
|
62
|
+
@mutex.synchronize do
|
63
|
+
bucket = @last_bucket
|
64
|
+
measures = @current_measures
|
65
|
+
@current_measures = Concurrent::Array.new
|
66
|
+
@string_cache = StringCache.new
|
67
|
+
@error_cache = {}
|
68
|
+
end
|
69
|
+
unless measures.empty?
|
70
|
+
Persistence.store!(bucket, measures)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# @api private
|
75
|
+
#
|
76
|
+
# Fetch error information from a cache. The cache is used since backtraces
|
77
|
+
# can be quite long and, if the application got into a bad state, a lot of
|
78
|
+
# errors could be generated in a short time. The cache is here to prevent
|
79
|
+
# memory bloat if that happens by only storing one copy of each error trace.
|
80
|
+
#
|
81
|
+
# There is also a hard limit of 1000 distinct errors at a time. This is a
|
82
|
+
# protection in case errors end up with dynamically generated traces so that
|
83
|
+
# don't use up all the memory. If your application gets over 1000 distinct
|
84
|
+
# errors in a minute, seeing a truncated list of them is the least of your
|
85
|
+
# worries.
|
86
|
+
def error_cache_fetch(error)
|
87
|
+
return nil unless error
|
88
|
+
backtrace = SpartanAPM.clean_backtrace(error.backtrace)
|
89
|
+
error_key = Digest::MD5.hexdigest("#{error.class.name} #{backtrace&.join}")
|
90
|
+
cached_error = @error_cache[error_key]
|
91
|
+
unless cached_error
|
92
|
+
return nil if @error_cache.size > 1000
|
93
|
+
cached_error = [error.class.name, error.message, backtrace]
|
94
|
+
@error_cache[error_key] = cached_error
|
95
|
+
end
|
96
|
+
cached_error
|
97
|
+
end
|
98
|
+
|
99
|
+
attr_reader :string_cache
|
100
|
+
end
|
101
|
+
|
102
|
+
def initialize(app, action = nil)
|
103
|
+
@app = self.class.string_cache.fetch(app)
|
104
|
+
@action = self.class.string_cache.fetch(action) if action
|
105
|
+
@timers = Hash.new(0.0)
|
106
|
+
@counts = Hash.new(0)
|
107
|
+
@current_name = nil
|
108
|
+
@current_start_time = nil
|
109
|
+
@current_exclusive = false
|
110
|
+
end
|
111
|
+
|
112
|
+
def action=(value)
|
113
|
+
@action = self.class.string_cache.fetch(value)
|
114
|
+
end
|
115
|
+
|
116
|
+
def app=(value)
|
117
|
+
@app = self.class.string_cache.fetch(value)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Capture the timing for a component. See SpartanAPM#capture for more info.
|
121
|
+
def capture(name, exclusive: false)
|
122
|
+
name = name.to_sym
|
123
|
+
if @current_exclusive
|
124
|
+
# Already capturing from within an exclusive block, so don't interrupt that capture.
|
125
|
+
yield
|
126
|
+
else
|
127
|
+
start_time = Time.now
|
128
|
+
restore_name = @current_name
|
129
|
+
if restore_name
|
130
|
+
@timers[restore_name] += start_time - @current_start_time
|
131
|
+
end
|
132
|
+
@current_name = name
|
133
|
+
@current_start_time = start_time
|
134
|
+
@current_exclusive = exclusive
|
135
|
+
begin
|
136
|
+
yield
|
137
|
+
ensure
|
138
|
+
end_time = Time.now
|
139
|
+
@timers[name] += end_time - @current_start_time
|
140
|
+
@counts[name] += 1
|
141
|
+
if restore_name
|
142
|
+
@current_name = restore_name
|
143
|
+
@current_start_time = end_time
|
144
|
+
else
|
145
|
+
@current_name = nil
|
146
|
+
@current_start_time = nil
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
# Capture the timing for a component. See SpartanAPM#capture_time for more info.
|
153
|
+
def capture_time(name, elapsed_time)
|
154
|
+
name = name.to_sym
|
155
|
+
@timers[name] += elapsed_time.to_f
|
156
|
+
@counts[name] += 1
|
157
|
+
end
|
158
|
+
|
159
|
+
# Capture an error. See SpartanAPM#capture_error for more info.
|
160
|
+
def capture_error(error)
|
161
|
+
@error, @error_message, @error_backtrace = self.class.error_cache_fetch(error)
|
162
|
+
end
|
163
|
+
|
164
|
+
# This method must be called to add the Measure to the measures for
|
165
|
+
# the current bucket.
|
166
|
+
def record!
|
167
|
+
if (action && !@timers.empty?) || error
|
168
|
+
self.class.current_measures << self
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpartanAPM
|
4
|
+
# Data structure for information about the request metrics for a particular time.
|
5
|
+
class Metric
|
6
|
+
attr_reader :time
|
7
|
+
attr_accessor :count, :avg, :p50, :p90, :p99, :error_count, :components
|
8
|
+
|
9
|
+
def initialize(time)
|
10
|
+
@time = time
|
11
|
+
@components = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def component_names
|
15
|
+
@components.keys.collect { |n| n.to_s.freeze }
|
16
|
+
end
|
17
|
+
|
18
|
+
def component_request_time(name)
|
19
|
+
Array(@components[name.to_s])[0]
|
20
|
+
end
|
21
|
+
|
22
|
+
def component_request_count(name)
|
23
|
+
Array(@components[name.to_s])[1]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpartanAPM
|
4
|
+
module Middleware
|
5
|
+
module Rack
|
6
|
+
# Middleware that should be added of the end of the middleware chain.
|
7
|
+
class EndMiddleware
|
8
|
+
def initialize(app)
|
9
|
+
@app = app
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(env)
|
13
|
+
start_time = Time.now
|
14
|
+
SpartanAPM.capture(:app) do
|
15
|
+
begin
|
16
|
+
@app.call(env)
|
17
|
+
ensure
|
18
|
+
# Capture how much time was spent in middleware.
|
19
|
+
middleware_start_time = env["spartan_apm.middleware_start_time"]
|
20
|
+
if middleware_start_time
|
21
|
+
SpartanAPM.capture_time(:middleware, start_time.to_f - middleware_start_time)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpartanAPM
|
4
|
+
module Middleware
|
5
|
+
module Rack
|
6
|
+
# Middleware that should be added to the start of the start of the middleware chain.
|
7
|
+
class StartMiddleware
|
8
|
+
def initialize(app)
|
9
|
+
@app = app
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(env)
|
13
|
+
if SpartanAPM.ignore_request?("web", env["PATH_INFO"])
|
14
|
+
@app.call(env)
|
15
|
+
else
|
16
|
+
start_time = Time.now.to_f
|
17
|
+
|
18
|
+
# This value is used in EndMiddleware to capture how long all the middleware
|
19
|
+
# between the two middlewares took to execute.
|
20
|
+
env["spartan_apm.middleware_start_time"] = start_time
|
21
|
+
|
22
|
+
SpartanAPM.measure("web") do
|
23
|
+
begin
|
24
|
+
@app.call(env)
|
25
|
+
ensure
|
26
|
+
# Record how long the web request was enqueued before the Rack server
|
27
|
+
# got the request if that information is available.
|
28
|
+
enqueued_at_time = request_queue_start_time(env)
|
29
|
+
if enqueued_at_time && start_time > enqueued_at_time
|
30
|
+
SpartanAPM.capture_time(:queue, start_time - enqueued_at_time)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def request_queue_start_time(env)
|
40
|
+
# There's a few of differing conventions on where web servers record the
|
41
|
+
# start time on a request in the proxied HTTP headers.
|
42
|
+
header = (env["HTTP_X_REQUEST_START"] || env["HTTP_X_QUEUE_START"])
|
43
|
+
start_time = nil
|
44
|
+
if header
|
45
|
+
header = header[2, header.size] if header.start_with?("t=")
|
46
|
+
t = header.to_f
|
47
|
+
# Header could be in seconds, milliseconds, or microseconds
|
48
|
+
t /= 1000.0 if t > 5_000_000_000
|
49
|
+
t /= 1000.0 if t > 5_000_000_000
|
50
|
+
start_time = t if t > 0
|
51
|
+
end
|
52
|
+
start_time
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpartanAPM
|
4
|
+
module Middleware
|
5
|
+
module Sidekiq
|
6
|
+
# Middleware that should be added of the end of the middleware chain.
|
7
|
+
class EndMiddleware
|
8
|
+
def call(worker, msg, queue, &block)
|
9
|
+
start_time = Time.now.to_f
|
10
|
+
SpartanAPM.capture(:app) do
|
11
|
+
begin
|
12
|
+
yield
|
13
|
+
ensure
|
14
|
+
# Capture how much time was spent in middleware.
|
15
|
+
middleware_start_time = msg["spartan_apm.middleware_start_time"]
|
16
|
+
if middleware_start_time
|
17
|
+
SpartanAPM.capture_time(:middleware, start_time - middleware_start_time)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpartanAPM
|
4
|
+
module Middleware
|
5
|
+
module Sidekiq
|
6
|
+
# Middleware that should be added to the start of the start of the middleware chain.
|
7
|
+
class StartMiddleware
|
8
|
+
def call(worker, msg, queue, &block)
|
9
|
+
if SpartanAPM.ignore_request?("sidekiq", worker.class.name)
|
10
|
+
yield
|
11
|
+
else
|
12
|
+
start_time = Time.now.to_f
|
13
|
+
|
14
|
+
# This value is used in EndMiddleware to capture how long all the middleware
|
15
|
+
# between the two middlewares took to execute.
|
16
|
+
msg["spartan_apm.middleware_start_time"] = start_time
|
17
|
+
|
18
|
+
SpartanAPM.measure("sidekiq", worker.class.name) do
|
19
|
+
begin
|
20
|
+
yield
|
21
|
+
ensure
|
22
|
+
# Capture how long the message was enqueued in Redis before a worker got the job.
|
23
|
+
enqueued_time = msg["enqueued_at"].to_f if msg.is_a?(Hash)
|
24
|
+
if enqueued_time && enqueued_time > 0 && start_time > enqueued_time
|
25
|
+
SpartanAPM.capture_time(:queue, start_time - enqueued_time)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpartanAPM
|
4
|
+
module Middleware
|
5
|
+
module Rack
|
6
|
+
end
|
7
|
+
|
8
|
+
module Sidekiq
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
require_relative "middleware/rack/end_middleware"
|
14
|
+
require_relative "middleware/rack/start_middleware"
|
15
|
+
require_relative "middleware/sidekiq/end_middleware"
|
16
|
+
require_relative "middleware/sidekiq/start_middleware"
|