scout_apm 5.7.1 → 6.0.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +2 -0
  3. data/CHANGELOG.markdown +21 -1
  4. data/README.markdown +20 -8
  5. data/gems/instruments.gemfile +1 -0
  6. data/lib/scout_apm/auto_instrument/instruction_sequence.rb +2 -1
  7. data/lib/scout_apm/auto_instrument/parser.rb +150 -2
  8. data/lib/scout_apm/auto_instrument/prism.rb +357 -0
  9. data/lib/scout_apm/auto_instrument/rails.rb +9 -155
  10. data/lib/scout_apm/auto_instrument/requirements.rb +11 -0
  11. data/lib/scout_apm/background_job_integrations/delayed_job.rb +25 -1
  12. data/lib/scout_apm/background_job_integrations/faktory.rb +7 -1
  13. data/lib/scout_apm/background_job_integrations/good_job.rb +7 -1
  14. data/lib/scout_apm/background_job_integrations/legacy_sneakers.rb +7 -1
  15. data/lib/scout_apm/background_job_integrations/que.rb +7 -1
  16. data/lib/scout_apm/background_job_integrations/shoryuken.rb +7 -1
  17. data/lib/scout_apm/background_job_integrations/sidekiq.rb +89 -1
  18. data/lib/scout_apm/background_job_integrations/sneakers.rb +7 -1
  19. data/lib/scout_apm/background_job_integrations/solid_queue.rb +19 -1
  20. data/lib/scout_apm/config.rb +32 -7
  21. data/lib/scout_apm/context.rb +3 -1
  22. data/lib/scout_apm/error_service/error_record.rb +5 -1
  23. data/lib/scout_apm/instrument_manager.rb +2 -0
  24. data/lib/scout_apm/instruments/http_client.rb +10 -0
  25. data/lib/scout_apm/instruments/httpx.rb +119 -0
  26. data/lib/scout_apm/instruments/opensearch.rb +131 -0
  27. data/lib/scout_apm/limited_layer.rb +5 -2
  28. data/lib/scout_apm/logger.rb +1 -1
  29. data/lib/scout_apm/sampling.rb +25 -13
  30. data/lib/scout_apm/server_integrations/puma.rb +21 -4
  31. data/lib/scout_apm/version.rb +1 -1
  32. data/lib/scout_apm.rb +9 -4
  33. data/test/unit/auto_instrument/controller-ast.prism.txt +1015 -0
  34. data/test/unit/auto_instrument/controller-instrumented.rb +36 -11
  35. data/test/unit/auto_instrument/controller.rb +25 -0
  36. data/test/unit/auto_instrument/hash_shorthand_controller-instrumented.rb +28 -10
  37. data/test/unit/auto_instrument/hash_shorthand_controller.rb +19 -1
  38. data/test/unit/auto_instrument_test.rb +7 -1
  39. data/test/unit/background_job_integrations/faktory_test.rb +109 -0
  40. data/test/unit/background_job_integrations/shoryuken_test.rb +81 -0
  41. data/test/unit/background_job_integrations/sidekiq_test.rb +38 -0
  42. data/test/unit/config_test.rb +14 -0
  43. data/test/unit/error_service/error_buffer_test.rb +32 -0
  44. data/test/unit/error_test.rb +3 -3
  45. data/test/unit/ignored_uris_test.rb +7 -0
  46. data/test/unit/instruments/http_client_test.rb +0 -2
  47. data/test/unit/instruments/httpx_test.rb +78 -0
  48. data/test/unit/limited_layer_test.rb +4 -4
  49. data/test/unit/sampling_test.rb +10 -10
  50. metadata +10 -3
  51. data/lib/scout_apm/utils/time.rb +0 -12
  52. /data/test/unit/auto_instrument/{controller-ast.txt → controller-ast.parser.txt} +0 -0
@@ -0,0 +1,119 @@
1
+ module ScoutApm
2
+ module Instruments
3
+ class HTTPX
4
+ attr_reader :context
5
+
6
+ def initialize(context)
7
+ @context = context
8
+ @installed = false
9
+ end
10
+
11
+ def logger
12
+ context.logger
13
+ end
14
+
15
+ def installed?
16
+ @installed
17
+ end
18
+
19
+ def install(prepend:)
20
+ if defined?(::HTTPX) && defined?(::HTTPX::Session)
21
+ @installed = true
22
+
23
+ logger.info "Instrumenting HTTPX"
24
+
25
+ ::HTTPX::Session.send(:prepend, HTTPXInstrumentationPrepend)
26
+ end
27
+ end
28
+
29
+ module HTTPXInstrumentationPrepend
30
+ def request(*args, **params)
31
+ verb, desc = determine_verb_and_desc(*args, **params)
32
+
33
+ layer = ScoutApm::Layer.new("HTTP", verb)
34
+ layer.desc = desc
35
+
36
+ req = ScoutApm::RequestManager.lookup
37
+ req.start_layer(layer)
38
+
39
+ begin
40
+ super(*args, **params)
41
+ ensure
42
+ req.stop_layer
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # See the following for various argument patterns:
49
+ # https://gitlab.com/os85/httpx/-/blob/v1.6.3/lib/httpx/session.rb?ref_type=tags#L87
50
+ def determine_verb_and_desc(*args, **params)
51
+ # Pattern 1: session.request(req1) or session.request(req1, req2, ...)
52
+ if args.first.is_a?(::HTTPX::Request)
53
+ if args.length > 1
54
+ return args.first.verb.to_s.upcase, "#{args.length} requests"
55
+ else
56
+ return args.first.verb.to_s.upcase, scout_url_desc(args.first.uri)
57
+ end
58
+ end
59
+
60
+ # Pattern 2: session.request("GET", "https://server.org/a")
61
+ # Pattern 3: session.request("GET", ["https://server.org/a", "https://server.org/b"])
62
+ # Pattern 4: session.request("POST", ["https://server.org/a"], form: { ... })
63
+ # Pattern 5: session.request("GET", ["https://..."], headers: { ... })
64
+ if args.first.is_a?(String) || args.first.is_a?(Symbol)
65
+ verb = args.first.to_s.upcase
66
+
67
+ if args[1].is_a?(String)
68
+ return verb, scout_url_desc(args[1])
69
+ elsif args[1].is_a?(Array)
70
+ return verb, scout_url_desc(args[1][0]) unless args[1].length > 1
71
+ return verb, "#{args[1].length} requests"
72
+ else
73
+ return verb, ""
74
+ end
75
+ end
76
+
77
+ # Pattern 6: session.request(["GET", "https://..."], ["GET", "https://..."])
78
+ # Pattern 7: session.request(["POST", "https://...", form: {...}], ["GET", "https://..."])
79
+ if args.first.is_a?(Array)
80
+ if args.length > 1
81
+ verb = args.first[0].to_s.upcase rescue "REQUEST"
82
+ return verb, "#{args.length} requests"
83
+ elsif args.first.length >= 2
84
+ verb = args.first[0].to_s.upcase rescue "REQUEST"
85
+ url = args.first[1]
86
+ return verb, scout_url_desc(url)
87
+ end
88
+ end
89
+
90
+ return "REQUEST", ""
91
+ end
92
+
93
+ def scout_url_desc(uri)
94
+ max_length = ScoutApm::Agent.instance.context.config.value('instrument_http_url_length')
95
+ uri_str = uri.to_s
96
+
97
+ # URI object
98
+ if uri.respond_to?(:host) && uri.respond_to?(:path)
99
+ path = uri.path.to_s
100
+ path = "/" if path.empty?
101
+ result = "#{uri.host}#{path.split('?').first}"
102
+ # String URL
103
+ elsif uri_str =~ %r{^https?://([^/]+)(/[^?]*)?}
104
+ host = $1
105
+ path = $2 || "/"
106
+ result = "#{host}#{path}"
107
+ else
108
+ # Fallback
109
+ result = uri_str.split('?').first
110
+ end
111
+
112
+ result[0..(max_length - 1)]
113
+ rescue => e
114
+ ""
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: false
2
+
3
+ module ScoutApm
4
+ module Instruments
5
+ class OpenSearch
6
+ attr_reader :context
7
+
8
+ def initialize(context)
9
+ @context = context
10
+ @installed = false
11
+ end
12
+
13
+ def logger
14
+ context.logger
15
+ end
16
+
17
+ def installed?
18
+ @installed
19
+ end
20
+
21
+ def install(prepend:)
22
+ if defined?(::OpenSearch) &&
23
+ defined?(::OpenSearch::Transport) &&
24
+ defined?(::OpenSearch::Transport::Client)
25
+
26
+ @installed = true
27
+
28
+ logger.info "Instrumenting OpenSearch. Prepend: #{prepend}"
29
+
30
+ if prepend
31
+ ::OpenSearch::Transport::Client.send(:include, ScoutApm::Tracer)
32
+ ::OpenSearch::Transport::Client.send(:prepend, OpenSearchTransportClientInstrumentationPrepend)
33
+ else
34
+ ::OpenSearch::Transport::Client.class_eval do
35
+ include ScoutApm::Tracer
36
+
37
+ def perform_request_with_scout_instruments(*args, &block)
38
+ name = _sanitize_name(args[1])
39
+
40
+ self.class.instrument("OpenSearch", name, :ignore_children => true) do
41
+ perform_request_without_scout_instruments(*args, &block)
42
+ end
43
+ end
44
+
45
+ alias_method :perform_request_without_scout_instruments, :perform_request
46
+ alias_method :perform_request, :perform_request_with_scout_instruments
47
+
48
+ def _sanitize_name(path)
49
+ name = path.split("/").last.gsub(/^_/, '')
50
+ allowed_names = ["bench",
51
+ "bulk",
52
+ "count",
53
+ "exists",
54
+ "explain",
55
+ "field_stats",
56
+ "health",
57
+ "mget",
58
+ "mlt",
59
+ "mpercolate",
60
+ "msearch",
61
+ "mtermvectors",
62
+ "percolate",
63
+ "query",
64
+ "scroll",
65
+ "search_shards",
66
+ "source",
67
+ "suggest",
68
+ "template",
69
+ "termvectors",
70
+ "update",
71
+ "search", ]
72
+
73
+ if allowed_names.include?(name)
74
+ name
75
+ else
76
+ "Unknown"
77
+ end
78
+ rescue
79
+ "Unknown"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ module OpenSearchTransportClientInstrumentationPrepend
88
+ def perform_request(*args, &block)
89
+ name = _sanitize_name(args[1])
90
+
91
+ self.class.instrument("OpenSearch", name, :ignore_children => true) do
92
+ super(*args, &block)
93
+ end
94
+ end
95
+
96
+ def _sanitize_name(path)
97
+ name = path.split("/").last.gsub(/^_/, '')
98
+ allowed_names = ["bench",
99
+ "bulk",
100
+ "count",
101
+ "exists",
102
+ "explain",
103
+ "field_stats",
104
+ "health",
105
+ "mget",
106
+ "mlt",
107
+ "mpercolate",
108
+ "msearch",
109
+ "mtermvectors",
110
+ "percolate",
111
+ "query",
112
+ "scroll",
113
+ "search_shards",
114
+ "source",
115
+ "suggest",
116
+ "template",
117
+ "termvectors",
118
+ "update",
119
+ "search", ]
120
+
121
+ if allowed_names.include?(name)
122
+ name
123
+ else
124
+ "Unknown"
125
+ end
126
+ rescue
127
+ "Unknown"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -21,10 +21,13 @@ module ScoutApm
21
21
  @total_layers += 1
22
22
 
23
23
  @total_call_time += layer.total_call_time
24
- @total_exclusive_time += layer.total_exclusive_time
24
+ # For limited layers, exclusive time should equal total time since limited layers
25
+ # report no children. As such, we need to consider all absorbed time as exclusive.
26
+ @total_exclusive_time += layer.total_call_time
25
27
 
26
28
  @total_allocations += layer.total_allocations
27
- @total_exclusive_allocations += layer.total_exclusive_allocations
29
+ # Same logic applies to allocations
30
+ @total_exclusive_allocations += layer.total_allocations
28
31
  end
29
32
 
30
33
  def total_call_time
@@ -145,7 +145,7 @@ module ScoutApm
145
145
  def call(severity, time, progname, msg)
146
146
  # since STDOUT isn't exclusive like the scout_apm.log file, apply a prefix.
147
147
  # XXX: Pass in context to the formatter
148
- "[#{Utils::Time.to_s(time)} #{ScoutApm::Agent.instance.context.environment.hostname} (#{$$})] #{severity} : #{msg}\n"
148
+ "[#{time.strftime("%m/%d/%y %H:%M:%S %z")} #{ScoutApm::Agent.instance.context.environment.hostname} (#{$$})] #{severity} : #{msg}\n"
149
149
  end
150
150
  end
151
151
 
@@ -8,7 +8,8 @@ module ScoutApm
8
8
  # jobs matched explicitly by name
9
9
 
10
10
  # for now still support old config key ('ignore') for backwards compatibility
11
- @ignore_endpoints = config.value_present?('ignore') ? config.value('ignore') : config.value('ignore_endpoints')
11
+ raw_ignore = config.value_present?('ignore') ? config.value('ignore') : config.value('ignore_endpoints')
12
+ @ignore_endpoints = IgnoredUris.new(raw_ignore) unless raw_ignore.blank?
12
13
  @sample_endpoints = individual_sample_to_hash(config.value('sample_endpoints'))
13
14
  @endpoint_sample_rate = config.value('endpoint_sample_rate')
14
15
 
@@ -34,20 +35,20 @@ module ScoutApm
34
35
  if transaction.job?
35
36
  job_name = transaction.layer_finder.job.name
36
37
  rate = job_sample_rate(job_name)
37
- return sample?(rate) unless rate.nil?
38
+ return downsample?(rate) unless rate.nil?
38
39
  return true if ignore_job?(job_name)
39
- return sample?(@job_sample_rate) unless @job_sample_rate.nil?
40
+ return downsample?(@job_sample_rate) unless @job_sample_rate.nil?
40
41
  elsif transaction.web?
41
42
  uri = transaction.annotations[:uri]
42
43
  rate = web_sample_rate(uri)
43
- return sample?(rate) unless rate.nil?
44
+ return downsample?(rate) unless rate.nil?
44
45
  return true if ignore_uri?(uri)
45
- return sample?(@endpoint_sample_rate) unless @endpoint_sample_rate.nil?
46
+ return downsample?(@endpoint_sample_rate) unless @endpoint_sample_rate.nil?
46
47
  end
47
48
 
48
49
  # global sample check
49
50
  if @global_sample_rate
50
- return sample?(@global_sample_rate)
51
+ return downsample?(@global_sample_rate)
51
52
  end
52
53
 
53
54
  false # don't drop the request
@@ -59,17 +60,14 @@ module ScoutApm
59
60
  sample_hash = {}
60
61
  sampling_config.each do |sample|
61
62
  path, _, rate = sample.rpartition(':')
62
- sample_hash[path] = rate.to_i
63
+ sample_hash[path] = coerce_to_rate(rate)
63
64
  end
64
65
  sample_hash
65
66
  end
66
67
 
67
68
  def ignore_uri?(uri)
68
69
  return false if @ignore_endpoints.blank?
69
- @ignore_endpoints.each do |prefix|
70
- return true if uri.start_with?(prefix)
71
- end
72
- false
70
+ @ignore_endpoints.ignore?(uri)
73
71
  end
74
72
 
75
73
  def web_sample_rate(uri)
@@ -90,8 +88,9 @@ module ScoutApm
90
88
  @sample_jobs.fetch(job_name, nil)
91
89
  end
92
90
 
93
- def sample?(rate)
94
- rand * 100 > rate
91
+ def downsample?(rate)
92
+ # Should we drop this request based on the sample rate?
93
+ rand > rate
95
94
  end
96
95
 
97
96
  private
@@ -100,5 +99,18 @@ module ScoutApm
100
99
  ScoutApm::Agent.instance.logger
101
100
  end
102
101
 
102
+ def coerce_to_rate(val)
103
+ # Analogous to Config::SampleRateCoercion
104
+ v = val.to_f
105
+ # Anything above 1 is assumed a percentage for backwards compat, so convert to a decimal
106
+ if v > 1
107
+ v = v / 100
108
+ end
109
+ if v < 0 || v > 1
110
+ logger.warn("Sample rates must be between 0 and 1. You passed in #{val.inspect}, which we interpreted as #{v}. Clamping.")
111
+ v = v.clamp(0, 1)
112
+ end
113
+ v
114
+ end
103
115
  end
104
116
  end
@@ -53,11 +53,28 @@ module ScoutApm
53
53
  #
54
54
  def install
55
55
  old = ::Puma.cli_config.options.user_options[:before_worker_boot] || []
56
- new = Array(old) + [Proc.new do
57
- logger.info "Installing Puma worker loop."
58
- ScoutApm::Agent.instance.start_background_worker
59
- end]
60
56
 
57
+ hook =
58
+ if Gem::Version.new(::Puma::Const::PUMA_VERSION) < Gem::Version.new("7.0.0")
59
+ # Puma < 7 uses a raw block
60
+ Proc.new do
61
+ logger.info "Installing Puma worker loop."
62
+ ScoutApm::Agent.instance.start_background_worker
63
+ end
64
+ else
65
+ # Puma >= 7 uses the structured hook format:
66
+ # https://github.com/puma/puma/commit/b16790f7a3c1bfc1847225a58897c9fbd19981f8
67
+ {
68
+ block: Proc.new {
69
+ logger.info "Installing Puma worker loop."
70
+ ScoutApm::Agent.instance.start_background_worker
71
+ },
72
+ id: nil,
73
+ cluster_only: false
74
+ }
75
+ end
76
+
77
+ new = Array.new(old) + [hook]
61
78
  ::Puma.cli_config.options[:before_worker_boot] = new
62
79
  rescue
63
80
  logger.warn "Unable to install Puma worker loop: #{$!.message}"
@@ -1,3 +1,3 @@
1
1
  module ScoutApm
2
- VERSION = "5.7.1"
2
+ VERSION = "6.0.0"
3
3
  end
data/lib/scout_apm.rb CHANGED
@@ -84,6 +84,7 @@ require 'scout_apm/histogram'
84
84
  require 'scout_apm/instruments/net_http'
85
85
  require 'scout_apm/instruments/http_client'
86
86
  require 'scout_apm/instruments/http'
87
+ require 'scout_apm/instruments/httpx'
87
88
  require 'scout_apm/instruments/typhoeus'
88
89
  require 'scout_apm/instruments/moped'
89
90
  require 'scout_apm/instruments/mongoid'
@@ -92,6 +93,7 @@ require 'scout_apm/instruments/redis'
92
93
  require 'scout_apm/instruments/redis5'
93
94
  require 'scout_apm/instruments/influxdb'
94
95
  require 'scout_apm/instruments/elasticsearch'
96
+ require 'scout_apm/instruments/opensearch'
95
97
  require 'scout_apm/instruments/active_record'
96
98
  require 'scout_apm/instruments/action_controller_rails_2'
97
99
  require 'scout_apm/instruments/action_controller_rails_3_rails4'
@@ -118,7 +120,6 @@ require 'scout_apm/utils/installed_gems'
118
120
  require 'scout_apm/utils/klass_helper'
119
121
  require 'scout_apm/utils/scm'
120
122
  require 'scout_apm/utils/sql_sanitizer'
121
- require 'scout_apm/utils/time'
122
123
  require 'scout_apm/utils/unique_id'
123
124
  require 'scout_apm/utils/numbers'
124
125
  require 'scout_apm/utils/gzip_helper'
@@ -226,11 +227,15 @@ if defined?(Rails) && defined?(Rails::VERSION) && defined?(Rails::VERSION::MAJOR
226
227
  # Attempt to start right away, this will work best for preloading apps, Unicorn & Puma & similar
227
228
  ScoutApm::Agent.instance.install
228
229
 
229
- if ScoutApm::Agent.instance.context.config.value("auto_instruments")
230
- if defined?(Parser::TreeRewriter)
230
+ if ScoutApm::Agent.instance.context.config.value("auto_instruments") &&
231
+ ScoutApm::Agent.instance.should_load_instruments?
232
+ require 'scout_apm/auto_instrument/requirements'
233
+ if defined?(Prism) || defined?(Parser::TreeRewriter)
231
234
  ScoutApm::Agent.instance.context.logger.debug("AutoInstruments is enabled.")
232
235
  require 'scout_apm/auto_instrument'
233
- else # AutoInstruments is turned on, but we don't he the prerequisites to use it
236
+ else
237
+ # AutoInstruments is turned on, but we don't have the prerequisites to use it
238
+ # Prism should be available for Ruby >= 3.3.0
234
239
  ScoutApm::Agent.instance.context.logger.debug("AutoInstruments is enabled, but Parser::TreeRewriter is missing. Update 'parser' gem to >= 2.5.0.")
235
240
  end
236
241
  else