spartan_apm 0.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +55 -0
  5. data/VERSION +1 -0
  6. data/app/assets/flatpickr-4.6.9/LICENSE.md +21 -0
  7. data/app/assets/flatpickr-4.6.9/flatpickr.min.css +13 -0
  8. data/app/assets/flatpickr-4.6.9/flatpickr.min.js +2 -0
  9. data/app/assets/nice-select2-2.0.0/LICENSE +21 -0
  10. data/app/assets/nice-select2-2.0.0/nice-select2.min.css +1 -0
  11. data/app/assets/nice-select2-2.0.0/nice-select2.min.js +1 -0
  12. data/app/assets/spartan.svg +5 -0
  13. data/app/views/_help.html.erb +147 -0
  14. data/app/views/index.html.erb +231 -0
  15. data/app/views/scripts.js +911 -0
  16. data/app/views/styles.css +332 -0
  17. data/config.ru +36 -0
  18. data/lib/spartan_apm/engine.rb +45 -0
  19. data/lib/spartan_apm/error_info.rb +17 -0
  20. data/lib/spartan_apm/instrumentation/active_record.rb +13 -0
  21. data/lib/spartan_apm/instrumentation/base.rb +36 -0
  22. data/lib/spartan_apm/instrumentation/bunny.rb +24 -0
  23. data/lib/spartan_apm/instrumentation/cassandra.rb +13 -0
  24. data/lib/spartan_apm/instrumentation/curb.rb +13 -0
  25. data/lib/spartan_apm/instrumentation/dalli.rb +13 -0
  26. data/lib/spartan_apm/instrumentation/elasticsearch.rb +18 -0
  27. data/lib/spartan_apm/instrumentation/excon.rb +13 -0
  28. data/lib/spartan_apm/instrumentation/http.rb +13 -0
  29. data/lib/spartan_apm/instrumentation/httpclient.rb +13 -0
  30. data/lib/spartan_apm/instrumentation/net_http.rb +13 -0
  31. data/lib/spartan_apm/instrumentation/redis.rb +13 -0
  32. data/lib/spartan_apm/instrumentation/typhoeus.rb +13 -0
  33. data/lib/spartan_apm/instrumentation.rb +71 -0
  34. data/lib/spartan_apm/measure.rb +172 -0
  35. data/lib/spartan_apm/metric.rb +26 -0
  36. data/lib/spartan_apm/middleware/rack/end_middleware.rb +29 -0
  37. data/lib/spartan_apm/middleware/rack/start_middleware.rb +57 -0
  38. data/lib/spartan_apm/middleware/sidekiq/end_middleware.rb +25 -0
  39. data/lib/spartan_apm/middleware/sidekiq/start_middleware.rb +34 -0
  40. data/lib/spartan_apm/middleware.rb +16 -0
  41. data/lib/spartan_apm/persistence.rb +648 -0
  42. data/lib/spartan_apm/report.rb +436 -0
  43. data/lib/spartan_apm/string_cache.rb +27 -0
  44. data/lib/spartan_apm/web/api_request.rb +133 -0
  45. data/lib/spartan_apm/web/helpers.rb +88 -0
  46. data/lib/spartan_apm/web/router.rb +90 -0
  47. data/lib/spartan_apm/web.rb +10 -0
  48. data/lib/spartan_apm.rb +399 -0
  49. data/spartan_apm.gemspec +39 -0
  50. 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"