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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +14 -2
- data/lib/speedshop/cloudwatch/metrics.rb +3 -3
- data/lib/speedshop/cloudwatch/observations/rack.rb +44 -4
- 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 +4 -6
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,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
|
|
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
|
|
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: "
|
|
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
|
|
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
|
]
|
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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 *
|
|
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[:
|
|
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,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.
|
|
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:
|
|
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.
|
|
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
|