goodsorcery 0.1.1 → 0.3.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +35 -7
  4. data/lib/generators/goodsorcery/install/templates/initializer.rb +8 -2
  5. data/lib/goodsorcery/client.rb +13 -4
  6. data/lib/goodsorcery/configuration/performance_settings.rb +37 -0
  7. data/lib/goodsorcery/configuration/validation.rb +10 -0
  8. data/lib/goodsorcery/configuration.rb +24 -10
  9. data/lib/goodsorcery/defaults.rb +4 -0
  10. data/lib/goodsorcery/delivery.rb +3 -12
  11. data/lib/goodsorcery/errors.rb +0 -4
  12. data/lib/goodsorcery/integrations/active_job.rb +4 -2
  13. data/lib/goodsorcery/integrations/rails/error_subscriber.rb +1 -5
  14. data/lib/goodsorcery/integrations/rails.rb +3 -2
  15. data/lib/goodsorcery/integrations/sidekiq.rb +18 -2
  16. data/lib/goodsorcery/integrations.rb +0 -6
  17. data/lib/goodsorcery/performance/agent.rb +103 -0
  18. data/lib/goodsorcery/performance/aggregator.rb +63 -0
  19. data/lib/goodsorcery/performance/call_site.rb +61 -0
  20. data/lib/goodsorcery/performance/flusher.rb +224 -0
  21. data/lib/goodsorcery/performance/histogram.rb +23 -0
  22. data/lib/goodsorcery/performance/job_timing.rb +106 -0
  23. data/lib/goodsorcery/performance/measurement.rb +117 -0
  24. data/lib/goodsorcery/performance/net_http.rb +37 -0
  25. data/lib/goodsorcery/performance/payloads.rb +110 -0
  26. data/lib/goodsorcery/performance/process_label.rb +22 -0
  27. data/lib/goodsorcery/performance/recorder.rb +202 -0
  28. data/lib/goodsorcery/performance/span_list.rb +156 -0
  29. data/lib/goodsorcery/performance/sql_normalizer.rb +149 -0
  30. data/lib/goodsorcery/performance/subscribers.rb +140 -0
  31. data/lib/goodsorcery/performance/trace_buffer.rb +78 -0
  32. data/lib/goodsorcery/performance/trace_builder.rb +121 -0
  33. data/lib/goodsorcery/performance/transaction_stats.rb +93 -0
  34. data/lib/goodsorcery/performance/uuid_v7.rb +27 -0
  35. data/lib/goodsorcery/performance.rb +158 -0
  36. data/lib/goodsorcery/rack/middleware.rb +10 -3
  37. data/lib/goodsorcery/status_report.rb +1 -2
  38. data/lib/goodsorcery/transport.rb +12 -1
  39. data/lib/goodsorcery/version.rb +1 -1
  40. data/lib/goodsorcery.rb +17 -0
  41. metadata +21 -4
  42. data/lib/goodsorcery/delivery/active_job_queue.rb +0 -71
  43. data/lib/goodsorcery/delivery/custom.rb +0 -27
  44. data/lib/goodsorcery/delivery_job.rb +0 -26
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Performance
5
+ # Per-process, per-minute metrics (DESIGN.md 8.4). Keyed by the minute in
6
+ # which a unit of work finished (integer Unix minutes), then kind, then
7
+ # name. At most MAX_TRANSACTIONS names per kind per minute (the rest are
8
+ # counted under "(other)") and MAX_PENDING_MINUTES minutes are held.
9
+ class Aggregator
10
+ MAX_TRANSACTIONS = 500
11
+ MAX_PENDING_MINUTES = 5
12
+ OTHER = "(other)"
13
+
14
+ attr_reader :dropped_minutes
15
+
16
+ def initialize
17
+ @lock = Mutex.new
18
+ @minutes = {}
19
+ @dropped_minutes = 0
20
+ end
21
+
22
+ def record(minute, sample)
23
+ @lock.synchronize do
24
+ names = (minute_table(minute)[sample.kind] ||= {})
25
+ name = names.key?(sample.name) || names.size < MAX_TRANSACTIONS ? sample.name : OTHER
26
+ (names[name] ||= TransactionStats.new).add(sample, keep_labels: name != OTHER)
27
+ end
28
+ end
29
+
30
+ # Removes and returns the minutes up to and including +last_minute+
31
+ # (all of them when nil), oldest first: [[minute, {kind => {name => stats}}]].
32
+ def take(last_minute = nil)
33
+ @lock.synchronize do
34
+ taken = @minutes.keys.sort.select { |minute| last_minute.nil? || minute <= last_minute }
35
+ taken.map { |minute| [minute, @minutes.delete(minute)] }
36
+ end
37
+ end
38
+
39
+ def minutes
40
+ @lock.synchronize { @minutes.keys.sort }
41
+ end
42
+
43
+ def empty?
44
+ @lock.synchronize { @minutes.empty? }
45
+ end
46
+
47
+ private
48
+
49
+ def minute_table(minute)
50
+ table = @minutes[minute]
51
+ return table if table
52
+
53
+ drop_oldest while @minutes.size >= MAX_PENDING_MINUTES
54
+ @minutes[minute] = {}
55
+ end
56
+
57
+ def drop_oldest
58
+ @minutes.delete(@minutes.keys.min)
59
+ @dropped_minutes += 1
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Performance
5
+ # Where the app ran a repeated query: the first stack frame that is the
6
+ # app's own code (not a gem, not this library). Captured inside the
7
+ # sql.active_record callback; the code lines are read only when a trace
8
+ # is built.
9
+ class CallSite
10
+ GEM_LIB = "#{File.expand_path("../..", __dir__)}/".freeze
11
+ CONTEXT_BEFORE = 2
12
+ CONTEXT_AFTER = 2
13
+ MAX_LINE_LENGTH = 200
14
+ MAX_PATH_LENGTH = 1_024
15
+ MAX_METHOD_LENGTH = 255
16
+
17
+ attr_reader :path, :line, :label
18
+
19
+ # => a CallSite, or nil when no app frame is on the stack.
20
+ def self.capture(paths)
21
+ Thread.each_caller_location do |location|
22
+ path = location.absolute_path || location.path
23
+ next if path.nil? || path.start_with?(GEM_LIB) || !paths.in_app?(path)
24
+
25
+ return new(path, location.lineno, location.base_label)
26
+ end
27
+ nil
28
+ end
29
+
30
+ def initialize(path, line, label)
31
+ @path = path
32
+ @line = line
33
+ @label = label
34
+ end
35
+
36
+ def to_h(paths, line_cache: nil)
37
+ hash = { "file" => Text.limit(paths.relative(path), MAX_PATH_LENGTH), "line" => line,
38
+ "method" => label && Text.limit(label, MAX_METHOD_LENGTH) }.compact
39
+ context = line_cache && code_context(line_cache)
40
+ hash["context"] = context if context
41
+ hash
42
+ end
43
+
44
+ private
45
+
46
+ # Two lines before and after the call (5 lines of up to 200 chars).
47
+ def code_context(line_cache)
48
+ lines = line_cache.lines(path)
49
+ return nil unless lines && line.between?(1, lines.size)
50
+
51
+ first = [line - CONTEXT_BEFORE, 1].max
52
+ last = [line + CONTEXT_AFTER, lines.size].min
53
+ { "start_line" => first, "lines" => limited(lines[(first - 1)..(last - 1)]) }
54
+ end
55
+
56
+ def limited(lines)
57
+ lines.map { |text| Text.limit(text, MAX_LINE_LENGTH) }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Performance
5
+ # Sends the closed minutes once a minute from one background thread per
6
+ # process ("goodsorcery-apm"), at the minute boundary plus a random
7
+ # 2–20 s offset that spreads the server's load (DESIGN.md 8.7).
8
+ class Flusher
9
+ OFFSET_RANGE = (2.0..20.0)
10
+
11
+ attr_reader :offset, :outbox
12
+
13
+ def initialize(agent:, log:, clock: nil, offset: nil, transport: nil)
14
+ @agent = agent
15
+ @log = log
16
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_REALTIME) }
17
+ @offset = offset || rand(OFFSET_RANGE)
18
+ @outbox = Outbox.new(log: log, transport: transport || -> { GoodSorcery.client.transport })
19
+ @lock = Mutex.new
20
+ @wake = ConditionVariable.new
21
+ @flush_lock = Mutex.new
22
+ @stopped = false
23
+ @dropped_seen = 0
24
+ end
25
+
26
+ # The next wall-clock time (seconds) to wake at after +now+.
27
+ def next_wake_at(now)
28
+ (((now / 60).floor + 1) * 60) + @offset
29
+ end
30
+
31
+ def ensure_started
32
+ return if @stopped || @thread&.alive?
33
+
34
+ @lock.synchronize { start_thread unless @stopped || @thread&.alive? }
35
+ end
36
+
37
+ def running?
38
+ @thread&.alive? || false
39
+ end
40
+
41
+ # Collects the closed minutes (every minute with +include_open+) and
42
+ # sends what is pending, stopping at +deadline+ (monotonic seconds).
43
+ # With the :test delivery the payloads are stored instead.
44
+ def flush(include_open: false, deadline: nil)
45
+ @flush_lock.synchronize do
46
+ collect(include_open ? nil : (@clock.call / 60).floor - 1)
47
+ test_delivery? ? @outbox.store_for_tests : @outbox.send_pending(deadline)
48
+ end
49
+ true
50
+ rescue StandardError => e
51
+ @log.warn_every(:apm_flush_error, 60, "performance flush failed (#{e.class})")
52
+ false
53
+ end
54
+
55
+ # Stops the thread and sends everything, including the open minute.
56
+ def shutdown(timeout)
57
+ thread = stop_thread
58
+ thread&.join([timeout, 1].min) unless thread == Thread.current
59
+ flush(include_open: true, deadline: Performance.monotonic + timeout)
60
+ end
61
+
62
+ # Performance monitoring was turned off: stop and forget what is pending.
63
+ def stop!
64
+ stop_thread
65
+ @outbox.clear
66
+ end
67
+
68
+ private
69
+
70
+ def start_thread
71
+ @thread = Thread.new do
72
+ Thread.current.report_on_exception = false
73
+ run
74
+ end
75
+ @thread.name = "goodsorcery-apm"
76
+ Delivery::ExitHook.install
77
+ end
78
+
79
+ def stop_thread
80
+ @lock.synchronize do
81
+ @stopped = true
82
+ @wake.broadcast
83
+ @thread
84
+ end
85
+ end
86
+
87
+ def run
88
+ until @stopped
89
+ wait_until(next_wake_at(@clock.call))
90
+ flush unless @stopped
91
+ end
92
+ end
93
+
94
+ def wait_until(target)
95
+ @lock.synchronize do
96
+ loop do
97
+ remaining = target - @clock.call
98
+ break if @stopped || remaining <= 0
99
+
100
+ @wake.wait(@lock, remaining)
101
+ end
102
+ end
103
+ end
104
+
105
+ def collect(last_minute)
106
+ minutes = @agent.aggregator.take(last_minute)
107
+ traces = @agent.trace_buffer.take(last_minute)
108
+ log_dropped_minutes
109
+ @outbox.add(Payloads.metrics(minutes) + Payloads.traces(traces))
110
+ end
111
+
112
+ def log_dropped_minutes
113
+ dropped = @agent.aggregator.dropped_minutes
114
+ return if dropped == @dropped_seen
115
+
116
+ @dropped_seen = dropped
117
+ @log.warn_every(:apm_minutes_dropped, 60, "dropped #{dropped} minute(s) of performance metrics so far")
118
+ end
119
+
120
+ def test_delivery?
121
+ GoodSorcery.configuration.delivery == :test
122
+ end
123
+ end
124
+
125
+ # The payloads waiting to be sent, oldest first, and what to do with each
126
+ # result (DESIGN.md 8.7): 5xx or a network error keeps the payload (with
127
+ # its batch_id) for the next wake, up to MAX_ATTEMPTS; 429 pauses sending
128
+ # until Retry-After; 400/401/413/415/422 drop it; 404 turns performance
129
+ # monitoring off for the process. At most MAX_PENDING_MINUTES minutes of
130
+ # metrics and MAX_TRACE_BODIES trace bodies wait; the oldest go first.
131
+ class Outbox
132
+ MAX_ATTEMPTS = 3
133
+ MAX_TRACE_BODIES = 5
134
+ Pending = Struct.new(:body, :attempts)
135
+
136
+ def initialize(log:, transport:)
137
+ @log = log
138
+ @transport = transport
139
+ @pending = []
140
+ @resume_at = nil
141
+ end
142
+
143
+ def add(bodies)
144
+ bodies.each { |body| @pending << Pending.new(body, 0) }
145
+ drop_oldest(:metrics?) while pending_minutes > Aggregator::MAX_PENDING_MINUTES
146
+ drop_oldest(:traces?) while @pending.count { |item| traces?(item) } > MAX_TRACE_BODIES
147
+ end
148
+
149
+ def size = @pending.size
150
+ def clear = @pending.clear
151
+
152
+ def send_pending(deadline = nil)
153
+ return if paused?
154
+
155
+ while (item = @pending.first)
156
+ break if deadline && Performance.monotonic > deadline
157
+ break unless handled?(@transport.call.post_apm(item.body.path, item.body.json), item)
158
+ end
159
+ end
160
+
161
+ def store_for_tests
162
+ @pending.each { |item| Performance.store_test_payload(item.body.path, JSON.parse(item.body.json)) }
163
+ @pending.clear
164
+ end
165
+
166
+ private
167
+
168
+ def metrics?(item) = item.body.minutes.positive?
169
+ def traces?(item) = item.body.minutes.zero?
170
+ def pending_minutes = @pending.sum { |item| item.body.minutes }
171
+
172
+ def drop_oldest(kind)
173
+ @pending.delete_at(@pending.index { |item| send(kind, item) })
174
+ @log.warn_every(:apm_dropped, 60, "dropped unsent performance data (the server did not accept it in time)")
175
+ end
176
+
177
+ # => true to go on with the next payload.
178
+ def handled?(result, item)
179
+ return sent if result.success?
180
+ return pause(result.retry_after) if result.rate_limited?
181
+ return server_without_apm if result.not_found?
182
+ return failed_attempt(result, item) if result.retryable?
183
+
184
+ @pending.shift
185
+ @log.warn_every(:apm_rejected, 60, "the server rejected performance data (#{result.description}); dropped")
186
+ true
187
+ end
188
+
189
+ def sent
190
+ @pending.shift
191
+ true
192
+ end
193
+
194
+ def pause(seconds)
195
+ @resume_at = Performance.monotonic + seconds.to_f
196
+ false
197
+ end
198
+
199
+ def paused?
200
+ return false unless @resume_at
201
+ return true if Performance.monotonic < @resume_at
202
+
203
+ @resume_at = nil
204
+ false
205
+ end
206
+
207
+ def failed_attempt(result, item)
208
+ item.attempts += 1
209
+ return false if item.attempts < MAX_ATTEMPTS
210
+
211
+ @pending.shift
212
+ @log.warn_every(:apm_failed, 60, "could not send performance data (#{result.description}); dropped")
213
+ false
214
+ end
215
+
216
+ def server_without_apm
217
+ @log.warn_once(:apm_not_found, "the server has no performance endpoints; performance monitoring is off")
218
+ @pending.clear
219
+ Performance.disable!
220
+ false
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Performance
5
+ # The fixed log-scale duration buckets (scheme "gs-v1", DESIGN.md 8.6),
6
+ # shared literally with the server. Bucket i holds durations d with
7
+ # BOUNDS_MS[i - 1] <= d < BOUNDS_MS[i]; bucket 30 holds 48 s and up.
8
+ module Histogram
9
+ SCHEME = "gs-v1"
10
+ BOUNDS_MS = [
11
+ 1, 1.5, 2, 3, 4.5, 6.5, 9.5, 14, 20, 28,
12
+ 40, 60, 85, 125, 180, 260, 380, 550, 800, 1_150,
13
+ 1_700, 2_450, 3_550, 5_100, 7_500, 11_000, 16_000, 23_000, 33_000, 48_000
14
+ ].freeze
15
+ LAST_INDEX = BOUNDS_MS.size
16
+
17
+ # The bucket index (0..30) for a duration in milliseconds.
18
+ def self.index(duration_ms)
19
+ BOUNDS_MS.bsearch_index { |bound| bound > duration_ms } || LAST_INDEX
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Performance
5
+ # Labels a job recorder: name, adapter, queue, source and queue wait
6
+ # (start − scheduled_at or enqueued_at, never negative). Subclasses read
7
+ # them from an Active Job instance or a Sidekiq job hash.
8
+ class JobTiming
9
+ def label(recorder)
10
+ recorder.name = name
11
+ recorder.adapter = adapter
12
+ recorder.queue = queue
13
+ recorder.source = SourceLocation.for(source_class)
14
+ recorder.queue_wait_ms = wait_ms(recorder.started_wall)
15
+ recorder.job_details = self
16
+ end
17
+
18
+ private
19
+
20
+ def wait_ms(started_wall)
21
+ since = epoch_seconds(queued_since)
22
+ since ? [(started_wall - since) * 1000.0, 0.0].max : nil
23
+ end
24
+
25
+ # A Time, epoch seconds (Float), epoch milliseconds (Sidekiq 8) or
26
+ # an ISO 8601 string.
27
+ def epoch_seconds(value)
28
+ case value
29
+ when Numeric then Event::Fields.epoch_seconds(value).to_f
30
+ when String then Time.iso8601(value).to_f
31
+ when nil then nil
32
+ else value.to_time.to_f
33
+ end
34
+ rescue ArgumentError, NoMethodError
35
+ nil
36
+ end
37
+ end
38
+
39
+ # An Active Job execution (any adapter).
40
+ class ActiveJobTiming < JobTiming
41
+ REQUEUED_AT_KEY = :__goodsorcery_requeued_at
42
+
43
+ # While the block runs, jobs wait from +time+: when the adapter queued
44
+ # this run again without updating the job's own enqueued_at (a Sidekiq
45
+ # retry).
46
+ def self.requeued_at(time)
47
+ previous = Thread.current[REQUEUED_AT_KEY]
48
+ Thread.current[REQUEUED_AT_KEY] = time
49
+ yield
50
+ ensure
51
+ Thread.current[REQUEUED_AT_KEY] = previous
52
+ end
53
+
54
+ def initialize(job)
55
+ super()
56
+ @job = job
57
+ end
58
+
59
+ def name = @job.class.name
60
+ def queue = @job.queue_name&.to_s
61
+ def source_class = @job.class.name
62
+ def executions = @job.executions
63
+
64
+ def adapter
65
+ klass = @job.class
66
+ name = klass.queue_adapter_name if klass.respond_to?(:queue_adapter_name)
67
+ name&.to_s
68
+ end
69
+
70
+ private
71
+
72
+ def queued_since
73
+ requeued = Thread.current[REQUEUED_AT_KEY]
74
+ return requeued if requeued
75
+
76
+ scheduled = @job.respond_to?(:scheduled_at) ? @job.scheduled_at : nil
77
+ scheduled || @job.enqueued_at
78
+ end
79
+ end
80
+
81
+ # A Sidekiq job not wrapped by Active Job.
82
+ class SidekiqTiming < JobTiming
83
+ def initialize(job, queue, worker)
84
+ super()
85
+ @job = job
86
+ @queue = queue || job["queue"]
87
+ @worker = worker
88
+ end
89
+
90
+ def name = @job["class"].to_s
91
+ def adapter = "sidekiq"
92
+ def queue = @queue&.to_s
93
+ def source_class = @worker&.class&.name || name
94
+
95
+ # retry_count is absent on the first run and 0 on the first retry.
96
+ def executions
97
+ retries = @job["retry_count"]
98
+ retries.is_a?(Integer) ? retries + 2 : 1
99
+ end
100
+
101
+ private
102
+
103
+ def queued_since = @job["enqueued_at"]
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Performance
5
+ # What integrations and subscribers call while a unit of work runs:
6
+ # timing a request or job, reaching the fiber's recorder, and linking a
7
+ # reported error. Extended into GoodSorcery::Performance.
8
+ module Measurement
9
+ # Wraps a Rack request (the middleware calls this when active?).
10
+ def measure_request(env)
11
+ recorder = start(:web)
12
+ return yield unless recorder
13
+
14
+ recorder.env = env
15
+ recorder.http_method = env["REQUEST_METHOD"]
16
+ response = yield
17
+ recorder.response_status = response.first.to_i if response.is_a?(Array)
18
+ response
19
+ rescue Exception => e # rubocop:disable Lint/RescueException
20
+ recorder&.raised!(exception_status(e))
21
+ raise
22
+ ensure
23
+ finish(recorder) if recorder
24
+ end
25
+
26
+ # Wraps a job execution; +timing+ is a JobTiming.
27
+ def measure_job(timing)
28
+ recorder = start(:job)
29
+ return yield unless recorder
30
+
31
+ label_job(recorder, timing)
32
+ yield
33
+ rescue Exception # rubocop:disable Lint/RescueException
34
+ recorder&.raised!
35
+ raise
36
+ ensure
37
+ finish(recorder) if recorder
38
+ end
39
+
40
+ # Yields the active recorder of this fiber; a failure stops recording
41
+ # this unit of work and is logged by class name.
42
+ def with_recorder
43
+ recorder = Thread.current[RECORDER_KEY]
44
+ return nil if recorder.nil? || recorder.broken?
45
+
46
+ yield recorder
47
+ rescue StandardError => e
48
+ recorder&.broken!
49
+ log.warn_every(:apm_subscriber_error, 60,
50
+ "performance recording failed (#{e.class}); a unit of work is not measured")
51
+ nil
52
+ end
53
+
54
+ def http_recorder
55
+ return nil if Thread.current[SUPPRESS_KEY]
56
+
57
+ recorder = Thread.current[RECORDER_KEY]
58
+ recorder unless recorder.nil? || recorder.broken?
59
+ end
60
+
61
+ def record_http(recorder, started, host, request, response)
62
+ status = response.respond_to?(:code) ? response.code.to_i : nil
63
+ recorder.add_http([started, monotonic], request.method, host, request.path, status)
64
+ rescue StandardError => e
65
+ recorder.broken!
66
+ log.warn_every(:apm_http_error, 60, "could not record an HTTP call (#{e.class})")
67
+ end
68
+
69
+ # Client#notify links the first reported error to the unit of work.
70
+ def note_error(event_id)
71
+ recorder = Thread.current[RECORDER_KEY]
72
+ recorder.error_event_id ||= event_id if recorder && event_id
73
+ end
74
+
75
+ private
76
+
77
+ # The status Rails' ShowExceptions answers an escaping exception with
78
+ # (404 for ActiveRecord::RecordNotFound, 400 for ParameterMissing…);
79
+ # nil outside Rails.
80
+ def exception_status(error)
81
+ return nil unless defined?(::ActionDispatch::ExceptionWrapper)
82
+
83
+ template_error = defined?(::ActionView::Template::Error) && error.is_a?(::ActionView::Template::Error)
84
+ error = error.cause || error if template_error
85
+ ::ActionDispatch::ExceptionWrapper.status_code_for_exception(error.class.name)
86
+ rescue StandardError
87
+ nil
88
+ end
89
+
90
+ def label_job(recorder, timing)
91
+ timing.label(recorder)
92
+ rescue StandardError => e
93
+ recorder.broken!
94
+ log.warn_every(:apm_job_label_error, 60, "could not read job details for performance data (#{e.class})")
95
+ end
96
+ end
97
+
98
+ # The payloads the :test delivery keeps instead of sending them
99
+ # (GoodSorcery.test_performance_payloads).
100
+ module TestPayloads
101
+ PAYLOADS_LOCK = Mutex.new
102
+ PAYLOADS = [] # rubocop:disable Style/MutableConstant
103
+
104
+ def store_test_payload(path, body)
105
+ PAYLOADS_LOCK.synchronize { PAYLOADS << { "path" => path, "body" => body } }
106
+ end
107
+
108
+ def test_payloads
109
+ PAYLOADS_LOCK.synchronize { PAYLOADS.dup }
110
+ end
111
+
112
+ def clear_test_payloads
113
+ PAYLOADS_LOCK.synchronize { PAYLOADS.clear }
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Performance
5
+ # Prepended to Net::HTTP: one "http" span per request made while a unit
6
+ # of work is recorded (DESIGN.md 8.3). It always calls super and never
7
+ # rescues the app's exceptions. The recursive call Net::HTTP#request
8
+ # makes through #start is skipped, and nothing is recorded while
9
+ # Performance.suppressed? (the gem's own requests).
10
+ module NetHttp
11
+ ACTIVE_KEY = :__goodsorcery_http_request
12
+
13
+ def self.install
14
+ ::Net::HTTP.prepend(self) unless installed?
15
+ end
16
+
17
+ def self.installed?
18
+ defined?(::Net::HTTP) ? ::Net::HTTP.ancestors.include?(self) : false
19
+ end
20
+
21
+ def request(req, body = nil, &)
22
+ recorder = Thread.current[ACTIVE_KEY] ? nil : Performance.http_recorder
23
+ return super unless recorder
24
+
25
+ Thread.current[ACTIVE_KEY] = true
26
+ started = Performance.monotonic
27
+ response = nil
28
+ begin
29
+ response = super
30
+ ensure
31
+ Thread.current[ACTIVE_KEY] = nil
32
+ Performance.record_http(recorder, started, address, req, response)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end