speedshop-cloudwatch 0.2.1 → 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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +11 -1
- data/lib/speedshop/cloudwatch/metrics.rb +2 -2
- data/lib/speedshop/cloudwatch/reporter.rb +68 -58
- data/lib/speedshop/cloudwatch/request_size.rb +31 -0
- data/lib/speedshop/cloudwatch/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6cff6ca0b03ef4fa6214a14bfe89384b72593aec69ec8fb93288460e3b4228a0
|
|
4
|
+
data.tar.gz: 1505eadbc697749442550a5ad22df30fa545c27302b6ba2b5c62a1a218b9c248
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 986e68cf85c6c4d06c606312838d701e7309f9fb6a20879b23e1353360ae03a29f056c1c8129ac11e0b10cf381226eabeec7a76a160b0555dc1b8f243815d10b
|
|
7
|
+
data.tar.gz: 76bc9802a6107aa1f153c096be8bfc94897084cd380646657b10a45cb7dbcbf2e7e98e797fc86c0b87d7084628810e9389bf982f3e9126cd69a0d6b1a7687f9a
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ 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
|
+
|
|
8
15
|
## [0.2.1] - 2026-06-09
|
|
9
16
|
|
|
10
17
|
### Fixed
|
|
@@ -27,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
27
34
|
### Added
|
|
28
35
|
- Initial public release.
|
|
29
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
|
|
30
39
|
[0.2.1]: https://github.com/speedshop/speedshop-cloudwatch/compare/v0.2.0...v0.2.1
|
|
31
40
|
[0.2.0]: https://github.com/speedshop/speedshop-cloudwatch/compare/v0.1.0...v0.2.0
|
|
32
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"`.
|
|
@@ -210,7 +220,7 @@ We report the following metrics:
|
|
|
210
220
|
QueueLatency - Time job spent waiting in queue before execution (Seconds)
|
|
211
221
|
```
|
|
212
222
|
|
|
213
|
-
This metric includes QueueName dimension and
|
|
223
|
+
This metric includes a QueueName dimension and preserves the per-interval distribution using CloudWatch `Values` and `Counts`.
|
|
214
224
|
|
|
215
225
|
## Yabeda
|
|
216
226
|
|
|
@@ -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
|
|
175
|
-
"
|
|
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
|
]
|
|
@@ -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[:
|
|
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
|
-
|
|
210
|
-
|
|
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).
|
|
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 = [
|
|
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
|
|
256
|
+
return items if items.size == 1
|
|
229
257
|
|
|
230
258
|
strategy = items.first[:aggregation_strategy]
|
|
231
|
-
return
|
|
232
|
-
return
|
|
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
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
283
|
+
def merge_statistic_values_group(items)
|
|
284
|
+
statistic_values = items.map { |item| item[:statistic_values] }
|
|
286
285
|
{
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: speedshop-cloudwatch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nate Berkopec
|
|
@@ -82,6 +82,7 @@ files:
|
|
|
82
82
|
- lib/speedshop/cloudwatch/rack.rb
|
|
83
83
|
- lib/speedshop/cloudwatch/railtie.rb
|
|
84
84
|
- lib/speedshop/cloudwatch/reporter.rb
|
|
85
|
+
- lib/speedshop/cloudwatch/request_size.rb
|
|
85
86
|
- lib/speedshop/cloudwatch/sidekiq.rb
|
|
86
87
|
- lib/speedshop/cloudwatch/version.rb
|
|
87
88
|
- lib/speedshop/cloudwatch/yabeda.rb
|