datadog-ci 1.0.0.beta1 → 1.0.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +37 -46
  4. data/lib/datadog/ci/configuration/components.rb +43 -9
  5. data/lib/datadog/ci/configuration/settings.rb +6 -0
  6. data/lib/datadog/ci/contrib/cucumber/formatter.rb +9 -7
  7. data/lib/datadog/ci/contrib/cucumber/patcher.rb +3 -0
  8. data/lib/datadog/ci/contrib/cucumber/step.rb +27 -0
  9. data/lib/datadog/ci/contrib/minitest/hooks.rb +4 -2
  10. data/lib/datadog/ci/contrib/rspec/example.rb +9 -5
  11. data/lib/datadog/ci/ext/environment/providers/local_git.rb +8 -79
  12. data/lib/datadog/ci/ext/environment.rb +11 -16
  13. data/lib/datadog/ci/ext/settings.rb +1 -0
  14. data/lib/datadog/ci/ext/test.rb +5 -0
  15. data/lib/datadog/ci/ext/transport.rb +8 -0
  16. data/lib/datadog/ci/git/local_repository.rb +238 -0
  17. data/lib/datadog/ci/git/packfiles.rb +70 -0
  18. data/lib/datadog/ci/git/search_commits.rb +77 -0
  19. data/lib/datadog/ci/git/tree_uploader.rb +90 -0
  20. data/lib/datadog/ci/git/upload_packfile.rb +66 -0
  21. data/lib/datadog/ci/git/user.rb +29 -0
  22. data/lib/datadog/ci/itr/coverage/event.rb +18 -1
  23. data/lib/datadog/ci/itr/coverage/writer.rb +108 -0
  24. data/lib/datadog/ci/itr/runner.rb +120 -11
  25. data/lib/datadog/ci/itr/skippable.rb +106 -0
  26. data/lib/datadog/ci/span.rb +9 -0
  27. data/lib/datadog/ci/test.rb +19 -12
  28. data/lib/datadog/ci/test_module.rb +2 -2
  29. data/lib/datadog/ci/test_session.rb +2 -2
  30. data/lib/datadog/ci/test_suite.rb +2 -2
  31. data/lib/datadog/ci/test_visibility/null_recorder.rb +4 -1
  32. data/lib/datadog/ci/test_visibility/recorder.rb +47 -9
  33. data/lib/datadog/ci/test_visibility/transport.rb +1 -1
  34. data/lib/datadog/ci/transport/http.rb +24 -4
  35. data/lib/datadog/ci/transport/remote_settings_api.rb +12 -6
  36. data/lib/datadog/ci/utils/configuration.rb +2 -2
  37. data/lib/datadog/ci/utils/git.rb +6 -67
  38. data/lib/datadog/ci/utils/parsing.rb +16 -0
  39. data/lib/datadog/ci/utils/test_run.rb +13 -0
  40. data/lib/datadog/ci/version.rb +1 -1
  41. data/lib/datadog/ci/worker.rb +35 -0
  42. data/lib/datadog/ci.rb +4 -0
  43. metadata +15 -4
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "msgpack"
4
4
 
5
+ require_relative "../../git/local_repository"
6
+
5
7
  module Datadog
6
8
  module CI
7
9
  module ITR
@@ -50,13 +52,28 @@ module Datadog
50
52
  files.each do |filename|
51
53
  packer.write_map_header(1)
52
54
  packer.write("filename")
53
- packer.write(filename)
55
+ packer.write(Git::LocalRepository.relative_to_root(filename))
54
56
  end
55
57
  end
56
58
 
57
59
  def to_s
58
60
  "Coverage::Event[test_id=#{test_id}, test_suite_id=#{test_suite_id}, test_session_id=#{test_session_id}, coverage=#{coverage}]"
59
61
  end
62
+
63
+ # Return a human readable version of the event
64
+ def pretty_print(q)
65
+ q.group 0 do
66
+ q.breakable
67
+ q.text "Test ID: #{@test_id}\n"
68
+ q.text "Test Suite ID: #{@test_suite_id}\n"
69
+ q.text "Test Session ID: #{@test_session_id}\n"
70
+ q.group(2, "Files: [", "]\n") do
71
+ q.seplist @coverage.keys.each do |key|
72
+ q.text key
73
+ end
74
+ end
75
+ end
76
+ end
60
77
  end
61
78
  end
62
79
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "datadog/core/workers/async"
4
+ require "datadog/core/workers/queue"
5
+ require "datadog/core/workers/polling"
6
+
7
+ require "datadog/core/buffer/cruby"
8
+ require "datadog/core/buffer/thread_safe"
9
+
10
+ require "datadog/core/environment/ext"
11
+
12
+ module Datadog
13
+ module CI
14
+ module ITR
15
+ module Coverage
16
+ class Writer
17
+ include Core::Workers::Queue
18
+ include Core::Workers::Polling
19
+
20
+ attr_reader :transport
21
+
22
+ DEFAULT_BUFFER_MAX_SIZE = 10_000
23
+ DEFAULT_SHUTDOWN_TIMEOUT = 60
24
+
25
+ def initialize(transport:, options: {})
26
+ @transport = transport
27
+
28
+ # Workers::Polling settings
29
+ self.enabled = options.fetch(:enabled, true)
30
+
31
+ # Workers::Async::Thread settings
32
+ self.fork_policy = Core::Workers::Async::Thread::FORK_POLICY_RESTART
33
+
34
+ # Workers::IntervalLoop settings
35
+ self.loop_base_interval = options[:interval] if options.key?(:interval)
36
+ self.loop_back_off_ratio = options[:back_off_ratio] if options.key?(:back_off_ratio)
37
+ self.loop_back_off_max = options[:back_off_max] if options.key?(:back_off_max)
38
+
39
+ @buffer_size = options.fetch(:buffer_size, DEFAULT_BUFFER_MAX_SIZE)
40
+
41
+ self.buffer = buffer_klass.new(@buffer_size)
42
+
43
+ @shutdown_timeout = options.fetch(:shutdown_timeout, DEFAULT_SHUTDOWN_TIMEOUT)
44
+
45
+ @stopped = false
46
+ end
47
+
48
+ def write(event)
49
+ return if @stopped
50
+
51
+ # Start worker thread. If the process has forked, it will trigger #after_fork to
52
+ # reconfigure the worker accordingly.
53
+ perform
54
+
55
+ enqueue(event)
56
+ end
57
+
58
+ def perform(*events)
59
+ responses = transport.send_events(events)
60
+
61
+ loop_back_off! if responses.find(&:server_error?)
62
+
63
+ nil
64
+ end
65
+
66
+ def stop(force_stop = false, timeout = @shutdown_timeout)
67
+ @stopped = true
68
+
69
+ buffer.close if running?
70
+
71
+ super
72
+ end
73
+
74
+ def enqueue(event)
75
+ buffer.push(event)
76
+ end
77
+
78
+ def dequeue
79
+ buffer.pop
80
+ end
81
+
82
+ def work_pending?
83
+ !buffer.empty?
84
+ end
85
+
86
+ def async?
87
+ true
88
+ end
89
+
90
+ def after_fork
91
+ # In multiprocess environments, forks will share the same buffer until its written to.
92
+ # A.K.A. copy-on-write. We don't want forks to write events generated from another process.
93
+ # Instead, we reset it after the fork. (Make sure any enqueue operations happen after this.)
94
+ self.buffer = buffer_klass.new(@buffer_size)
95
+ end
96
+
97
+ def buffer_klass
98
+ if Core::Environment::Ext::RUBY_ENGINE == "ruby"
99
+ Core::Buffer::CRuby
100
+ else
101
+ Core::Buffer::ThreadSafe
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -1,9 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pp"
4
+
5
+ require "datadog/core/utils/forking"
6
+
3
7
  require_relative "../ext/test"
4
8
  require_relative "../ext/transport"
5
9
 
6
- require_relative "../utils/git"
10
+ require_relative "../git/local_repository"
11
+
12
+ require_relative "../utils/parsing"
13
+
14
+ require_relative "coverage/event"
15
+ require_relative "skippable"
7
16
 
8
17
  module Datadog
9
18
  module CI
@@ -12,26 +21,44 @@ module Datadog
12
21
  # Integrates with backend to provide test impact analysis data and
13
22
  # skip tests that are not impacted by the changes
14
23
  class Runner
24
+ include Core::Utils::Forking
25
+
26
+ attr_reader :correlation_id, :skippable_tests, :skipped_tests_count
27
+
15
28
  def initialize(
29
+ dd_env:,
30
+ api: nil,
31
+ coverage_writer: nil,
16
32
  enabled: false
17
33
  )
18
34
  @enabled = enabled
35
+ @api = api
36
+ @dd_env = dd_env
37
+
19
38
  @test_skipping_enabled = false
20
39
  @code_coverage_enabled = false
21
40
 
41
+ @coverage_writer = coverage_writer
42
+
43
+ @correlation_id = nil
44
+ @skippable_tests = []
45
+
46
+ @skipped_tests_count = 0
47
+ @mutex = Mutex.new
48
+
22
49
  Datadog.logger.debug("ITR Runner initialized with enabled: #{@enabled}")
23
50
  end
24
51
 
25
- def configure(remote_configuration, test_session)
52
+ def configure(remote_configuration, test_session:, git_tree_upload_worker:)
26
53
  Datadog.logger.debug("Configuring ITR Runner with remote configuration: #{remote_configuration}")
27
54
 
28
- @enabled = convert_to_bool(
55
+ @enabled = Utils::Parsing.convert_to_bool(
29
56
  remote_configuration.fetch(Ext::Transport::DD_API_SETTINGS_RESPONSE_ITR_ENABLED_KEY, false)
30
57
  )
31
- @test_skipping_enabled = @enabled && convert_to_bool(
58
+ @test_skipping_enabled = @enabled && Utils::Parsing.convert_to_bool(
32
59
  remote_configuration.fetch(Ext::Transport::DD_API_SETTINGS_RESPONSE_TESTS_SKIPPING_KEY, false)
33
60
  )
34
- @code_coverage_enabled = @enabled && convert_to_bool(
61
+ @code_coverage_enabled = @enabled && Utils::Parsing.convert_to_bool(
35
62
  remote_configuration.fetch(Ext::Transport::DD_API_SETTINGS_RESPONSE_CODE_COVERAGE_KEY, false)
36
63
  )
37
64
 
@@ -46,6 +73,8 @@ module Datadog
46
73
  load_datadog_cov! if @code_coverage_enabled
47
74
 
48
75
  Datadog.logger.debug("Configured ITR Runner with enabled: #{@enabled}, skipping_tests: #{@test_skipping_enabled}, code_coverage: #{@code_coverage_enabled}")
76
+
77
+ fetch_skippable_tests(test_session: test_session, git_tree_upload_worker: git_tree_upload_worker)
49
78
  end
50
79
 
51
80
  def enabled?
@@ -60,22 +89,85 @@ module Datadog
60
89
  @code_coverage_enabled
61
90
  end
62
91
 
63
- def start_coverage
92
+ def start_coverage(test)
64
93
  return if !enabled? || !code_coverage?
65
94
 
66
95
  coverage_collector&.start
67
96
  end
68
97
 
69
- def stop_coverage(_test)
98
+ def stop_coverage(test)
70
99
  return if !enabled? || !code_coverage?
71
100
 
72
- coverage_collector&.stop
101
+ coverage = coverage_collector&.stop
102
+ return if coverage.nil? || coverage.empty?
103
+
104
+ return if test.skipped?
105
+
106
+ test_source_file = test.source_file
107
+
108
+ # cucumber's gherkin files are not covered by the code coverage collector
109
+ ensure_test_source_covered(test_source_file, coverage) unless test_source_file.nil?
110
+
111
+ event = Coverage::Event.new(
112
+ test_id: test.id.to_s,
113
+ test_suite_id: test.test_suite_id.to_s,
114
+ test_session_id: test.test_session_id.to_s,
115
+ coverage: coverage
116
+ )
117
+
118
+ Datadog.logger.debug { "Writing coverage event \n #{event.pretty_inspect}" }
119
+
120
+ write(event)
121
+
122
+ event
123
+ end
124
+
125
+ def mark_if_skippable(test)
126
+ return if !enabled? || !skipping_tests?
127
+
128
+ skippable_test_id = Utils::TestRun.skippable_test_id(test.name, test.test_suite_name, test.parameters)
129
+ if @skippable_tests.include?(skippable_test_id)
130
+ test.set_tag(Ext::Test::TAG_ITR_SKIPPED_BY_ITR, "true")
131
+
132
+ Datadog.logger.debug { "Marked test as skippable: #{skippable_test_id}" }
133
+ else
134
+ Datadog.logger.debug { "Test is not skippable: #{skippable_test_id}" }
135
+ end
136
+ end
137
+
138
+ def count_skipped_test(test)
139
+ if forked?
140
+ Datadog.logger.warn { "ITR is not supported for forking test runners yet" }
141
+ return
142
+ end
143
+
144
+ return if !test.skipped? || !test.skipped_by_itr?
145
+
146
+ @mutex.synchronize do
147
+ @skipped_tests_count += 1
148
+ end
149
+ end
150
+
151
+ def write_test_session_tags(test_session)
152
+ return if !enabled?
153
+
154
+ test_session.set_tag(Ext::Test::TAG_ITR_TESTS_SKIPPED, @skipped_tests_count.positive?.to_s)
155
+ test_session.set_tag(Ext::Test::TAG_ITR_TEST_SKIPPING_COUNT, @skipped_tests_count)
156
+ end
157
+
158
+ def shutdown!
159
+ @coverage_writer&.stop
73
160
  end
74
161
 
75
162
  private
76
163
 
164
+ def write(event)
165
+ # skip sending events if writer is not configured
166
+ @coverage_writer&.write(event)
167
+ end
168
+
77
169
  def coverage_collector
78
- Thread.current[:dd_coverage_collector] ||= Coverage::DDCov.new(root: Utils::Git.root)
170
+ Thread.current[:dd_coverage_collector] ||= Coverage::DDCov.new(root: Git::LocalRepository.root)
79
171
  end
80
172
 
81
173
  def load_datadog_cov!
@@ -86,8 +178,25 @@ module Datadog
86
178
  @code_coverage_enabled = false
87
179
  end
88
180
 
89
- def convert_to_bool(value)
90
- value.to_s == "true"
181
+ def ensure_test_source_covered(test_source_file, coverage)
182
+ absolute_test_source_file_path = File.join(Git::LocalRepository.root, test_source_file)
183
+ return if coverage.key?(absolute_test_source_file_path)
184
+
185
+ coverage[absolute_test_source_file_path] = true
186
+ end
187
+
188
+ def fetch_skippable_tests(test_session:, git_tree_upload_worker:)
189
+ return unless skipping_tests?
190
+
191
+ # we can only request skippable tests if git metadata is already uploaded
192
+ git_tree_upload_worker.wait_until_done
193
+
194
+ skippable_response = Skippable.new(api: @api, dd_env: @dd_env).fetch_skippable_tests(test_session)
195
+ @correlation_id = skippable_response.correlation_id
196
+ @skippable_tests = skippable_response.tests
197
+
198
+ Datadog.logger.debug { "Fetched skippable tests: \n #{@skippable_tests}" }
199
+ Datadog.logger.debug { "ITR correlation ID: #{@correlation_id}" }
91
200
  end
92
201
  end
93
202
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../ext/transport"
6
+ require_relative "../ext/test"
7
+ require_relative "../utils/test_run"
8
+
9
+ module Datadog
10
+ module CI
11
+ module ITR
12
+ class Skippable
13
+ class Response
14
+ def initialize(http_response)
15
+ @http_response = http_response
16
+ @json = nil
17
+ end
18
+
19
+ def ok?
20
+ resp = @http_response
21
+ !resp.nil? && resp.ok?
22
+ end
23
+
24
+ def correlation_id
25
+ payload.dig("meta", "correlation_id")
26
+ end
27
+
28
+ def tests
29
+ res = Set.new
30
+
31
+ payload.fetch("data", [])
32
+ .each do |test_data|
33
+ next unless test_data["type"] == Ext::Test::ITR_TEST_SKIPPING_MODE
34
+
35
+ attrs = test_data["attributes"] || {}
36
+ res << Utils::TestRun.skippable_test_id(attrs["name"], attrs["suite"], attrs["parameters"])
37
+ end
38
+
39
+ res
40
+ end
41
+
42
+ private
43
+
44
+ def payload
45
+ cached = @json
46
+ return cached unless cached.nil?
47
+
48
+ resp = @http_response
49
+ return @json = {} if resp.nil? || !ok?
50
+
51
+ begin
52
+ @json = JSON.parse(resp.payload)
53
+ rescue JSON::ParserError => e
54
+ Datadog.logger.error("Failed to parse skippable tests response payload: #{e}. Payload was: #{resp.payload}")
55
+ @json = {}
56
+ end
57
+ end
58
+ end
59
+
60
+ def initialize(dd_env:, api: nil)
61
+ @api = api
62
+ @dd_env = dd_env
63
+ end
64
+
65
+ def fetch_skippable_tests(test_session)
66
+ api = @api
67
+ return Response.new(nil) unless api
68
+
69
+ request_payload = payload(test_session)
70
+ Datadog.logger.debug("Fetching skippable tests with request: #{request_payload}")
71
+
72
+ http_response = api.api_request(
73
+ path: Ext::Transport::DD_API_SKIPPABLE_TESTS_PATH,
74
+ payload: request_payload
75
+ )
76
+
77
+ Response.new(http_response)
78
+ end
79
+
80
+ private
81
+
82
+ def payload(test_session)
83
+ {
84
+ "data" => {
85
+ "type" => Ext::Transport::DD_API_SKIPPABLE_TESTS_TYPE,
86
+ "attributes" => {
87
+ "test_level" => Ext::Test::ITR_TEST_SKIPPING_MODE,
88
+ "service" => test_session.service,
89
+ "env" => @dd_env,
90
+ "repository_url" => test_session.git_repository_url,
91
+ "sha" => test_session.git_commit_sha,
92
+ "configurations" => {
93
+ Ext::Test::TAG_OS_PLATFORM => test_session.os_platform,
94
+ Ext::Test::TAG_OS_ARCHITECTURE => test_session.os_architecture,
95
+ Ext::Test::TAG_OS_VERSION => test_session.os_version,
96
+ Ext::Test::TAG_RUNTIME_NAME => test_session.runtime_name,
97
+ Ext::Test::TAG_RUNTIME_VERSION => test_session.runtime_version
98
+ }
99
+ }
100
+ }
101
+ }.to_json
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "datadog/core/environment/platform"
4
+
3
5
  require_relative "ext/test"
4
6
  require_relative "utils/test_run"
5
7
 
@@ -151,6 +153,12 @@ module Datadog
151
153
  tracer_span.get_tag(Ext::Test::TAG_OS_PLATFORM)
152
154
  end
153
155
 
156
+ # Returns the OS version extracted from the environment.
157
+ # @return [String] OS version.
158
+ def os_version
159
+ tracer_span.get_tag(Ext::Test::TAG_OS_VERSION)
160
+ end
161
+
154
162
  # Returns the runtime name extracted from the environment.
155
163
  # @return [String] runtime name.
156
164
  def runtime_name
@@ -166,6 +174,7 @@ module Datadog
166
174
  def set_environment_runtime_tags
167
175
  tracer_span.set_tag(Ext::Test::TAG_OS_ARCHITECTURE, ::RbConfig::CONFIG["host_cpu"])
168
176
  tracer_span.set_tag(Ext::Test::TAG_OS_PLATFORM, ::RbConfig::CONFIG["host_os"])
177
+ tracer_span.set_tag(Ext::Test::TAG_OS_VERSION, Core::Environment::Platform.kernel_release)
169
178
  tracer_span.set_tag(Ext::Test::TAG_RUNTIME_NAME, Core::Environment::Ext::LANG_ENGINE)
170
179
  tracer_span.set_tag(Ext::Test::TAG_RUNTIME_VERSION, Core::Environment::Ext::ENGINE_VERSION)
171
180
  tracer_span.set_tag(Ext::Test::TAG_COMMAND, Utils::TestRun.command)
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  require_relative "span"
6
+ require_relative "utils/test_run"
6
7
 
7
8
  module Datadog
8
9
  module CI
@@ -18,9 +19,9 @@ module Datadog
18
19
  # Finishes the current test.
19
20
  # @return [void]
20
21
  def finish
21
- super
22
-
23
22
  recorder.deactivate_test
23
+
24
+ super
24
25
  end
25
26
 
26
27
  # Running test suite that this test is part of (if any).
@@ -62,6 +63,12 @@ module Datadog
62
63
  get_tag(Ext::Test::TAG_SOURCE_FILE)
63
64
  end
64
65
 
66
+ # Returns "true" if the test is skipped by the intelligent test runner.
67
+ # @return [Boolean] true if the test is skipped by the intelligent test runner, false otherwise.
68
+ def skipped_by_itr?
69
+ get_tag(Ext::Test::TAG_ITR_SKIPPED_BY_ITR) == "true"
70
+ end
71
+
65
72
  # Sets the status of the span to "pass".
66
73
  # @return [void]
67
74
  def passed!
@@ -89,7 +96,7 @@ module Datadog
89
96
  record_test_result(Ext::Test::Status::SKIP)
90
97
  end
91
98
 
92
- # Sets the parameters for this test (e.g. Cucumber example or RSpec shared specs).
99
+ # Sets the parameters for this test (e.g. Cucumber example or RSpec specs).
93
100
  # Parameters are needed to compute test fingerprint to distinguish between different tests having same names.
94
101
  #
95
102
  # @param [Hash] arguments the arguments that test accepts as key-value hash
@@ -98,15 +105,15 @@ module Datadog
98
105
  def set_parameters(arguments, metadata = {})
99
106
  return if arguments.nil?
100
107
 
101
- set_tag(
102
- Ext::Test::TAG_PARAMETERS,
103
- JSON.generate(
104
- {
105
- arguments: arguments,
106
- metadata: metadata
107
- }
108
- )
109
- )
108
+ set_tag(Ext::Test::TAG_PARAMETERS, Utils::TestRun.test_parameters(arguments: arguments, metadata: metadata))
109
+ end
110
+
111
+ # Gets the parameters for this test (e.g. Cucumber example or RSpec specs) as a serialized JSON.
112
+ #
113
+ # @return [String] the serialized JSON of the parameters
114
+ # @return [nil] if this test does not have parameters
115
+ def parameters
116
+ get_tag(Ext::Test::TAG_PARAMETERS)
110
117
  end
111
118
 
112
119
  private
@@ -14,9 +14,9 @@ module Datadog
14
14
  # Finishes this test module.
15
15
  # @return [void]
16
16
  def finish
17
- super
18
-
19
17
  recorder.deactivate_test_module
18
+
19
+ super
20
20
  end
21
21
  end
22
22
  end
@@ -15,9 +15,9 @@ module Datadog
15
15
  # Finishes the current test session.
16
16
  # @return [void]
17
17
  def finish
18
- super
19
-
20
18
  recorder.deactivate_test_session
19
+
20
+ super
21
21
  end
22
22
 
23
23
  # Return the test session's name which is equal to test command used
@@ -26,9 +26,9 @@ module Datadog
26
26
  # we try to derive test suite status from execution stats if no status was set explicitly
27
27
  set_status_from_stats! if undefined?
28
28
 
29
- super
30
-
31
29
  recorder.deactivate_test_suite(name)
30
+
31
+ super
32
32
  end
33
33
  end
34
34
 
@@ -23,7 +23,7 @@ module Datadog
23
23
  skip_tracing(block)
24
24
  end
25
25
 
26
- def trace(type, span_name, tags: {}, &block)
26
+ def trace(span_name, type: "span", tags: {}, &block)
27
27
  skip_tracing(block)
28
28
  end
29
29
 
@@ -42,6 +42,9 @@ module Datadog
42
42
  def active_test_suite(test_suite_name)
43
43
  end
44
44
 
45
+ def shutdown!
46
+ end
47
+
45
48
  private
46
49
 
47
50
  def skip_tracing(block = nil)