speedshop-cloudwatch 0.2.0 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19039fb6a3ef58a84cae232f0c6a501451c2ee7d52a49762aca926f9e22f733c
4
- data.tar.gz: f8084e387ec56912545f0c4b04edf6f26210f57fb015189578311ce5dfc421d2
3
+ metadata.gz: 6cff6ca0b03ef4fa6214a14bfe89384b72593aec69ec8fb93288460e3b4228a0
4
+ data.tar.gz: 1505eadbc697749442550a5ad22df30fa545c27302b6ba2b5c62a1a218b9c248
5
5
  SHA512:
6
- metadata.gz: 2b62fe5780fd5646a230f51c8b5d4cd4dfb2ef47915cf9ddd983dbe0af00c97d5b52106c4e74729b771c3466a4d872ef2965653c5f2e3606a405158a71f65ffd
7
- data.tar.gz: e49872a3eb6a96dc6a51f14defcf77b6139d3a2bb7479e0c33600fbfb956f291337ab32f549e7beb40befa3094f3a51ee6859fd10008b42b7784c9f24c37ebc7
6
+ metadata.gz: 986e68cf85c6c4d06c606312838d701e7309f9fb6a20879b23e1353360ae03a29f056c1c8129ac11e0b10cf381226eabeec7a76a160b0555dc1b8f243815d10b
7
+ data.tar.gz: 76bc9802a6107aa1f153c096be8bfc94897084cd380646657b10a45cb7dbcbf2e7e98e797fc86c0b87d7084628810e9389bf982f3e9126cd69a0d6b1a7687f9a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.2] - 2026-09-07
11
+
12
+ ### Changed
13
+ - Preserve metric distributions with CloudWatch `Values` and `Counts` instead of reducing repeated observations to StatisticSets, enabling percentile statistics without changing application configuration.
14
+
15
+ ## [0.2.1] - 2026-06-09
16
+
17
+ ### Fixed
18
+ - Improved Rack request queue time parsing for common `X-Request-Start` and `X-Queue-Start` formats, including `t=` prefixes, seconds, milliseconds, microseconds, and comma-separated header values.
19
+ - Subtracted Puma request body wait time from Rack queue time when `env["puma.request_body_wait"]` is available, so slow uploads are not counted as upstream queueing.
20
+
8
21
  ## [0.2.0] - 2026-04-13
9
22
 
10
23
  ### Added
@@ -21,5 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
34
  ### Added
22
35
  - Initial public release.
23
36
 
37
+ [Unreleased]: https://github.com/speedshop/speedshop-cloudwatch/compare/v0.2.2...HEAD
38
+ [0.2.2]: https://github.com/speedshop/speedshop-cloudwatch/compare/v0.2.1...v0.2.2
39
+ [0.2.1]: https://github.com/speedshop/speedshop-cloudwatch/compare/v0.2.0...v0.2.1
24
40
  [0.2.0]: https://github.com/speedshop/speedshop-cloudwatch/compare/v0.1.0...v0.2.0
25
41
  [0.1.0]: https://github.com/speedshop/speedshop-cloudwatch/releases/tag/v0.1.0
data/README.md CHANGED
@@ -92,6 +92,16 @@ end
92
92
  > [!WARNING]
93
93
  > Setting `config.interval` to less than 60 seconds automatically enables [high-resolution storage](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/publishingMetrics.html#high-resolution-metrics) (1-second granularity) in CloudWatch, which incurs additional costs.
94
94
 
95
+ ### Percentiles and distribution statistics
96
+
97
+ When a metric has multiple observations in one reporting period, the reporter publishes them using CloudWatch `Values` and `Counts`. Repeated values are frequency-encoded, and distributions with more than CloudWatch's limit of 150 unique values are split across multiple metric datums. Requests retain the 20-datum cap and use a conservative serialized-size bound to stay within CloudWatch's uncompressed 1 MiB limit. An individual datum exceeding that bound is logged and discarded without preventing other datums from being sent.
98
+
99
+ This preserves the observed distribution, allowing CloudWatch to calculate `p50`, `p95`, `p99`, trimmed means, and other distribution statistics in addition to Average, Sum, Minimum, Maximum, and SampleCount. Observations discarded because `config.queue_max_size` was exceeded are not included.
100
+
101
+ CloudWatch does not provide percentile statistics for metrics containing negative values. Explicit `statistic_values:` input remains supported, but neither it nor historical StatisticSets recovers discarded distributions.
102
+
103
+ Explicit Yabeda gauge aggregation strategies retain their existing behavior: `most_recent` publishes the last value and `max` publishes the maximum value.
104
+
95
105
  ### Environment Control
96
106
 
97
107
  **By default, the reporter only runs in production.** The environment is detected from `RAILS_ENV`, `RACK_ENV`, and defaults to `"development"`.
@@ -144,7 +154,9 @@ If you're using Rails, we'll automatically insert the correct middleware into th
144
154
 
145
155
  If you're using some other Rack-based framework, insert the `Speedshop::Cloudwatch::Rack` high up (i.e. first) in the stack.
146
156
 
147
- You will need a reverse proxy, such as nginx, adding an `X-Request-Start` or `X-Queue-Start` header (containing the time since the Unix epoch in milliseconds) to incoming requests. See [New Relic's instructions](https://docs.newrelic.com/docs/apm/applications-menu/features/configure-request-queue-reporting/) for more about how to do this.
157
+ You will need a reverse proxy, such as nginx, adding an `X-Request-Start` or `X-Queue-Start` header to incoming requests. The header may use common queue-time formats such as epoch milliseconds (`1512379167574`), seconds with decimals (`t=1512379167.574`), or microseconds (`t=1570633834463123`). See [New Relic's instructions](https://docs.newrelic.com/docs/apm/applications-menu/features/configure-request-queue-reporting/) for more about how to do this.
158
+
159
+ When Puma exposes `env["puma.request_body_wait"]`, we subtract it from queue time so slow request-body uploads are not counted as upstream queueing.
148
160
 
149
161
  We report the following metrics:
150
162
 
@@ -208,7 +220,7 @@ We report the following metrics:
208
220
  QueueLatency - Time job spent waiting in queue before execution (Seconds)
209
221
  ```
210
222
 
211
- This metric includes QueueName dimension and is aggregated per interval using CloudWatch StatisticSets.
223
+ This metric includes a QueueName dimension and preserves the per-interval distribution using CloudWatch `Values` and `Counts`.
212
224
 
213
225
  ## Yabeda
214
226
 
@@ -162,7 +162,7 @@ module Speedshop
162
162
  description: "Time a request spent waiting in the reverse proxy before " \
163
163
  "reaching the application. High values indicate requests " \
164
164
  "backing up before reaching your application server.",
165
- source: "(Time.now.to_f * 1000) - HTTP_X_REQUEST_START"
165
+ source: "parsed X-Request-Start/X-Queue-Start timestamp minus Puma request body wait"
166
166
  )
167
167
  ],
168
168
 
@@ -171,8 +171,8 @@ module Speedshop
171
171
  name: :QueueLatency,
172
172
  unit: "Seconds",
173
173
  description: "Time a job spent waiting in the queue before execution " \
174
- "started. Values are aggregated into CloudWatch " \
175
- "StatisticSets per reporting interval.",
174
+ "started. Values are frequency-encoded without discarding " \
175
+ "the per-interval distribution.",
176
176
  source: "Time.now.to_f - job.enqueued_at"
177
177
  )
178
178
  ]
@@ -4,17 +4,57 @@ module Speedshop
4
4
  module Cloudwatch
5
5
  module Observations
6
6
  module Rack
7
+ class HeaderTimestampParser
8
+ MIN_EPOCH = Time.utc(2000, 1, 1).to_f
9
+ FUTURE_TOLERANCE = 30.0
10
+ DIVISORS = [1_000_000.0, 1_000.0, 1.0].freeze
11
+ NUMBER_RE = /[+-]?(?:\d+(?:\.\d+)?|\.\d+)/
12
+ T_EQUALS_RE = /t\s*=\s*(#{NUMBER_RE.source})/i
13
+
14
+ def parse(value, now:)
15
+ header_value = value.to_s.split(",", 2).first.to_s.strip
16
+ return if header_value.empty?
17
+
18
+ token = header_value[T_EQUALS_RE, 1] || header_value[NUMBER_RE, 0]
19
+ normalize(Float(token), now) if token
20
+ rescue ArgumentError, TypeError
21
+ end
22
+
23
+ private
24
+
25
+ def normalize(raw, now)
26
+ max = now + FUTURE_TOLERANCE
27
+ divisor = DIVISORS.find { |d| (raw / d).between?(MIN_EPOCH, max) }
28
+ raw / divisor if divisor
29
+ end
30
+ end
31
+
7
32
  module_function
8
33
 
9
34
  def request_queue_time(env, now_ms: current_time_ms)
10
- header = env["HTTP_X_REQUEST_START"] || env["HTTP_X_QUEUE_START"]
11
- return unless header
35
+ now = now_ms / 1_000.0
36
+ request_start = header_timestamp_parser.parse(env["HTTP_X_REQUEST_START"], now: now) ||
37
+ header_timestamp_parser.parse(env["HTTP_X_QUEUE_START"], now: now)
38
+ return unless request_start
12
39
 
13
- now_ms - header.gsub("t=", "").to_f
40
+ queue_time_ms = (now - request_start) * 1_000.0
41
+ return if queue_time_ms.negative?
42
+
43
+ [queue_time_ms - (request_body_wait_ms(env) || 0), 0.0].max
14
44
  end
15
45
 
16
46
  def current_time_ms
17
- Time.now.to_f * 1000
47
+ Time.now.to_f * 1_000.0
48
+ end
49
+
50
+ def header_timestamp_parser
51
+ @header_timestamp_parser ||= HeaderTimestampParser.new
52
+ end
53
+
54
+ def request_body_wait_ms(env)
55
+ wait_ms = Float(env["puma.request_body_wait"])
56
+ wait_ms if wait_ms.finite? && !wait_ms.negative?
57
+ rescue ArgumentError, TypeError
18
58
  end
19
59
  end
20
60
  end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "singleton"
4
+ require_relative "request_size"
4
5
 
5
6
  module Speedshop
6
7
  module Cloudwatch
7
8
  class Reporter
9
+ MAX_VALUES_PER_DATUM = 150
10
+
8
11
  include Singleton
9
12
 
10
13
  def initialize
@@ -195,7 +198,10 @@ module Speedshop
195
198
  timestamp: m[:timestamp],
196
199
  dimensions: m[:dimensions]
197
200
  }
198
- if m[:statistic_values]
201
+ if m[:values]
202
+ datum[:values] = m[:values]
203
+ datum[:counts] = m[:counts]
204
+ elsif m[:statistic_values]
199
205
  datum[:statistic_values] = m[:statistic_values]
200
206
  else
201
207
  datum[:value] = m[:value]
@@ -206,91 +212,95 @@ module Speedshop
206
212
  end
207
213
 
208
214
  def send_batches(namespace, metric_data)
209
- metric_data.each_slice(20) do |batch|
210
- config.client.put_metric_data(namespace: namespace, metric_data: batch)
215
+ overhead = RequestSize.request_overhead(namespace)
216
+ batch = []
217
+ size = overhead
218
+ metric_data.each do |datum|
219
+ datum_size = RequestSize.bound(datum)
220
+ if overhead + datum_size > RequestSize::LIMIT
221
+ Speedshop::Cloudwatch.log_error("Dropping oversized CloudWatch datum: #{datum[:metric_name]} (request size bound exceeds 1 MiB)")
222
+ next
223
+ end
224
+ if batch.size == 20 || size + datum_size > RequestSize::LIMIT
225
+ config.client.put_metric_data(namespace: namespace, metric_data: batch)
226
+ batch = []
227
+ size = overhead
228
+ end
229
+ batch << datum
230
+ size += datum_size
211
231
  end
232
+ config.client.put_metric_data(namespace: namespace, metric_data: batch) unless batch.empty?
212
233
  end
213
234
 
214
235
  def aggregate_namespace_metrics(ns_metrics)
215
- group_metrics(ns_metrics).map { |items| aggregate_group(items) }
236
+ group_metrics(ns_metrics).flat_map { |items| aggregate_group(items) }
216
237
  end
217
238
 
218
239
  def group_metrics(ns_metrics)
219
240
  groups = {}
220
241
  ns_metrics.each do |m|
221
- key = [m[:metric_name], m[:unit], normalized_dimensions_key(m[:dimensions]), m[:aggregation_strategy]]
242
+ key = [
243
+ m[:metric_name],
244
+ m[:unit],
245
+ normalized_dimensions_key(m[:dimensions]),
246
+ m[:aggregation_strategy],
247
+ m.key?(:statistic_values),
248
+ timestamp_bucket(m[:timestamp])
249
+ ]
222
250
  (groups[key] ||= []) << m
223
251
  end
224
252
  groups.values
225
253
  end
226
254
 
227
255
  def aggregate_group(items)
228
- return items.first if items.size == 1
256
+ return items if items.size == 1
229
257
 
230
258
  strategy = items.first[:aggregation_strategy]
231
- return aggregate_most_recent_group(items) if strategy == :most_recent
232
- return aggregate_max_group(items) if strategy == :max
259
+ return [items.last] if strategy == :most_recent
260
+ return [items.max_by { |item| item[:value].to_f }] if strategy == :max
261
+ return [merge_statistic_values_group(items)] if items.first[:statistic_values]
233
262
 
234
- sample_count, sum, minimum, maximum = aggregate_values(items)
235
- {
236
- metric_name: items.first[:metric_name],
237
- unit: items.first[:unit],
238
- dimensions: items.first[:dimensions],
239
- timestamp: Time.now,
240
- statistic_values: build_statistic_values(sample_count, sum, minimum, maximum)
241
- }
263
+ aggregate_distribution_group(items)
242
264
  end
243
265
 
244
- def aggregate_values(items)
245
- sample_count = 0.0
246
- sum = 0.0
247
- minimum = Float::INFINITY
248
- maximum = -Float::INFINITY
249
-
250
- items.each do |item|
251
- if item[:statistic_values]
252
- sv = item[:statistic_values]
253
- sc = sv[:sample_count].to_f
254
- sample_count += sc
255
- sum += sv[:sum].to_f
256
- minimum = [minimum, sv[:minimum].to_f].min
257
- maximum = [maximum, sv[:maximum].to_f].max
258
- elsif item.key?(:value)
259
- v = item[:value].to_f
260
- sample_count += 1.0
261
- sum += v
262
- minimum = [minimum, v].min
263
- maximum = [maximum, v].max
264
- end
266
+ def aggregate_distribution_group(items)
267
+ frequencies = items.each_with_object(Hash.new(0)) do |item, counts|
268
+ counts[item[:value].to_f] += 1
265
269
  end
266
270
 
267
- [sample_count, sum, minimum, maximum]
268
- end
269
-
270
- def aggregate_most_recent_group(items)
271
- items.last
272
- end
273
-
274
- def aggregate_max_group(items)
275
- items.max_by { |item| item_value_for_max(item) }
276
- end
277
-
278
- def item_value_for_max(item)
279
- return item[:statistic_values][:maximum].to_f if item[:statistic_values]
280
- return item[:value].to_f if item.key?(:value)
281
-
282
- -Float::INFINITY
271
+ frequencies.sort_by(&:first).each_slice(MAX_VALUES_PER_DATUM).map do |slice|
272
+ {
273
+ metric_name: items.first[:metric_name],
274
+ unit: items.first[:unit],
275
+ dimensions: items.first[:dimensions],
276
+ timestamp: timestamp_bucket(items.first[:timestamp]),
277
+ values: slice.map(&:first),
278
+ counts: slice.map(&:last)
279
+ }
280
+ end
283
281
  end
284
282
 
285
- def build_statistic_values(sample_count, sum, minimum, maximum)
283
+ def merge_statistic_values_group(items)
284
+ statistic_values = items.map { |item| item[:statistic_values] }
286
285
  {
287
- sample_count: sample_count,
288
- sum: sum,
289
- minimum: minimum.finite? ? minimum : 0.0,
290
- maximum: maximum.finite? ? maximum : 0.0
286
+ metric_name: items.first[:metric_name],
287
+ unit: items.first[:unit],
288
+ dimensions: items.first[:dimensions],
289
+ timestamp: timestamp_bucket(items.first[:timestamp]),
290
+ statistic_values: {
291
+ sample_count: statistic_values.sum { |values| values[:sample_count].to_f },
292
+ sum: statistic_values.sum { |values| values[:sum].to_f },
293
+ minimum: statistic_values.map { |values| values[:minimum].to_f }.min,
294
+ maximum: statistic_values.map { |values| values[:maximum].to_f }.max
295
+ }
291
296
  }
292
297
  end
293
298
 
299
+ def timestamp_bucket(timestamp)
300
+ period = [config.interval.to_i, 1].max
301
+ Time.at((timestamp.to_f / period).floor * period)
302
+ end
303
+
294
304
  def normalized_dimensions_key(dims)
295
305
  (dims || []).sort_by { |d| d[:name].to_s }.map { |d| "#{d[:name]}=#{d[:value]}" }.join("|")
296
306
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Speedshop
4
+ module Cloudwatch
5
+ module RequestSize
6
+ LIMIT = 1024 * 1024
7
+
8
+ # Upper bound for uncompressed PutMetricData Query bodies, not Ruby object size.
9
+ # Every supported leaf's full Query key (including MetricData.member.20,
10
+ # Dimensions/Values/Counts indices), '=' and '&' fits within 128 bytes.
11
+ # Percent encoding expands each UTF-8 byte by at most 3. The 64-byte floor
12
+ # covers SDK numeric/timestamp formatting independently of Ruby's to_s.
13
+ # Container overhead is deliberately overcounted, including empty lists.
14
+ def self.bound(value)
15
+ case value
16
+ when Hash
17
+ 128 + value.values.sum { |item| bound(item) }
18
+ when Array
19
+ 128 + value.sum { |item| bound(item) }
20
+ else
21
+ 128 + 3 * [value.to_s.bytesize, 64].max
22
+ end
23
+ end
24
+
25
+ def self.request_overhead(namespace)
26
+ # Action, Version, Namespace key, and separators.
27
+ 512 + bound(namespace)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Speedshop
4
4
  module Cloudwatch
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: speedshop-cloudwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nate Berkopec
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-04-13 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: aws-sdk-cloudwatch
@@ -83,6 +82,7 @@ files:
83
82
  - lib/speedshop/cloudwatch/rack.rb
84
83
  - lib/speedshop/cloudwatch/railtie.rb
85
84
  - lib/speedshop/cloudwatch/reporter.rb
85
+ - lib/speedshop/cloudwatch/request_size.rb
86
86
  - lib/speedshop/cloudwatch/sidekiq.rb
87
87
  - lib/speedshop/cloudwatch/version.rb
88
88
  - lib/speedshop/cloudwatch/yabeda.rb
@@ -94,7 +94,6 @@ metadata:
94
94
  allowed_push_host: https://rubygems.org
95
95
  homepage_uri: https://github.com/nateberkopec/speedshop-cloudwatch
96
96
  source_code_uri: https://github.com/nateberkopec/speedshop-cloudwatch
97
- post_install_message:
98
97
  rdoc_options: []
99
98
  require_paths:
100
99
  - lib
@@ -109,8 +108,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
108
  - !ruby/object:Gem::Version
110
109
  version: '0'
111
110
  requirements: []
112
- rubygems_version: 3.1.6
113
- signing_key:
111
+ rubygems_version: 3.6.9
114
112
  specification_version: 4
115
113
  summary: Ruby application integration with AWS CloudWatch for Puma, Rack, Sidekiq,
116
114
  and ActiveJob