statsd-instrument 3.11.0 → 3.11.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: 1f2b45a3e809ef0e5ca26b7faf52512632705f62120b3b73987a10282e19ed76
4
- data.tar.gz: 7d7574e77adea4dc54fcf0355b83e1392623bcf2d4ecccd60a2cc101f62c84bb
3
+ metadata.gz: 51853f9d4b70fc39649e251e7e93ff3d77b2bcca1d776182f82de8219ea86886
4
+ data.tar.gz: fce22a816682f0bc25510b6a68248a64c93ba983e7640b57ec01dbb0b985c9d2
5
5
  SHA512:
6
- metadata.gz: bb416ef206605fb8204acec2b0501e4722a2bd51d81fbc1083c4a4a96203a2838dd329355908ee83b64b7a3943f8f8af862cbf7d8433363d4452e68f14292f95
7
- data.tar.gz: bf0597922ce2dbe8837ef29b0de1f7c4fa1ca0e9623dc4d15628661b49059577d31499c46164a14e98744c5e765d68b407a15f14153c212ec245f3d90ed691e7
6
+ metadata.gz: 4a06bd60427001260e206243f4db74ecb3d8225c1c5e3f8f9764239bc40901457cf621d7f5fbd0936d39fa0765145499672a9fb0a9c22025ac8611f7051f7154
7
+ data.tar.gz: f74279e49141844c564d896e1d04b88b6b9f66585e56401a4ee52f7f4cb8dcc5fbe6a99b8427fbfa45ca1be843fef5a8c9357fb629129372880ac5020aceab31
@@ -9,7 +9,7 @@ jobs:
9
9
  strategy:
10
10
  fail-fast: false
11
11
  matrix:
12
- ruby: ['2.7', '3.0', '3.1', '3.2', '3.3', 'ruby-head', 'jruby-9.4.9.0', 'truffleruby-25.0.0']
12
+ ruby: ['3.0', '3.1', '3.2', '3.3', '3.4', 'ruby-head', 'jruby-10.1.1.0', 'truffleruby-34.0.1']
13
13
 
14
14
  steps:
15
15
  - uses: actions/checkout@v4
data/CHANGELOG.md CHANGED
@@ -6,6 +6,16 @@ section below.
6
6
 
7
7
  ## Unreleased changes
8
8
 
9
+ ## Version 3.11.2
10
+
11
+ - Widen the `CompiledMetric` tag-combination cache key from 32 to 57 bits, eliminating
12
+ recurring hash collisions on hot metrics while keeping all intermediates within Fixnum
13
+ range (no new allocations on the emit path).
14
+
15
+ ## Version 3.11.1
16
+
17
+ - Fix `CompiledMetric::Distribution` applying `sample_rate` twice.
18
+
9
19
  ## Version 3.11.0
10
20
 
11
21
  - [#418](https://github.com/Shopify/statsd-instrument/pull/418) - Prevent misuse of `CompiledMetric` definitions by requiring a subclass when defining one.
data/Gemfile CHANGED
@@ -13,15 +13,12 @@ gem "rubocop-shopify", require: false
13
13
  gem "benchmark-ips"
14
14
  gem "dogstatsd-ruby", "~> 5.0", require: false
15
15
  gem "simplecov", require: false, group: :test
16
+ # logger is no longer bundled with newer Ruby and JRuby releases.
17
+ gem "logger"
16
18
 
17
19
  platform :mri do
18
20
  # only if Ruby is MRI && >= 3.2
19
21
  if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2")
20
22
  gem "vernier", require: false
21
23
  end
22
-
23
- # From Ruby >= 3.5, logger is not part of the stdlib anymore
24
- if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.5")
25
- gem "logger"
26
- end
27
24
  end
@@ -285,6 +285,25 @@ module StatsD
285
285
  StatsD::Instrument::VOID
286
286
  end
287
287
 
288
+ # Emits a pre-sampled compiled distribution metric.
289
+ # This method is used when the generated compiled metric method already made
290
+ # the sampling decision before measuring block latency.
291
+ #
292
+ # @param precompiled_datagram [StatsD::Instrument::CompiledMetric::PrecompiledDatagram]
293
+ # The precompiled metric datagram
294
+ # @param value [Numeric] The metric value
295
+ # @return [void]
296
+ # @api private
297
+ def emit_presampled_precompiled_distribution_metric(precompiled_datagram, value)
298
+ if @enable_aggregation
299
+ @aggregator.aggregate_precompiled_timing_metric(precompiled_datagram, value)
300
+ return StatsD::Instrument::VOID
301
+ end
302
+
303
+ emit(precompiled_datagram.to_datagram(value))
304
+ StatsD::Instrument::VOID
305
+ end
306
+
288
307
  # Emits a precompiled metric for high-performance use cases.
289
308
  # This method is used by {StatsD::Instrument::CompiledMetric::Gauge} to emit metrics
290
309
  # with minimal allocations.
@@ -177,6 +177,10 @@ module StatsD
177
177
  default_val = default_value
178
178
  default_val_assignment = default_val.nil? ? "" : " = #{default_val.inspect}"
179
179
  allow_block = allow_measuring_latency
180
+ # Block-enabled compiled metrics run through generate_block_handler first,
181
+ # which applies sample_rate before emitting. Use the presampled client helper
182
+ # here so distributions don't make a second sampling decision.
183
+ client_emit_method = allow_block ? "emit_presampled_precompiled_#{method}_metric" : "emit_precompiled_#{method}_metric"
180
184
 
181
185
  method_code = <<~RUBY
182
186
  def self.#{method}(__value__#{default_val_assignment}, #{tag_names.map { |name| "#{name}:" }.join(", ")})
@@ -185,10 +189,13 @@ module StatsD
185
189
 
186
190
  # Compute hash of tag values for cache lookup using rotate-left + XOR.
187
191
  # Rotation makes it order-dependent (unlike plain XOR), preventing collisions
188
- # when tag values are swapped. We mask to 32 bits to avoid Bignum allocations
189
- # from left shifts on 64-bit hash values.
190
- __cache_key__ = #{tag_names.first}.hash & 0xFFFFFFFF
191
- #{tag_names.drop(1).map { |name| "__cache_key__ = (((__cache_key__ << 5) | (__cache_key__ >> 27)) ^ #{name}.hash) & 0xFFFFFFFF" }.join("\n")}
192
+ # when tag values are swapped. The mask keeps 57 bits of entropy, wide enough
193
+ # that birthday collisions are negligible at real tag-combination volumes.
194
+ # 57 bits is the widest key for which the intermediate (__cache_key__ << 5)
195
+ # stays within Fixnum range (2**62 - 1 on 64-bit CRuby) and never allocates
196
+ # a Bignum.
197
+ __cache_key__ = #{tag_names.first}.hash & 0x01FFFFFFFFFFFFFF
198
+ #{tag_names.drop(1).map { |name| "__cache_key__ = (((__cache_key__ << 5) | (__cache_key__ >> 52)) ^ #{name}.hash) & 0x01FFFFFFFFFFFFFF" }.join("\n")}
192
199
 
193
200
  # Look up or create a PrecompiledDatagram
194
201
  __datagram__ =
@@ -218,7 +225,7 @@ module StatsD
218
225
 
219
226
  __datagram__ ||= PrecompiledDatagram.new([#{tag_names.join(", ")}], @datagram_blueprint, @sample_rate)
220
227
 
221
- @singleton_client.emit_precompiled_#{method}_metric(__datagram__, __value__)
228
+ @singleton_client.#{client_emit_method}(__datagram__, __value__)
222
229
  __return_value__
223
230
  end
224
231
  RUBY
@@ -233,12 +240,16 @@ module StatsD
233
240
  method = method_name
234
241
  default_val = default_value
235
242
  allow_block = allow_measuring_latency
243
+ # Block-enabled compiled metrics run through generate_block_handler first,
244
+ # which applies sample_rate before emitting. Use the presampled client helper
245
+ # here so distributions don't make a second sampling decision.
246
+ client_emit_method = allow_block ? "emit_presampled_precompiled_#{method}_metric" : "emit_precompiled_#{method}_metric"
236
247
 
237
248
  instance_eval(<<~RUBY, __FILE__, __LINE__ + 1)
238
249
  def self.#{method}(__value__ = #{default_val.inspect})
239
250
  __return_value__ = StatsD::Instrument::VOID
240
251
  #{generate_block_handler if allow_block}
241
- @singleton_client.emit_precompiled_#{method}_metric(@static_datagram, __value__)
252
+ @singleton_client.#{client_emit_method}(@static_datagram, __value__)
242
253
  __return_value__
243
254
  end
244
255
  RUBY
@@ -77,7 +77,7 @@ module StatsD
77
77
  (?:\|\#(?<tags>(?:[^\|,]+(?:,[^\|,]+)*)))?
78
78
  \n? # In some implementations, the datagram may include a trailing newline.
79
79
  \z
80
- }x
80
+ }x.freeze
81
81
 
82
82
  def parsed_datagram
83
83
  @parsed ||= if (match_info = PARSER.match(@source))
@@ -69,7 +69,7 @@ module StatsD
69
69
  (?:\|m:(?<message>[^\|]+))?
70
70
  \n? # In some implementations, the datagram may include a trailing newline.
71
71
  \z
72
- }x
72
+ }x.freeze
73
73
 
74
74
  # |k:my-key|p:low|s:source|t:success|
75
75
  EVENT_PARSER = %r{
@@ -84,7 +84,7 @@ module StatsD
84
84
  (?:\|\#(?<tags>(?:[^\|,]+(?:,[^\|,]+)*)))?
85
85
  \n? # In some implementations, the datagram may include a trailing newline.
86
86
  \z
87
- }x
87
+ }x.freeze
88
88
 
89
89
  PARSER = Regexp.union(StatsD::Instrument::Datagram::PARSER, SERVICE_CHECK_PARSER, EVENT_PARSER)
90
90
  end
@@ -19,7 +19,7 @@ module RuboCop
19
19
 
20
20
  MSG = "Do not use the return value of StatsD metric methods"
21
21
 
22
- INVALID_PARENTS = [:lvasgn, :array, :pair, :send, :return, :yield]
22
+ INVALID_PARENTS = [:lvasgn, :array, :pair, :send, :return, :yield].freeze
23
23
 
24
24
  def on_send(node)
25
25
  if metric_method?(node) && node.arguments.last&.type != :block_pass
@@ -3,7 +3,7 @@
3
3
  module RuboCop
4
4
  module Cop
5
5
  module StatsD
6
- METRIC_METHODS = [:increment, :gauge, :measure, :set, :histogram, :distribution, :key_value]
6
+ METRIC_METHODS = [:increment, :gauge, :measure, :set, :histogram, :distribution, :key_value].freeze
7
7
 
8
8
  METAPROGRAMMING_METHODS = [
9
9
  :statsd_measure,
@@ -11,7 +11,7 @@ module RuboCop
11
11
  :statsd_count_success,
12
12
  :statsd_count_if,
13
13
  :statsd_count,
14
- ]
14
+ ].freeze
15
15
 
16
16
  SINGLETON_CONFIGURATION_METHODS = [
17
17
  :backend,
@@ -22,7 +22,7 @@ module RuboCop
22
22
  :"default_tags=",
23
23
  :default_sample_rate,
24
24
  :"default_sample_rate=",
25
- ]
25
+ ].freeze
26
26
 
27
27
  private
28
28
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StatsD
4
4
  module Instrument
5
- VERSION = "3.11.0"
5
+ VERSION = "3.11.2"
6
6
  end
7
7
  end
@@ -230,6 +230,43 @@ class CompiledMetricDistributionTest < Minitest::Test
230
230
  assert_equal(["env:production", "region:us-east", "service:web"], datagram.tags.sort)
231
231
  end
232
232
 
233
+ def test_sampled_distribution_value_uses_one_sampling_decision
234
+ sample_rate = 0.5
235
+ @sink.expects(:sample?).with(sample_rate).once.returns(true)
236
+
237
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Distribution) do
238
+ define(
239
+ name: "foo.bar",
240
+ tags: { shop_id: Integer },
241
+ sample_rate: sample_rate,
242
+ )
243
+ end
244
+
245
+ metric.distribution(5, shop_id: 123)
246
+
247
+ assert_equal(1, @sink.datagrams.size)
248
+ assert_equal("test.foo.bar:5|d|@0.5|#shop_id:123", @sink.datagrams.first.source)
249
+ end
250
+
251
+ def test_sampled_distribution_block_uses_one_sampling_decision
252
+ sample_rate = 0.5
253
+ @sink.expects(:sample?).with(sample_rate).once.returns(true)
254
+ Process.stubs(:clock_gettime).with(Process::CLOCK_MONOTONIC, :float_millisecond).returns(100.0, 125.0)
255
+
256
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Distribution) do
257
+ define(
258
+ name: "foo.bar",
259
+ sample_rate: sample_rate,
260
+ )
261
+ end
262
+
263
+ returned_value = metric.distribution { :result }
264
+
265
+ assert_equal(:result, returned_value)
266
+ assert_equal(1, @sink.datagrams.size)
267
+ assert_equal("test.foo.bar:25.0|d|@0.5", @sink.datagrams.first.source)
268
+ end
269
+
233
270
  def test_latency_as_value_when_block_provided
234
271
  metric = Class.new(StatsD::Instrument::CompiledMetric::Distribution) do
235
272
  define(
@@ -415,18 +452,17 @@ class CompiledMetricDistributionWithAggregationTest < Minitest::Test
415
452
  end
416
453
 
417
454
  def test_sample_rate_with_aggregation
418
- # When aggregating with sample_rate, sampling happens before aggregation
419
- # This test verifies that with a sample_rate >0, a subset of distributions are aggregated
455
+ sample_rate = 0.5
456
+ @sink.expects(:sample?).with(sample_rate).times(5).returns(false, true, false, false, true)
457
+
420
458
  metric = Class.new(StatsD::Instrument::CompiledMetric::Distribution) do
421
459
  define(
422
460
  name: "foo.bar",
423
461
  static_tags: { service: "web" },
424
- sample_rate: 0.5,
462
+ sample_rate: sample_rate,
425
463
  )
426
464
  end
427
465
 
428
- metric.stubs(:sample?).returns(false, true, false, false, true)
429
-
430
466
  metric.distribution(1)
431
467
  metric.distribution(2)
432
468
  metric.distribution(3)
@@ -285,10 +285,10 @@ class CompiledMetricDefinitionTest < Minitest::Test
285
285
 
286
286
  cache = metric.instance_variable_get(:@tag_combination_cache)
287
287
 
288
- # Compute cache keys using the rotate-left + XOR formula (32-bit bounded)
289
- # For single tag, it's the hash value masked to 32 bits
290
- cache_key_for_1 = 1.hash & 0xFFFFFFFF
291
- cache_key_for_2 = 2.hash & 0xFFFFFFFF
288
+ # Compute cache keys using the rotate-left + XOR formula (57-bit bounded)
289
+ # For single tag, it's the hash value masked to 57 bits
290
+ cache_key_for_1 = 1.hash & 0x01FFFFFFFFFFFFFF
291
+ cache_key_for_2 = 2.hash & 0x01FFFFFFFFFFFFFF
292
292
 
293
293
  # Store the cached datagram under the collision key
294
294
  cached_datagram = cache[cache_key_for_1]
@@ -312,6 +312,77 @@ class CompiledMetricDefinitionTest < Minitest::Test
312
312
  assert_includes(hash_collision_metric.tags, "metric_name:foo.bar")
313
313
  end
314
314
 
315
+ def test_cache_key_space_is_wider_than_32_bits
316
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
317
+ define(
318
+ name: "foo.bar",
319
+ tags: { shop_id: Integer },
320
+ )
321
+ end
322
+
323
+ # Hash seeds are process-local, so find a pair of tag values at runtime that
324
+ # shares its low 32 hash bits (collided under the previous 32-bit cache key)
325
+ # while still differing across the full 57-bit key space.
326
+ seen = {}
327
+ pair = nil
328
+ i = 0
329
+ until pair
330
+ key = i.hash & 0xFFFFFFFF
331
+ other = seen[key]
332
+ if other && other != i && (other.hash & 0x01FFFFFFFFFFFFFF) != (i.hash & 0x01FFFFFFFFFFFFFF)
333
+ pair = [other, i]
334
+ end
335
+ seen[key] ||= i
336
+ i += 1
337
+ end
338
+
339
+ metric.increment(1, shop_id: pair[0])
340
+ metric.increment(1, shop_id: pair[1])
341
+
342
+ cache = metric.instance_variable_get(:@tag_combination_cache)
343
+ assert_equal(2, cache.size)
344
+
345
+ collision_metric = @sink.datagrams.find do |datagram|
346
+ datagram.name == "test.statsd_instrument.compiled_metric.hash_collision_detected"
347
+ end
348
+ assert_nil(collision_metric, "Expected no hash collision metric for 57-bit-distinct keys")
349
+ end
350
+
351
+ def test_cache_key_computation_does_not_allocate
352
+ skip_on_jruby("JRuby does not support GC.stat(:total_allocated_objects)")
353
+
354
+ single_tag_metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
355
+ define(name: "foo.single", tags: { a: String })
356
+ end
357
+ multi_tag_metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
358
+ define(name: "foo.multi", tags: { a: String, b: String, c: String, d: String, e: String, f: String, g: String, h: String })
359
+ end
360
+
361
+ single_call = -> { single_tag_metric.increment(1, a: "a") }
362
+ multi_call = -> { multi_tag_metric.increment(1, a: "a", b: "b", c: "c", d: "d", e: "e", f: "f", g: "g", h: "h") }
363
+ measure = ->(callable) { count_allocations { 10.times { callable.call } } }
364
+ [single_call, multi_call].each { |c| measure.call(c) }
365
+
366
+ # With frozen string literals, the rotate-left + XOR chain is the only per-tag
367
+ # work on a cache hit. A cache key wide enough to escape Fixnum range would
368
+ # allocate one heap integer per chain step and show up as extra allocations
369
+ # for the multi-tag metric.
370
+ assert_equal(
371
+ measure.call(single_call),
372
+ measure.call(multi_call),
373
+ "Expected per-tag cache-key computation to allocate nothing",
374
+ )
375
+ end
376
+
377
+ private
378
+
379
+ def count_allocations
380
+ GC.start
381
+ before = GC.stat(:total_allocated_objects)
382
+ yield
383
+ GC.stat(:total_allocated_objects) - before
384
+ end
385
+
315
386
  def test_handles_default_tags_as_array
316
387
  StatsD.singleton_client = StatsD::Instrument::Client.new(
317
388
  sink: @sink,
@@ -18,7 +18,10 @@ module Rubocop
18
18
 
19
19
  def test_offense_statsd_prefix
20
20
  assert_offense('StatsD.prefix = "foo"')
21
+ # The interpolation is intentionally literal source passed to the cop under test.
22
+ # rubocop:disable Lint/InterpolationCheck
21
23
  assert_offense('"#{StatsD.prefix}.foo"')
24
+ # rubocop:enable Lint/InterpolationCheck
22
25
  end
23
26
 
24
27
  def test_offense_statsd_default_tags
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statsd-instrument
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.11.0
4
+ version: 3.11.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jesse Storimer
@@ -135,7 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
135
135
  - !ruby/object:Gem::Version
136
136
  version: '0'
137
137
  requirements: []
138
- rubygems_version: 4.0.9
138
+ rubygems_version: 4.0.18
139
139
  specification_version: 4
140
140
  summary: A StatsD client for Ruby apps
141
141
  test_files: