goodsorcery 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +37 -2
- data/lib/generators/goodsorcery/install/templates/initializer.rb +7 -0
- data/lib/goodsorcery/client.rb +15 -1
- data/lib/goodsorcery/configuration/performance_settings.rb +37 -0
- data/lib/goodsorcery/configuration/validation.rb +10 -0
- data/lib/goodsorcery/configuration.rb +3 -0
- data/lib/goodsorcery/defaults.rb +6 -0
- data/lib/goodsorcery/integrations/active_job.rb +4 -1
- data/lib/goodsorcery/integrations/rails.rb +3 -2
- data/lib/goodsorcery/integrations/sidekiq.rb +18 -2
- data/lib/goodsorcery/performance/agent.rb +104 -0
- data/lib/goodsorcery/performance/aggregator.rb +63 -0
- data/lib/goodsorcery/performance/call_site.rb +61 -0
- data/lib/goodsorcery/performance/flusher.rb +224 -0
- data/lib/goodsorcery/performance/histogram.rb +23 -0
- data/lib/goodsorcery/performance/job_timing.rb +106 -0
- data/lib/goodsorcery/performance/measurement.rb +117 -0
- data/lib/goodsorcery/performance/net_http.rb +37 -0
- data/lib/goodsorcery/performance/payloads.rb +110 -0
- data/lib/goodsorcery/performance/process_label.rb +22 -0
- data/lib/goodsorcery/performance/recorder.rb +202 -0
- data/lib/goodsorcery/performance/span_list.rb +156 -0
- data/lib/goodsorcery/performance/sql_normalizer.rb +149 -0
- data/lib/goodsorcery/performance/subscribers.rb +140 -0
- data/lib/goodsorcery/performance/trace_buffer.rb +78 -0
- data/lib/goodsorcery/performance/trace_builder.rb +121 -0
- data/lib/goodsorcery/performance/transaction_stats.rb +93 -0
- data/lib/goodsorcery/performance/uuid_v7.rb +27 -0
- data/lib/goodsorcery/performance.rb +158 -0
- data/lib/goodsorcery/rack/middleware.rb +10 -3
- data/lib/goodsorcery/transport.rb +12 -1
- data/lib/goodsorcery/version.rb +1 -1
- data/lib/goodsorcery.rb +17 -0
- metadata +21 -1
|
@@ -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
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Performance
|
|
5
|
+
# Builds the request bodies for POST /api/v1/apm/metrics and
|
|
6
|
+
# /api/v1/apm/traces (INGEST_API.md › Performance monitoring), split so
|
|
7
|
+
# each stays under the server's size and count limits. Each body gets a
|
|
8
|
+
# UUIDv7 batch_id that it keeps on every retry.
|
|
9
|
+
module Payloads
|
|
10
|
+
METRICS_PATH = "/api/v1/apm/metrics"
|
|
11
|
+
TRACES_PATH = "/api/v1/apm/traces"
|
|
12
|
+
METRICS_MAX_BYTES = 450 * 1_024
|
|
13
|
+
TRACES_MAX_BYTES = 1_800 * 1_024
|
|
14
|
+
MAX_TRANSACTIONS_PER_MINUTE = 1_000
|
|
15
|
+
MAX_TRACES_PER_PAYLOAD = 50
|
|
16
|
+
|
|
17
|
+
# One JSON body with its path and how many minutes it carries.
|
|
18
|
+
Body = Struct.new(:path, :json, :minutes)
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# +minutes+ as Aggregator#take returns them.
|
|
23
|
+
def metrics(minutes)
|
|
24
|
+
chunks = MetricsChunks.new
|
|
25
|
+
minutes.each do |minute, kinds|
|
|
26
|
+
kinds.each do |kind, names|
|
|
27
|
+
names.each { |name, stats| chunks.add(minute, JSON.generate(stats.to_h(kind, name))) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
chunks.bodies
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Every trace (slowest first, as TraceBuffer#take orders them), in as
|
|
34
|
+
# few bodies as the size limit and the server's 50 traces per body
|
|
35
|
+
# allow. A flush can carry more than one minute (the exit flush adds
|
|
36
|
+
# the open one); the Outbox bounds how many bodies wait.
|
|
37
|
+
def traces(traces)
|
|
38
|
+
jsons = traces.map { |trace| JSON.generate(trace) }
|
|
39
|
+
Packer.pack(jsons, TRACES_MAX_BYTES, MAX_TRACES_PER_PAYLOAD).map do |chunk|
|
|
40
|
+
Body.new(TRACES_PATH, envelope("traces" => "[#{chunk.join(",")}]"), 0)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def header
|
|
45
|
+
{ "batch_id" => UuidV7.generate, "notifier" => { "name" => NOTIFIER_NAME, "version" => VERSION } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The envelope as JSON, with already serialised members spliced in.
|
|
49
|
+
def envelope(fields, extra = {})
|
|
50
|
+
head = JSON.generate(header.merge(extra))
|
|
51
|
+
members = fields.map { |key, json| "#{JSON.generate(key)}:#{json}" }
|
|
52
|
+
"#{head[0..-2]},#{members.join(",")}}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Greedy packing of JSON strings into groups under a byte budget.
|
|
56
|
+
module Packer
|
|
57
|
+
def self.pack(jsons, max_bytes, max_items)
|
|
58
|
+
jsons.each_with_object([[]]) do |json, chunks|
|
|
59
|
+
current = chunks.last
|
|
60
|
+
size = current.sum(&:bytesize) + json.bytesize
|
|
61
|
+
chunks << [] if !current.empty? && (size > max_bytes || current.size >= max_items)
|
|
62
|
+
chunks.last << json
|
|
63
|
+
end.reject(&:empty?)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Packs serialised transactions into metrics bodies: a new body when the
|
|
69
|
+
# current one would pass the size limit or a minute's transaction limit.
|
|
70
|
+
class MetricsChunks
|
|
71
|
+
def initialize
|
|
72
|
+
@bodies = []
|
|
73
|
+
@current = {}
|
|
74
|
+
@bytes = 0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def add(minute, json)
|
|
78
|
+
start_new_body if full?(minute, json)
|
|
79
|
+
(@current[minute] ||= []) << json
|
|
80
|
+
@bytes += json.bytesize + 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def bodies
|
|
84
|
+
start_new_body
|
|
85
|
+
@bodies
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def full?(minute, json)
|
|
91
|
+
return false if @current.empty?
|
|
92
|
+
|
|
93
|
+
@bytes + json.bytesize > Payloads::METRICS_MAX_BYTES ||
|
|
94
|
+
@current.fetch(minute, []).size >= Payloads::MAX_TRANSACTIONS_PER_MINUTE
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def start_new_body
|
|
98
|
+
return if @current.empty?
|
|
99
|
+
|
|
100
|
+
minutes = @current.map do |minute, jsons|
|
|
101
|
+
"{\"minute\":#{JSON.generate(Performance.minute_iso(minute))},\"transactions\":[#{jsons.join(",")}]}"
|
|
102
|
+
end
|
|
103
|
+
json = Payloads.envelope({ "minutes" => "[#{minutes.join(",")}]" }, "histogram" => Histogram::SCHEME)
|
|
104
|
+
@bodies << Payloads::Body.new(Payloads::METRICS_PATH, json, @current.size)
|
|
105
|
+
@current = {}
|
|
106
|
+
@bytes = 0
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Performance
|
|
5
|
+
# A short name for the current process, from its title: "Puma worker 3",
|
|
6
|
+
# "Puma", "Sidekiq", else the program's basename. Nothing else from the
|
|
7
|
+
# command line is used (it can carry arguments).
|
|
8
|
+
module ProcessLabel
|
|
9
|
+
MAX_LENGTH = 255
|
|
10
|
+
|
|
11
|
+
def self.current(title = $PROGRAM_NAME)
|
|
12
|
+
text = title.to_s
|
|
13
|
+
worker = text[/\Apuma: cluster worker (\d+)/, 1]
|
|
14
|
+
return "Puma worker #{worker}" if worker
|
|
15
|
+
return "Puma" if text.start_with?("puma")
|
|
16
|
+
return "Sidekiq" if text.start_with?("sidekiq")
|
|
17
|
+
|
|
18
|
+
Text.limit(File.basename(text.split.first.to_s), MAX_LENGTH)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|