statsd-instrument 3.9.9 → 3.10.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.
@@ -0,0 +1,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class CompiledMetricDefinitionTest < Minitest::Test
6
+ def setup
7
+ super
8
+ @old_client = StatsD.singleton_client
9
+ @sink = StatsD::Instrument::CaptureSink.new(parent: StatsD::Instrument::NullSink.new)
10
+ StatsD.singleton_client = StatsD::Instrument::Client.new(
11
+ sink: @sink,
12
+ prefix: "test",
13
+ default_tags: [],
14
+ enable_aggregation: false,
15
+ )
16
+ end
17
+
18
+ def teardown
19
+ super
20
+ @sink.clear
21
+ StatsD.singleton_client = @old_client
22
+ end
23
+
24
+ def test_sanitizes_tag_names
25
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
26
+ define(
27
+ name: "foo.bar",
28
+ static_tags: { "tag|with|pipes" => "value", "tag,with,commas" => "value2" },
29
+ )
30
+ end
31
+
32
+ metric.increment(1)
33
+
34
+ datagram = @sink.datagrams.first
35
+ # Pipes and commas should be removed from tag names
36
+ assert(datagram.tags[0], "tag_with_pipes:value")
37
+ assert(datagram.tags[1], "tag_with_commas:value2")
38
+ end
39
+
40
+ def test_sanitizes_tag_values_in_static_tags
41
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
42
+ define(
43
+ name: "foo.bar",
44
+ static_tags: { service: "web|api" },
45
+ )
46
+ end
47
+
48
+ metric.increment(1)
49
+
50
+ datagram = @sink.datagrams.first
51
+ # Pipes should be removed from tag values
52
+ assert_equal(["service:webapi"], datagram.tags)
53
+ end
54
+
55
+ def test_sanitizes_dynamic_string_tag_values
56
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
57
+ define(
58
+ name: "foo.bar",
59
+ tags: { endpoint: String },
60
+ )
61
+ end
62
+
63
+ metric.increment(1, endpoint: "/api|v1,endpoint")
64
+
65
+ datagram = @sink.datagrams.first
66
+ # Pipes and commas should be removed
67
+ assert_equal(["endpoint:/apiv1endpoint"], datagram.tags)
68
+ end
69
+
70
+ def test_normalizes_metric_name
71
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
72
+ define(
73
+ name: "foo:bar|baz@qux",
74
+ )
75
+ end
76
+ metric.increment(1)
77
+
78
+ datagram = @sink.datagrams.first
79
+ # Special characters should be converted to underscores
80
+ assert_equal("test.foo_bar_baz_qux", datagram.name)
81
+ end
82
+
83
+ def test_raises_on_unsupported_tag_type
84
+ assert_raises(ArgumentError) do
85
+ Class.new(StatsD::Instrument::CompiledMetric::Counter) do
86
+ define(
87
+ name: "foo.bar",
88
+ tags: { invalid: Array },
89
+ )
90
+ end
91
+ end
92
+ end
93
+
94
+ def test_sample_rate_parameter
95
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
96
+ define(
97
+ name: "foo.bar",
98
+ static_tags: { service: "web" },
99
+
100
+ sample_rate: 0.5,
101
+ )
102
+ end
103
+
104
+ metric.increment(1)
105
+
106
+ assert_equal(1, @sink.datagrams.size)
107
+ assert_equal(0.5, @sink.datagrams.first.sample_rate)
108
+ end
109
+
110
+ def test_default_sample_rate_from_client
111
+ # Create a client with default sample rate
112
+ client = StatsD::Instrument::Client.new(
113
+ sink: @sink,
114
+ prefix: "test",
115
+ default_tags: [],
116
+ enable_aggregation: false,
117
+ default_sample_rate: 0.6,
118
+ )
119
+ StatsD.singleton_client = client
120
+
121
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
122
+ define(
123
+ name: "foo.bar",
124
+ )
125
+ end
126
+ metric.increment(1)
127
+ assert_equal(1, @sink.datagrams.size)
128
+ assert_equal(0.6, @sink.datagrams.first.sample_rate)
129
+ end
130
+
131
+ def test_sample_rate_default_to_1
132
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
133
+ define(
134
+ name: "foo.bar",
135
+ static_tags: { service: "web" },
136
+ )
137
+ end
138
+
139
+ metric.increment(5)
140
+
141
+ assert_equal(1, @sink.datagrams.size)
142
+ assert_equal(1.0, @sink.datagrams.first.sample_rate)
143
+ end
144
+
145
+ def test_sample_rate_omitted_when_1
146
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
147
+ define(
148
+ name: "foo.bar",
149
+ sample_rate: 1.0,
150
+ )
151
+ end
152
+
153
+ # With sample rate = 1.0, it should be omitted from the datagram
154
+ metric.increment(3)
155
+
156
+ assert_equal(1, @sink.datagrams.size)
157
+ datagram = @sink.datagrams.first
158
+ # Sample rate defaults to 1.0 when not present in datagram
159
+ assert_equal(1.0, datagram.sample_rate)
160
+ # Verify the source doesn't contain |@1.0
161
+ refute_includes(datagram.source, "|@")
162
+ end
163
+
164
+ def test_normalizes_symbol_tag_values
165
+ # Test with tag value that's a symbol (should hit the else clause)
166
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
167
+ define(
168
+ name: "foo.bar",
169
+ tags: { status: String },
170
+ )
171
+ end
172
+
173
+ # Pass a symbol as a tag value (not a common case but should be handled)
174
+ # This will be converted to string
175
+ metric.increment(1, status: :active)
176
+
177
+ datagram = @sink.datagrams.first
178
+ assert_equal(["status:active"], datagram.tags)
179
+ end
180
+
181
+ def test_supports_boolean_dynamic_tags
182
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
183
+ define(
184
+ name: "foo.bar",
185
+ tags: { enabled: :Boolean },
186
+ )
187
+ end
188
+
189
+ metric.increment(1, enabled: true)
190
+ assert_equal(["enabled:true"], @sink.datagrams.first.tags)
191
+
192
+ @sink.clear
193
+
194
+ metric.increment(1, enabled: false)
195
+ assert_equal(["enabled:false"], @sink.datagrams.first.tags)
196
+ end
197
+
198
+ def test_supports_symbol_dynamic_tags
199
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
200
+ define(
201
+ name: "foo.bar",
202
+ tags: { status: Symbol },
203
+ )
204
+ end
205
+
206
+ metric.increment(1, status: :active)
207
+ assert_equal(["status:active"], @sink.datagrams.first.tags)
208
+
209
+ @sink.clear
210
+
211
+ metric.increment(1, status: :inactive)
212
+ assert_equal(["status:inactive"], @sink.datagrams.first.tags)
213
+ end
214
+
215
+ def test_sanitizes_symbol_dynamic_tag_values
216
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
217
+ define(
218
+ name: "foo.bar",
219
+ tags: { status: Symbol },
220
+ )
221
+ end
222
+
223
+ metric.increment(1, status: :"active|with,special")
224
+ assert_equal(["status:activewithspecial"], @sink.datagrams.first.tags)
225
+ end
226
+
227
+ def test_handles_nil_tag_values
228
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
229
+ define(
230
+ name: "foo.bar",
231
+ tags: { shop_id: Integer, name: String, rate: Float },
232
+ )
233
+ end
234
+
235
+ metric.increment(1, shop_id: nil, name: nil, rate: nil)
236
+
237
+ assert_equal(1, @sink.datagrams.size)
238
+ assert_equal(["name:", "rate:", "shop_id:"], @sink.datagrams.first.tags.sort)
239
+ end
240
+
241
+ def test_emits_metric_when_cache_exceeded
242
+ # Create a metric with a very small cache size
243
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
244
+ define(
245
+ name: "foo.bar",
246
+ tags: { shop_id: Integer },
247
+
248
+ max_cache_size: 2,
249
+ )
250
+ end
251
+
252
+ # Clear any existing datagrams
253
+ @sink.clear
254
+
255
+ # Fill the cache (2 entries)
256
+ metric.increment(1, shop_id: 1)
257
+ metric.increment(1, shop_id: 2)
258
+
259
+ # Third entry brings us to max_cache_size. it should trigger cache exceeded (cache.size = 3 >= 2)
260
+ metric.increment(1, shop_id: 3)
261
+
262
+ # Find the cache exceeded metric (includes prefix)
263
+ cache_exceeded_metric = @sink.datagrams.find do |datagram|
264
+ datagram.name == "test.statsd_instrument.compiled_metric.cache_exceeded_total"
265
+ end
266
+
267
+ assert_equal(4, @sink.datagrams.size)
268
+ refute_nil(cache_exceeded_metric, "Expected cache exceeded metric to be emitted")
269
+ assert_equal(1, cache_exceeded_metric.value)
270
+ assert_includes(cache_exceeded_metric.tags, "metric_name:foo.bar")
271
+ assert_includes(cache_exceeded_metric.tags, "max_size:2")
272
+ end
273
+
274
+ def test_emits_metric_on_hash_collision
275
+ # Create a metric with a single tag
276
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
277
+ define(
278
+ name: "foo.bar",
279
+ tags: { shop_id: Integer },
280
+ )
281
+ end
282
+
283
+ # First call with shop_id=1 to populate cache
284
+ metric.increment(1, shop_id: 1)
285
+
286
+ cache = metric.instance_variable_get(:@tag_combination_cache)
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
292
+
293
+ # Store the cached datagram under the collision key
294
+ cached_datagram = cache[cache_key_for_1]
295
+ cache[cache_key_for_2] = cached_datagram
296
+
297
+ # Clear datagrams before the collision test
298
+ @sink.clear
299
+
300
+ # Now increment with the collision shop_id - this should detect the collision
301
+ # because the tag_values won't match
302
+ metric.increment(1, shop_id: 2)
303
+
304
+ # Find the hash collision metric (includes prefix)
305
+ hash_collision_metric = @sink.datagrams.find do |datagram|
306
+ datagram.name == "test.statsd_instrument.compiled_metric.hash_collision_detected"
307
+ end
308
+
309
+ refute_nil(hash_collision_metric, "Expected hash collision metric to be emitted")
310
+ assert_equal(1, hash_collision_metric.value)
311
+ # The metric name uses the normalized name (only : | @ are replaced, not .)
312
+ assert_includes(hash_collision_metric.tags, "metric_name:foo.bar")
313
+ end
314
+
315
+ def test_handles_default_tags_as_array
316
+ StatsD.singleton_client = StatsD::Instrument::Client.new(
317
+ sink: @sink,
318
+ prefix: "test",
319
+ default_tags: ["env:production", "region:us-east"],
320
+ enable_aggregation: false,
321
+ )
322
+
323
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
324
+ define(
325
+ name: "foo.bar",
326
+ static_tags: { service: "web" },
327
+ )
328
+ end
329
+
330
+ metric.increment(1)
331
+
332
+ datagram = @sink.datagrams.first
333
+ assert_equal(["env:production", "region:us-east", "service:web"], datagram.tags.sort)
334
+ end
335
+
336
+ def test_handles_default_tags_as_hash
337
+ StatsD.singleton_client = StatsD::Instrument::Client.new(
338
+ sink: @sink,
339
+ prefix: "test",
340
+ default_tags: { env: "production", region: "us-east" },
341
+ enable_aggregation: false,
342
+ )
343
+
344
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
345
+ define(
346
+ name: "foo.bar",
347
+ static_tags: { service: "web" },
348
+ )
349
+ end
350
+
351
+ metric.increment(1)
352
+
353
+ datagram = @sink.datagrams.first
354
+ assert_equal(["env:production", "region:us-east", "service:web"], datagram.tags.sort)
355
+ end
356
+
357
+ def test_handles_default_tags_as_string
358
+ StatsD.singleton_client = StatsD::Instrument::Client.new(
359
+ sink: @sink,
360
+ prefix: "test",
361
+ default_tags: "env:production",
362
+ enable_aggregation: false,
363
+ )
364
+
365
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
366
+ define(
367
+ name: "foo.bar",
368
+ static_tags: { service: "web" },
369
+ )
370
+ end
371
+
372
+ metric.increment(1)
373
+
374
+ datagram = @sink.datagrams.first
375
+ assert_equal(["env:production", "service:web"], datagram.tags.sort)
376
+ end
377
+
378
+ def test_allows_value_as_tag_name
379
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
380
+ define(
381
+ name: "foo.bar",
382
+ tags: { value: String, status: String },
383
+ )
384
+ end
385
+
386
+ metric.increment(1, value: "product", status: "active")
387
+
388
+ datagram = @sink.datagrams.first
389
+ assert_equal(["status:active", "value:product"], datagram.tags.sort)
390
+ end
391
+
392
+ def test_cache_key_is_order_dependent
393
+ # Verify that swapped tag values produce different cache entries
394
+ # This tests the fix for XOR-based cache keys which are commutative
395
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
396
+ define(
397
+ name: "foo.bar",
398
+ tags: { tag_a: :Boolean, tag_b: :Boolean },
399
+ )
400
+ end
401
+
402
+ # Emit with (true, false)
403
+ metric.increment(1, tag_a: true, tag_b: false)
404
+
405
+ # Emit with (false, true) - swapped values
406
+ metric.increment(1, tag_a: false, tag_b: true)
407
+
408
+ # Both should be cached separately
409
+ cache = metric.instance_variable_get(:@tag_combination_cache)
410
+ assert_equal(2, cache.size, "Swapped tag values should produce different cache entries")
411
+
412
+ # Verify the datagrams have different tags
413
+ assert_equal(2, @sink.datagrams.size)
414
+ assert_equal(["tag_a:true", "tag_b:false"], @sink.datagrams[0].tags)
415
+ assert_equal(["tag_a:false", "tag_b:true"], @sink.datagrams[1].tags)
416
+ end
417
+
418
+ def test_sample_rate
419
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
420
+ define(
421
+ name: "foo.bar",
422
+ sample_rate: 0.1337,
423
+ )
424
+ end
425
+
426
+ assert_equal(0.1337, metric.sample_rate)
427
+ end
428
+
429
+ def test_sample_rate_with_define_without_sample_rate
430
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
431
+ define(
432
+ name: "foo.bar.withouth_sample_rate",
433
+ )
434
+ end
435
+
436
+ assert_equal(StatsD.singleton_client.default_sample_rate, metric.sample_rate)
437
+ end
438
+
439
+ def test_sample_rate_without_define
440
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter)
441
+
442
+ error = assert_raises(ArgumentError) do
443
+ metric.sample_rate
444
+ end
445
+ assert_equal("Every CompiledMetric subclass needs to call `define` before accessing its sample_rate.", error.message)
446
+ end
447
+ end
@@ -48,21 +48,21 @@ class DispatcherStatsTest < Minitest::Test
48
48
  end
49
49
  assert_equal(batches.length, stats.instance_variable_get(:@batched_sends))
50
50
  assert_equal(
51
- batches.map { |b|
51
+ batches.map do |b|
52
52
  b[:buffer_len]
53
- }.sum / batches.length,
53
+ end.sum / batches.length,
54
54
  stats.instance_variable_get(:@avg_buffer_length),
55
55
  )
56
56
  assert_equal(
57
- batches.map { |b|
57
+ batches.map do |b|
58
58
  b[:packet_size]
59
- }.sum / batches.length,
59
+ end.sum / batches.length,
60
60
  stats.instance_variable_get(:@avg_batched_packet_size),
61
61
  )
62
62
  assert_equal(
63
- batches.map { |b|
63
+ batches.map do |b|
64
64
  b[:batch_len]
65
- }.sum / batches.length,
65
+ end.sum / batches.length,
66
66
  stats.instance_variable_get(:@avg_batch_length),
67
67
  )
68
68
  end
@@ -103,4 +103,56 @@ class IntegrationTest < Minitest::Test
103
103
  assert_match(/counter:\d+|c/, packets.find { |packet| packet.start_with?("counter:") })
104
104
  assert_match(/test_distribution:\d+:3|d/, packets.find { |packet| packet.start_with?("test_distribution:") })
105
105
  end
106
+
107
+ def test_signal_trap_with_aggregation_fallback
108
+ skip("#{RUBY_ENGINE} not supported for this test. Reason: signal handling") if RUBY_ENGINE != "ruby"
109
+
110
+ client = StatsD::Instrument::Environment.new(
111
+ "STATSD_ADDR" => "#{@server.addr[2]}:#{@server.addr[1]}",
112
+ "STATSD_IMPLEMENTATION" => "dogstatsd",
113
+ "STATSD_ENV" => "production",
114
+ "STATSD_ENABLE_AGGREGATION" => "true",
115
+ "STATSD_AGGREGATION_INTERVAL" => "5.0",
116
+ ).client
117
+
118
+ signal_received = false
119
+
120
+ old_trap = Signal.trap("USR1") do
121
+ signal_received = true
122
+ # These should fall back to direct writes
123
+ client.increment("trap_metric", 5)
124
+ client.gauge("trap_gauge", 42)
125
+ client.distribution("trap_distribution", 100)
126
+ end
127
+
128
+ Process.kill("USR1", Process.pid)
129
+
130
+ sleep(0.1)
131
+
132
+ assert(signal_received, "Signal should have been received")
133
+
134
+ packets = []
135
+ while IO.select([@server], nil, nil, 0.1)
136
+ packet = @server.recvfrom(300).first
137
+ packets.concat(packet.split("\n"))
138
+ end
139
+
140
+ # When aggregation is disabled due to trap context, metrics might be batched
141
+ assert(packets.size >= 3, "Expected at least 3 metrics, got #{packets.size}: #{packets.inspect}")
142
+
143
+ assert(
144
+ packets.any? { |p| p == "trap_metric:5|c" },
145
+ "Expected counter metric, got: #{packets.inspect}",
146
+ )
147
+ assert(
148
+ packets.any? { |p| p == "trap_gauge:42|g" },
149
+ "Expected gauge metric, got: #{packets.inspect}",
150
+ )
151
+ assert(
152
+ packets.any? { |p| p == "trap_distribution:100|d" },
153
+ "Expected distribution metric, got: #{packets.inspect}",
154
+ )
155
+ ensure
156
+ Signal.trap("USR1", old_trap || "DEFAULT")
157
+ end
106
158
  end
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.9.9
4
+ version: 3.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jesse Storimer
@@ -9,7 +9,7 @@ authors:
9
9
  - Willem van Bergen
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2025-01-14 00:00:00.000000000 Z
12
+ date: 1980-01-02 00:00:00.000000000 Z
13
13
  dependencies: []
14
14
  description: A StatsD client for Ruby apps. Provides metaprogramming methods to inject
15
15
  StatsD instrumentation into your code.
@@ -49,6 +49,7 @@ files:
49
49
  - lib/statsd/instrument/batched_sink.rb
50
50
  - lib/statsd/instrument/capture_sink.rb
51
51
  - lib/statsd/instrument/client.rb
52
+ - lib/statsd/instrument/compiled_metric.rb
52
53
  - lib/statsd/instrument/connection_behavior.rb
53
54
  - lib/statsd/instrument/datagram.rb
54
55
  - lib/statsd/instrument/datagram_builder.rb
@@ -86,6 +87,10 @@ files:
86
87
  - test/capture_sink_test.rb
87
88
  - test/changelog_test.rb
88
89
  - test/client_test.rb
90
+ - test/compiled_metric/counter_test.rb
91
+ - test/compiled_metric/distribution_test.rb
92
+ - test/compiled_metric/gauge_test.rb
93
+ - test/compiled_metric_test.rb
89
94
  - test/datagram_builder_test.rb
90
95
  - test/datagram_test.rb
91
96
  - test/dispatcher_stats_test.rb
@@ -130,7 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
130
135
  - !ruby/object:Gem::Version
131
136
  version: '0'
132
137
  requirements: []
133
- rubygems_version: 3.6.2
138
+ rubygems_version: 4.0.8
134
139
  specification_version: 4
135
140
  summary: A StatsD client for Ruby apps
136
141
  test_files:
@@ -142,6 +147,10 @@ test_files:
142
147
  - test/capture_sink_test.rb
143
148
  - test/changelog_test.rb
144
149
  - test/client_test.rb
150
+ - test/compiled_metric/counter_test.rb
151
+ - test/compiled_metric/distribution_test.rb
152
+ - test/compiled_metric/gauge_test.rb
153
+ - test/compiled_metric_test.rb
145
154
  - test/datagram_builder_test.rb
146
155
  - test/datagram_test.rb
147
156
  - test/dispatcher_stats_test.rb