goodsorcery 0.1.1 → 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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +30 -1
  4. data/lib/generators/goodsorcery/install/templates/initializer.rb +7 -0
  5. data/lib/goodsorcery/client.rb +15 -1
  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 +3 -0
  9. data/lib/goodsorcery/defaults.rb +4 -0
  10. data/lib/goodsorcery/integrations/active_job.rb +4 -1
  11. data/lib/goodsorcery/integrations/rails.rb +3 -2
  12. data/lib/goodsorcery/integrations/sidekiq.rb +18 -2
  13. data/lib/goodsorcery/performance/agent.rb +104 -0
  14. data/lib/goodsorcery/performance/aggregator.rb +63 -0
  15. data/lib/goodsorcery/performance/call_site.rb +61 -0
  16. data/lib/goodsorcery/performance/flusher.rb +224 -0
  17. data/lib/goodsorcery/performance/histogram.rb +23 -0
  18. data/lib/goodsorcery/performance/job_timing.rb +106 -0
  19. data/lib/goodsorcery/performance/measurement.rb +117 -0
  20. data/lib/goodsorcery/performance/net_http.rb +37 -0
  21. data/lib/goodsorcery/performance/payloads.rb +110 -0
  22. data/lib/goodsorcery/performance/process_label.rb +22 -0
  23. data/lib/goodsorcery/performance/recorder.rb +202 -0
  24. data/lib/goodsorcery/performance/span_list.rb +156 -0
  25. data/lib/goodsorcery/performance/sql_normalizer.rb +149 -0
  26. data/lib/goodsorcery/performance/subscribers.rb +140 -0
  27. data/lib/goodsorcery/performance/trace_buffer.rb +78 -0
  28. data/lib/goodsorcery/performance/trace_builder.rb +121 -0
  29. data/lib/goodsorcery/performance/transaction_stats.rb +93 -0
  30. data/lib/goodsorcery/performance/uuid_v7.rb +27 -0
  31. data/lib/goodsorcery/performance.rb +158 -0
  32. data/lib/goodsorcery/rack/middleware.rb +10 -3
  33. data/lib/goodsorcery/transport.rb +12 -1
  34. data/lib/goodsorcery/version.rb +1 -1
  35. data/lib/goodsorcery.rb +17 -0
  36. 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