scout_apm 5.8.0 β†’ 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +2 -0
  3. data/CHANGELOG.markdown +14 -2
  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 +15 -1
  12. data/lib/scout_apm/background_job_integrations/sidekiq.rb +89 -1
  13. data/lib/scout_apm/config.rb +32 -7
  14. data/lib/scout_apm/context.rb +3 -1
  15. data/lib/scout_apm/error_service/error_record.rb +1 -1
  16. data/lib/scout_apm/instrument_manager.rb +2 -0
  17. data/lib/scout_apm/instruments/http_client.rb +10 -0
  18. data/lib/scout_apm/instruments/httpx.rb +119 -0
  19. data/lib/scout_apm/instruments/opensearch.rb +131 -0
  20. data/lib/scout_apm/sampling.rb +25 -13
  21. data/lib/scout_apm/server_integrations/puma.rb +21 -4
  22. data/lib/scout_apm/version.rb +1 -1
  23. data/lib/scout_apm.rb +9 -3
  24. data/test/unit/auto_instrument/controller-ast.prism.txt +1015 -0
  25. data/test/unit/auto_instrument/controller-instrumented.rb +36 -11
  26. data/test/unit/auto_instrument/controller.rb +25 -0
  27. data/test/unit/auto_instrument/hash_shorthand_controller-instrumented.rb +28 -10
  28. data/test/unit/auto_instrument/hash_shorthand_controller.rb +19 -1
  29. data/test/unit/auto_instrument_test.rb +7 -1
  30. data/test/unit/background_job_integrations/sidekiq_test.rb +38 -0
  31. data/test/unit/config_test.rb +14 -0
  32. data/test/unit/error_service/error_buffer_test.rb +31 -0
  33. data/test/unit/error_test.rb +1 -1
  34. data/test/unit/ignored_uris_test.rb +7 -0
  35. data/test/unit/instruments/http_client_test.rb +0 -2
  36. data/test/unit/instruments/httpx_test.rb +78 -0
  37. data/test/unit/sampling_test.rb +10 -10
  38. metadata +8 -2
  39. /data/test/unit/auto_instrument/{controller-ast.txt β†’ controller-ast.parser.txt} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80d2cac0818a8487fe1e9ca2395faccbbbadf2be368151e636e39dd7188b34ea
4
- data.tar.gz: 1c46ee7b70ac1bd2e9ff4ed4232c7304edc02088563df840619aedcb5e43384d
3
+ metadata.gz: 4c8fea1cfd66466534fa0ca5e7aaac6cdb332156e02a4e933d6fb883eb9fd68e
4
+ data.tar.gz: d6f8ab26c4a7d77cde8f0f4fce2b1ef4f849635e839169fe9163e9b64b796294
5
5
  SHA512:
6
- metadata.gz: da2b3e51bea0eb7198d76edd997ef62da6719b3e0df7bd7093f56cbd51d2f0426f6798ef57d641e786a3a16732ad6e9ad141ce977fce0d9888541ff72bfbb11d
7
- data.tar.gz: 42e1bb5874b213da9c8be9b4024263737bf8c34ae7c968b2259939f87bb3f983a105036d6cd3222de02964b8f40aedab8f5e47f10248ed5053c517acddf67833
6
+ metadata.gz: 1d38a64cd133729432a38611167a4551e5ebb208eeddd1d346a5622d0484ba82a4fedb1ac14e385cc48671cd1902726350662202db35a88a28cbde9fb7613702
7
+ data.tar.gz: 7755eb50a09652dba9fa4168893f51f3684b0f14c3fa681c8b2b0af3473eeb9d5b1cec98da46e49612b9f490bc5e9355a187d8658ced8616af0d6d0ad2bdef25
@@ -53,6 +53,8 @@ jobs:
53
53
  gemfile: gems/sqlite3-v2.gemfile
54
54
  - ruby: 3.3
55
55
  gemfile: gems/sqlite3-v2.gemfile
56
+ - ruby: 3.4
57
+ gemfile: gems/sqlite3-v2.gemfile
56
58
  env:
57
59
  BUNDLE_GEMFILE: ${{ matrix.gemfile }}
58
60
  SCOUT_TEST_FEATURES: ${{ matrix.test_features }}
data/CHANGELOG.markdown CHANGED
@@ -1,5 +1,16 @@
1
- # Unreleased
2
- - Enable Delayed Job error tracking (#565)
1
+ # 6.0.0
2
+ - Add Prism AutoInstruments Support (#582) (#594)
3
+ - Add OpenSearch instrumentation (#563)
4
+ - Add HTTPX instrumentation (#588)
5
+ - Add ability to automatically capture Sidekiq job args as context
6
+ - `job_params_capture` - Set to true to enable job argument capturing
7
+ - `job_params_filter` - A list of arguments to filter (automatically includes Rails filtered_parameters)
8
+ - Fix user error context being incorrectly flattened (#581)
9
+ - Handle Delayed Job PerformableMethod jobs for error tracking (#584)
10
+ - Require 'httpclient' library on instrumentation install (#586)
11
+ - Add support for Puma 7.0 hook format (#589)
12
+ - Update sample rate to support floats (#564)
13
+ - Reintegrate Regex support for ignored URIs (#574)
3
14
 
4
15
  # 5.8.0
5
16
  - Add error monitoring to SolidQueue, faktory, goodjob, que, shoryuken, sneakers (#571)
@@ -7,6 +18,7 @@
7
18
  - Remove unused time util conflict with DeviseTokenAuth (#575)
8
19
  - Add transaction ID to error records (#568)-
9
20
  - Adds request method to captured error context (#577)
21
+ - Enable Delayed Job error tracking (#565)
10
22
 
11
23
  # 5.7.1
12
24
  - Update error capture API to use context (#560)
data/README.markdown CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  [![Build Status](https://github.com/scoutapp/scout_apm_ruby/actions/workflows/test.yml/badge.svg)](https://github.com/scoutapp/scout_apm_ruby/actions)
4
4
 
5
- A Ruby gem for detailed Rails application performance monitoring πŸ“ˆ. Metrics and transaction traces are
6
- reported to [Scout](https://scoutapp.com), a hosted application monitoring
7
- service.
5
+ A Ruby gem for detailed Rails application performance monitoring πŸ“ˆ. Metrics, errors and transaction traces are
6
+ reported to [Scout](https://www.scoutapm.com), a hosted application monitoring
7
+ service. We have a free plan for small apps and a 14-day all-access trial to test out all
8
+ the features. If you want to send us Rails logs, add our [other
9
+ gem](https://github.com/scoutapp/scout_apm_ruby_logging) and we will correlate them with
10
+ your performance data!
8
11
 
9
12
  ## What's the special sauce? πŸ€”
10
13
 
@@ -12,7 +15,6 @@ The Scout agent is engineered to do some wonderful things:
12
15
 
13
16
  * A unique focus on identifying those hard-to-investigate outliers like memory bloat, N+1s, and user-specific problems. [See an example workflow](http://scoutapp.com/newrelic-alternative).
14
17
  * [Low-overhead](http://blog.scoutapp.com/articles/2016/02/07/overhead-benchmarks-new-relic-vs-scout)
15
- * View your performance metrics during development with [DevTrace](https://docs.scoutapm.com/#devtrace) and in production via [server_timing](https://github.com/scoutapp/ruby_server_timing).
16
18
  * Production-Safe profiling of custom code via [ScoutProf](https://docs.scoutapm.com/#scoutprof) (BETA).
17
19
 
18
20
  ## Getting Started
@@ -29,8 +31,8 @@ Update your Gemfile
29
31
 
30
32
  bundle install
31
33
 
32
- Signup for a [Scout](https://scoutapm.com) account and put the provided
33
- config file at `RAILS_ROOT/config/scout_apm.yml`.
34
+ Signup for a [Scout](https://scoutapm.com/users/sign_up?utm_source=github&utm_medium=github&utm_campaign=scout_apm_ruby)
35
+ account and put the provided config file at `RAILS_ROOT/config/scout_apm.yml`.
34
36
 
35
37
  Your config file should look like:
36
38
 
@@ -45,6 +47,16 @@ Your config file should look like:
45
47
  production:
46
48
  <<: *defaults
47
49
 
50
+ ## Error Monitoring
51
+
52
+ All of our accounts include Error Monitoring with 5000 errors/month free. To enable it, add the following to your `scout_apm.yml`:
53
+
54
+ ```yaml
55
+ # Common/dev/production/etc. whereever you would like to start trying it
56
+ # monitor: true should also be required to ensure your App exists in Scout
57
+ errors_enabled: true
58
+ ```
59
+
48
60
  ## DevTrace Quickstart
49
61
 
50
62
  To use DevTrace, our free, no-signup, in-browser development profiler:
@@ -71,8 +83,8 @@ SCOUT_DEV_TRACE=true rails server
71
83
  ## Docs
72
84
 
73
85
  For the complete list of supported frameworks, Rubies, configuration options
74
- and more, see our [help site](https://docs.scoutapm.com/).
86
+ and more, see our [help site](https://scoutapm.com/docs).
75
87
 
76
88
  ## Help
77
89
 
78
- Email support@scoutapp.com if you need a hand.
90
+ Email support@scoutapm.com if you need a hand.
@@ -6,3 +6,4 @@ gem 'redis'
6
6
  gem 'moped'
7
7
  gem 'actionpack'
8
8
  gem 'actionview'
9
+ gem 'httpx'
@@ -7,7 +7,8 @@ module ScoutApm
7
7
  def load_iseq(path)
8
8
  if Rails.controller_path?(path) & !Rails.ignore?(path)
9
9
  begin
10
- new_code = Rails.rewrite(path)
10
+ new_code = Rails.rewrite(path).force_encoding(Encoding.default_external)
11
+
11
12
  return self.compile(new_code, path, path)
12
13
  rescue
13
14
  warn "Failed to apply auto-instrumentation to #{path}: #{$!}" if ENV['SCOUT_LOG_LEVEL'].to_s.downcase == "debug"
@@ -4,11 +4,159 @@ raise LoadError, "Parser::TreeRewriter was not defined" unless defined?(Parser::
4
4
 
5
5
  module ScoutApm
6
6
  module AutoInstrument
7
+ module ParserImplementation
8
+ def self.rewrite(path, code = nil)
9
+ code ||= File.read(path)
10
+
11
+ ast = ::Parser::CurrentRuby.parse(code)
12
+
13
+ buffer = ::Parser::Source::Buffer.new(path)
14
+ buffer.source = code
15
+
16
+ rewriter = Rewriter.new
17
+
18
+ # Rewrite the AST, returns a String with the new form.
19
+ rewriter.rewrite(buffer, ast)
20
+ end
21
+
22
+ class Rewriter < ::Parser::TreeRewriter
23
+ def initialize
24
+ super
25
+
26
+ # Keeps track of the parent - child relationship between nodes:
27
+ @nesting = []
28
+
29
+ # The stack of method nodes (type :def):
30
+ @method = []
31
+
32
+ # The stack of class nodes:
33
+ @scope = []
34
+
35
+ @cache = Cache.new
36
+ end
37
+
38
+ def instrument(source, file_name, line)
39
+ # Don't log huge chunks of code... just the first line:
40
+ if lines = source.lines and lines.count > 1
41
+ source = lines.first.chomp + "..."
42
+ end
43
+
44
+ method_name = @method.last.children[0]
45
+ bt = ["#{file_name}:#{line}:in `#{method_name}'"]
46
+
47
+ return [
48
+ "::ScoutApm::AutoInstrument("+ source.dump + ",#{bt}){",
49
+ "}"
50
+ ]
51
+ end
52
+
53
+ # Look up 1 or more nodes to check if the parent exists and matches the given type.
54
+ # @param type [Symbol] the symbol type to match.
55
+ # @param up [Integer] how far up to look.
56
+ def parent_type?(type, up = 1)
57
+ parent = @nesting[@nesting.size - up - 1] and parent.type == type
58
+ end
59
+
60
+ def on_block(node)
61
+ # If we are not in a method, don't do any instrumentation:
62
+ return if @method.empty?
63
+
64
+ line = node.location.line || 'line?'
65
+ column = node.location.column || 'column?' # not used
66
+ method_name = node.children[0].children[1] || '*unknown*' # not used
67
+ file_name = @source_rewriter.source_buffer.name
68
+
69
+ wrap(node.location.expression, *instrument(node.location.expression.source, file_name, line))
70
+ end
71
+
72
+ def on_mlhs(node)
73
+ # Ignore / don't instrument multiple assignment (LHS).
74
+ return
75
+ end
76
+
77
+ def on_op_asgn(node)
78
+ process(node.children[2])
79
+ end
80
+
81
+ def on_or_asgn(node)
82
+ process(node.children[1])
83
+ end
84
+
85
+ def on_and_asgn(node)
86
+ process(node.children[1])
87
+ end
88
+
89
+ # Handle the method call AST node. If this method doesn't call `super`, no futher rewriting is applied to children.
90
+ def on_send(node)
91
+ # We aren't interested in top level function calls:
92
+ return if @method.empty?
93
+
94
+ if @cache.local_assignments?(node)
95
+ return super
96
+ end
97
+
98
+ # This ignores both initial block method invocation `*x*{}`, and subsequent nested invocations `x{*y*}`:
99
+ return if parent_type?(:block)
100
+
101
+ # Extract useful metadata for instrumentation:
102
+ line = node.location.line || 'line?'
103
+ column = node.location.column || 'column?' # not used
104
+ method_name = node.children[1] || '*unknown*' # not used
105
+ file_name = @source_rewriter.source_buffer.name
106
+
107
+ # Wrap the expression with instrumentation:
108
+ wrap(node.location.expression, *instrument(node.location.expression.source, file_name, line))
109
+ end
110
+
111
+ def on_hash(node)
112
+ node.children.each do |pair|
113
+ # Skip `pair` if we're sure it's not using the hash shorthand syntax
114
+ next if pair.type != :pair
115
+ key_node, value_node = pair.children
116
+ next unless key_node.type == :sym && value_node.type == :send
117
+ key = key_node.children[0]
118
+ next unless value_node.children.size == 2 && value_node.children[0].nil? && key == value_node.children[1]
119
+
120
+ # Extract useful metadata for instrumentation:
121
+ line = pair.location.line || 'line?'
122
+ # column = pair.location.column || 'column?' # not used
123
+ # method_name = key || '*unknown*' # not used
124
+ file_name = @source_rewriter.source_buffer.name
125
+
126
+ instrument_before, instrument_after = instrument(pair.location.expression.source, file_name, line)
127
+ replace(pair.loc.expression, "#{key}: #{instrument_before}#{key}#{instrument_after}")
128
+ end
129
+ super
130
+ end
131
+
132
+ # Invoked for every AST node as it is processed top to bottom.
133
+ def process(node)
134
+ # We are nesting inside this node:
135
+ @nesting.push(node)
136
+
137
+ if node and node.type == :def
138
+ # If the node is a method, push it on the method stack as well:
139
+ @method.push(node)
140
+ super
141
+ @method.pop
142
+ elsif node and node.type == :class
143
+ @scope.push(node.children[0])
144
+ super
145
+ @scope.pop
146
+ else
147
+ super
148
+ end
149
+
150
+ @nesting.pop
151
+ end
152
+ end
153
+ end
154
+
7
155
  class Cache
8
156
  def initialize
9
157
  @local_assignments = {}
10
158
  end
11
-
159
+
12
160
  def local_assignments?(node)
13
161
  unless @local_assignments.key?(node)
14
162
  if node.type == :lvasgn
@@ -19,7 +167,7 @@ module ScoutApm
19
167
  @local_assignments[node] = false
20
168
  end
21
169
  end
22
-
170
+
23
171
  return @local_assignments[node]
24
172
  end
25
173
  end
@@ -0,0 +1,357 @@
1
+
2
+ require 'prism'
3
+
4
+ module ScoutApm
5
+ module AutoInstrument
6
+ module PrismImplementation
7
+ def self.rewrite(path, code = nil)
8
+ code ||= File.read(path)
9
+
10
+ result = Prism.parse(code)
11
+ rewriter = Rewriter.new(path, code)
12
+ rewriter.rewrite(result.value)
13
+ end
14
+
15
+ class Rewriter
16
+ def initialize(path, code)
17
+ @path = path
18
+ # Set encoding to ASCII-8bit as Prism uses byte offsets
19
+ @code = code.b
20
+ @replacements = []
21
+ @instrumented_nodes = Set.new
22
+
23
+ # Keeps track of the parent - child relationship between nodes:
24
+ @nesting = []
25
+
26
+ # The stack of method nodes:
27
+ @method = []
28
+
29
+ # The stack of class nodes:
30
+ @scope = []
31
+
32
+ @cache = Cache.new
33
+ end
34
+
35
+ def rewrite(node)
36
+ process(node)
37
+ apply_replacements
38
+ end
39
+
40
+ def apply_replacements
41
+ # Sort replacements by start position in reverse order
42
+ # This ensures we apply replacements from end to start, avoiding offset issues
43
+ # when we modify the string
44
+ sorted_replacements = @replacements.sort_by { |r| -r[:start] }
45
+
46
+ result = @code.dup
47
+ sorted_replacements.each do |replacement|
48
+ result[replacement[:start]...replacement[:end]] = replacement[:new_text].b
49
+ end
50
+ # ::RubyVM::InstructionSequence.compile will infer the encoding when compiling
51
+ # and will compile with ASCII-8bit correctly.
52
+ result
53
+ end
54
+
55
+ def add_replacement(start_offset, end_offset, new_text)
56
+ @replacements << {start: start_offset, end: end_offset, new_text: new_text}
57
+ end
58
+
59
+ def instrument(source, file_name, line)
60
+ # Don't log huge chunks of code... just the first line:
61
+ if lines = source.lines and lines.count > 1
62
+ source = lines.first.chomp + "..."
63
+ end
64
+
65
+ method_name = @method.last.name
66
+ bt = ["#{file_name}:#{line}:in `#{method_name}'"]
67
+
68
+ return [
69
+ "::ScoutApm::AutoInstrument("+ source.dump + ",#{bt}){",
70
+ "}"
71
+ ]
72
+ end
73
+
74
+ # Look up 1 or more nodes to check if the parent exists and matches the given type.
75
+ # @param type [Class] the node class to match.
76
+ # @param up [Integer] how far up to look.
77
+ def parent_type?(type, up = 1)
78
+ parent = @nesting[@nesting.size - up - 1] and parent.is_a?(type)
79
+ end
80
+
81
+ def wrap_node(node)
82
+ # Skip if this node or any parent has already been instrumented
83
+ return if @instrumented_nodes.include?(node)
84
+
85
+ # Skip if any ancestor node has been instrumented (to avoid overlapping replacements)
86
+ @nesting.each do |ancestor|
87
+ return if @instrumented_nodes.include?(ancestor)
88
+ end
89
+
90
+ # Skip if any descendant node has already been instrumented (to avoid overlapping replacements)
91
+ # This prevents a parent node from being wrapped when a child node has already been modified
92
+ return if has_instrumented_descendant?(node)
93
+
94
+ start_offset = node.location.start_offset
95
+ end_offset = node.location.end_offset
96
+ line = node.location.start_line
97
+ source = @code[start_offset...end_offset]
98
+
99
+ instrument_before, instrument_after = instrument(source, @path, line)
100
+ new_text = "#{instrument_before}#{source}#{instrument_after}"
101
+ add_replacement(start_offset, end_offset, new_text)
102
+ @instrumented_nodes.add(node)
103
+ end
104
+
105
+ def has_instrumented_descendant?(node)
106
+ node.compact_child_nodes.any? do |child|
107
+ @instrumented_nodes.include?(child) || has_instrumented_descendant?(child)
108
+ end
109
+ end
110
+
111
+ def visit_block_node(node)
112
+ # If we are not in a method, don't do any instrumentation:
113
+ return process_children(node) if @method.empty?
114
+
115
+ # If this block is attached to a CallNode, don't wrap it separately
116
+ # The CallNode will wrap the entire call including the block
117
+ return process_children(node) if parent_type?(Prism::CallNode)
118
+
119
+ # If this block is attached to a SuperNode or ForwardingSuperNode, don't wrap it separately
120
+ # The super node will wrap the entire call including the block
121
+ return process_children(node) if parent_type?(Prism::SuperNode) || parent_type?(Prism::ForwardingSuperNode)
122
+
123
+ wrap_node(node)
124
+ end
125
+
126
+ def visit_multi_target_node(node)
127
+ # Ignore / don't instrument multiple assignment (LHS).
128
+ return
129
+ end
130
+
131
+ def visit_call_node(node)
132
+ # We aren't interested in top level function calls:
133
+ return process_children(node) if @method.empty?
134
+
135
+ if @cache.local_assignments?(node)
136
+ return process_children(node)
137
+ end
138
+
139
+ # This ignores both initial block method invocation and subsequent nested invocations:
140
+ return process_children(node) if parent_type?(Prism::BlockNode)
141
+
142
+ wrap_node(node)
143
+
144
+ # Process children to handle nested calls, but blocks attached to this call
145
+ # won't be wrapped separately (handled by visit_block_node check)
146
+ process_children(node)
147
+ end
148
+
149
+ def visit_super_node(node)
150
+ # We aren't interested in top level super calls:
151
+ return process_children(node) if @method.empty?
152
+
153
+ # This ignores super calls inside blocks:
154
+ return process_children(node) if parent_type?(Prism::BlockNode)
155
+
156
+ # Only wrap super calls that have a block attached
157
+ # Bare super calls (with or without arguments) are just delegation and shouldn't be instrumented
158
+ if node.block
159
+ wrap_node(node)
160
+ end
161
+
162
+ # Process children to handle nested calls, but blocks attached to this super
163
+ # won't be wrapped separately (handled by visit_block_node check)
164
+ process_children(node)
165
+ end
166
+
167
+ def visit_forwarding_super_node(node)
168
+ # We aren't interested in top level super calls:
169
+ return process_children(node) if @method.empty?
170
+
171
+ # This ignores super calls inside blocks:
172
+ return process_children(node) if parent_type?(Prism::BlockNode)
173
+
174
+ # Only wrap super calls that have a block attached
175
+ # Bare super calls are just delegation and shouldn't be instrumented
176
+ if node.block
177
+ wrap_node(node)
178
+ end
179
+
180
+ # Process children to handle nested calls, but blocks attached to this super
181
+ # won't be wrapped separately (handled by visit_block_node check)
182
+ process_children(node)
183
+ end
184
+
185
+ # This is meant to mirror that of the parser implementation.
186
+ # See test/unit/auto_instrument/hash_shorthand_controller-instrumented.rb
187
+ # Non-nil receiver is handled in visit_call_node.
188
+ def visit_hash_node(node)
189
+ # If this hash is a descendant of a CallNode (at any level), don't instrument individual elements
190
+ # The parent CallNode will wrap the entire expression
191
+ # This allows hashes in local variable assignments to be instrumented,
192
+ # but hashes in method calls to be wrapped as a unit
193
+ in_call_node = @nesting.any? { |n| n.is_a?(Prism::CallNode) }
194
+
195
+ node.elements.each do |element|
196
+ if element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)
197
+ value_node = element.value
198
+
199
+ # Only instrument hash element values if we're not in a CallNode
200
+ # Handles shorthand syntax like `shorthand:` β†’ line 6
201
+ if !in_call_node && value_node.is_a?(Prism::ImplicitNode)
202
+ key = element.key.unescaped
203
+ inner_call = value_node.value
204
+
205
+ line = element.location.start_line
206
+ source = @code[element.location.start_offset...element.location.end_offset]
207
+ file_name = @path
208
+ method_name = @method.last.name
209
+ bt = ["#{file_name}:#{line}:in `#{method_name}'"]
210
+
211
+ instrument_before = "::ScoutApm::AutoInstrument(#{source.dump},#{bt}){"
212
+ instrument_after = "}"
213
+ new_text = "#{key}: #{instrument_before}#{key}#{instrument_after}"
214
+ add_replacement(element.location.start_offset, element.location.end_offset, new_text)
215
+
216
+ @instrumented_nodes.add(value_node)
217
+ @instrumented_nodes.add(inner_call)
218
+ next
219
+ elsif !in_call_node && value_node.is_a?(Prism::CallNode) && value_node.receiver.nil?
220
+ line = element.location.start_line
221
+ key = element.key.unescaped
222
+ value_name = value_node.name.to_s
223
+ pair_source = @code[element.location.start_offset...element.location.end_offset]
224
+ value_source = @code[value_node.location.start_offset...value_node.location.end_offset]
225
+ key_source = @code[element.key.location.start_offset...element.key.location.end_offset]
226
+ file_name = @path
227
+ method_name = @method.last.name
228
+ bt = ["#{file_name}:#{line}:in `#{method_name}'"]
229
+
230
+ has_arguments = value_node.arguments && !value_node.arguments.arguments.empty?
231
+
232
+ # Handles hash_rocket w/ same key/value name and no arguments.
233
+ # See test for more info on backward compatibility on this one.
234
+ # e.g. `hash_rocket: hash_rocket` β†’ line 9
235
+ if key == value_name && !has_arguments && key_source.start_with?(':')
236
+ source_for_dump = pair_source
237
+ instrument_before = "::ScoutApm::AutoInstrument(#{source_for_dump.dump},#{bt}){"
238
+ instrument_after = "}"
239
+ instrumented_value = "#{instrument_before}#{value_source}#{instrument_after}"
240
+ new_text = "#{key}: #{instrumented_value}"
241
+ add_replacement(element.location.start_offset, element.location.end_offset, new_text)
242
+
243
+ # If key == value_name and no arguments β†’ direct shorthand pair
244
+ # e.g. `longhand: longhand` β†’ line 7
245
+ elsif key == value_name && !has_arguments && !key_source.start_with?(':')
246
+ source_for_dump = pair_source
247
+ instrument_before = "::ScoutApm::AutoInstrument(#{source_for_dump.dump},#{bt}){"
248
+ instrument_after = "}"
249
+ instrumented_value = "#{instrument_before}#{value_source}#{instrument_after}"
250
+ add_replacement(value_node.location.start_offset, value_node.location.end_offset, instrumented_value)
251
+
252
+ # If key != value_name β†’ β€œdifferent key/value name” case
253
+ # e.g. `longhand_different_key: longhand` β†’ line 8
254
+ # or `:hash_rocket_different_key => hash_rocket` β†’ line 10
255
+ elsif key != value_name && !has_arguments
256
+ source_for_dump = value_source
257
+ instrument_before = "::ScoutApm::AutoInstrument(#{source_for_dump.dump},#{bt}){"
258
+ instrument_after = "}"
259
+ instrumented_value = "#{instrument_before}#{value_source}#{instrument_after}"
260
+ add_replacement(value_node.location.start_offset, value_node.location.end_offset, instrumented_value)
261
+
262
+ # If value_node has arguments β†’ method call with params
263
+ # e.g. `nested_call(params["timestamp"])` β†’ line 15
264
+ elsif has_arguments
265
+ source_for_dump = value_source
266
+ instrument_before = "::ScoutApm::AutoInstrument(#{source_for_dump.dump},#{bt}){"
267
+ instrument_after = "}"
268
+ instrumented_value = "#{instrument_before}#{value_source}#{instrument_after}"
269
+ add_replacement(value_node.location.start_offset, value_node.location.end_offset, instrumented_value)
270
+ end
271
+
272
+ @instrumented_nodes.add(value_node)
273
+ next
274
+ end
275
+ end
276
+
277
+ element.compact_child_nodes.each do |child|
278
+ process(child)
279
+ end
280
+ end
281
+ end
282
+
283
+ def process(node)
284
+ return unless node
285
+
286
+ # We are nesting inside this node:
287
+ @nesting.push(node)
288
+
289
+ case node
290
+ when Prism::DefNode
291
+ # If the node is a method, push it on the method stack as well:
292
+ @method.push(node)
293
+ process_children(node)
294
+ @method.pop
295
+ when Prism::ClassNode
296
+ # If the node is a method, push it on the scope stack as well:
297
+ @scope.push(node.name)
298
+ process_children(node)
299
+ @scope.pop
300
+ when Prism::BlockNode
301
+ visit_block_node(node)
302
+ when Prism::MultiTargetNode
303
+ visit_multi_target_node(node)
304
+ when Prism::CallNode
305
+ visit_call_node(node)
306
+ when Prism::SuperNode
307
+ visit_super_node(node)
308
+ when Prism::ForwardingSuperNode
309
+ visit_forwarding_super_node(node)
310
+ when Prism::HashNode
311
+ visit_hash_node(node)
312
+ when Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode
313
+ # For op assignment nodes, only process the value
314
+ process(node.value)
315
+ else
316
+ process_children(node)
317
+ end
318
+
319
+ @nesting.pop
320
+ end
321
+
322
+ def process_children(node)
323
+ node.compact_child_nodes.each do |child|
324
+ process(child)
325
+ end
326
+ end
327
+ end
328
+ end
329
+
330
+ class Cache
331
+ def initialize
332
+ @local_assignments = {}
333
+ end
334
+
335
+ def local_assignments?(node)
336
+ return false unless node
337
+ return false unless node.respond_to?(:compact_child_nodes)
338
+
339
+ unless @local_assignments.key?(node)
340
+ if node.is_a?(Prism::LocalVariableWriteNode)
341
+ @local_assignments[node] = true
342
+ elsif node.compact_child_nodes.find{|child|
343
+ # Don't check blocks - assignments inside blocks shouldn't affect the parent call
344
+ next if child.is_a?(Prism::BlockNode)
345
+ self.local_assignments?(child)
346
+ }
347
+ @local_assignments[node] = true
348
+ else
349
+ @local_assignments[node] = false
350
+ end
351
+ end
352
+
353
+ return @local_assignments[node]
354
+ end
355
+ end
356
+ end
357
+ end