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,465 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatsD
4
+ module Instrument
5
+ # A compiled metric pre-builds the datagram template at definition time
6
+ # to minimize allocations during metric emission. This is particularly
7
+ # beneficial for high-frequency metrics with consistent tag patterns.
8
+ #
9
+ # Example:
10
+ # class CheckoutMetric < StatsD::Instrument::CompiledMetric::Counter
11
+ # define(
12
+ # name: "checkout.completed",
13
+ # static_tags: { service: "web" },
14
+ # tags: { shop_id: Integer, user_id: Integer }
15
+ # )
16
+ # end
17
+ #
18
+ # # Later, emit with minimal allocations:
19
+ # CheckoutMetric.increment(shop_id: 123, user_id: 456, value: 1)
20
+ class CompiledMetric
21
+ # Default maximum number of unique tag combinations to cache before clearing
22
+ # the cache to prevent unbounded memory growth
23
+ DEFAULT_MAX_TAG_COMBINATION_CACHE_SIZE = 5000
24
+
25
+ class << self
26
+ # Defines a new compiled metric class with the given configuration.
27
+ #
28
+ # @param name [String] The metric name
29
+ # @param static_tags [Hash{Symbol, String => String, Integer, Float}] Tags with fixed values
30
+ # @param tags [Hash{Symbol, String => Class}] Tags with dynamic values (Integer, Float, or String)
31
+ # @param no_prefix [Boolean] If true, skip the StatsD prefix and default_tags
32
+ # @param sample_rate [Float, nil] The sample rate (0.0-1.0) for this metric, nil for no sampling
33
+ # @param max_cache_size [Integer] Maximum tag combinations this metric supports, and will be retained in-memory. Cardinality beyond this number will fall back to the slow path and should be avoided.
34
+ # @return [Class] A new CompiledMetric subclass configured for this metric
35
+ def define(name:, static_tags: {}, tags: {}, no_prefix: false, sample_rate: nil, max_cache_size: DEFAULT_MAX_TAG_COMBINATION_CACHE_SIZE)
36
+ client = StatsD.singleton_client
37
+
38
+ # Build the datagram blueprint using the builder
39
+ # The builder handles prefix, tags compilation, and blueprint construction
40
+ datagram_blueprint = DatagramBlueprintBuilder.build(
41
+ name: name,
42
+ type: type,
43
+ client_prefix: client.prefix,
44
+ no_prefix: no_prefix,
45
+ default_tags: client.default_tags,
46
+ static_tags: static_tags,
47
+ dynamic_tags: tags,
48
+ sample_rate: sample_rate || client.default_sample_rate,
49
+ )
50
+
51
+ # Create a new class for this specific metric
52
+ # Using classes instead of instances for better YJIT optimization
53
+ metric_class = tap do
54
+ @name = DatagramBlueprintBuilder.normalize_name(name)
55
+ @datagram_blueprint = datagram_blueprint
56
+ @tag_combination_cache = {}
57
+ @max_cache_size = max_cache_size
58
+ @singleton_client = client
59
+ @sample_rate = sample_rate || client.default_sample_rate
60
+
61
+ define_metric_method(tags)
62
+ end
63
+
64
+ metric_class
65
+ end
66
+
67
+ # @return [String] The metric type character (e.g., "c" for counter)
68
+ def type
69
+ raise NotImplementedError, "Subclasses must implement #type"
70
+ end
71
+
72
+ # @return [Symbol] The method name to define (e.g., :increment)
73
+ def method_name
74
+ raise NotImplementedError, "Subclasses must implement #method_name"
75
+ end
76
+
77
+ # @return [Numeric, nil] The default value for the metric.
78
+ # Returning nil makes __value__ a required argument.
79
+ def default_value
80
+ raise NotImplementedError, "Subclasses must implement #default_value"
81
+ end
82
+
83
+ # @return [Boolean] When set to `true`, the created `method_name` method will accept a block.
84
+ # The `value` kwarg will be ignored and instead the execution time of the block in milliseconds will be used.
85
+ # The return value of the block will be passed through.
86
+ def allow_measuring_latency
87
+ false
88
+ end
89
+
90
+ # Defines the metric emission method - must be implemented by subclasses
91
+ # @param tags [Hash] The dynamic tags configuration
92
+ def define_metric_method(tags)
93
+ if tags.any?
94
+ define_dynamic_method(tags)
95
+ else
96
+ define_static_method
97
+ end
98
+ end
99
+
100
+ def sample?(sample_rate)
101
+ @singleton_client.sink.sample?(sample_rate)
102
+ end
103
+
104
+ # @return [Float] The defined sample rate for a metric class.
105
+ # Will raise when `define` has not yet been called on the class.
106
+ def sample_rate
107
+ raise ArgumentError, "Every CompiledMetric subclass needs to call `define` before accessing its sample_rate." unless defined?(@sample_rate)
108
+
109
+ @sample_rate
110
+ end
111
+
112
+ private
113
+
114
+ # The placeholder definitions of the metric subclasses will call this method.
115
+ # Once `define` was called during the class creation, it will override the
116
+ # method implementation to emit the actual metric datagrams.
117
+ def require_define_to_be_called
118
+ raise ArgumentError, "Every CompiledMetric subclass needs to call `define` before first invocation of #{method_name}."
119
+ end
120
+
121
+ def generate_block_handler
122
+ # For all timing metrics, we have to use the sampling logic.
123
+ # Not doing so would impact performance and CPU usage.
124
+ # See Datadog's documentation for more details: https://github.com/DataDog/datadog-go/blob/20af2dbfabbbe6bd0347780cd57ed931f903f223/statsd/aggregator.go#L281-L283
125
+ <<~RUBY
126
+ __sample_rate__ ||= @sample_rate
127
+ if __sample_rate__ && !sample?(__sample_rate__)
128
+ if block_given?
129
+ return yield
130
+ end
131
+
132
+ return StatsD::Instrument::VOID
133
+ end
134
+
135
+ if block_given?
136
+ __start__ = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
137
+ begin
138
+ __return_value__ = yield
139
+ ensure
140
+ __stop__ = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
141
+ __value__ = __stop__ - __start__
142
+ end
143
+ end
144
+ RUBY
145
+ end
146
+
147
+ # Defines the metric method for metrics with dynamic tags
148
+ # Generates optimized code with tag caching
149
+ def define_dynamic_method(tags)
150
+ # Use the actual tag names as keyword arguments
151
+ tag_names = tags.keys
152
+ method = method_name
153
+ default_val = default_value
154
+ default_val_assignment = default_val.nil? ? "" : " = #{default_val.inspect}"
155
+ allow_block = allow_measuring_latency
156
+
157
+ method_code = <<~RUBY
158
+ def self.#{method}(__value__#{default_val_assignment}, #{tag_names.map { |name| "#{name}:" }.join(", ")})
159
+ __return_value__ = StatsD::Instrument::VOID
160
+ #{generate_block_handler if allow_block}
161
+
162
+ # Compute hash of tag values for cache lookup using rotate-left + XOR.
163
+ # Rotation makes it order-dependent (unlike plain XOR), preventing collisions
164
+ # when tag values are swapped. We mask to 32 bits to avoid Bignum allocations
165
+ # from left shifts on 64-bit hash values.
166
+ __cache_key__ = #{tag_names.first}.hash & 0xFFFFFFFF
167
+ #{tag_names.drop(1).map { |name| "__cache_key__ = (((__cache_key__ << 5) | (__cache_key__ >> 27)) ^ #{name}.hash) & 0xFFFFFFFF" }.join("\n")}
168
+
169
+ # Look up or create a PrecompiledDatagram
170
+ __datagram__ =
171
+ if (__cache__ = @tag_combination_cache)
172
+ __cached_datagram__ = __cache__[__cache_key__] ||=
173
+ begin
174
+ __new_datagram__ = PrecompiledDatagram.new([#{tag_names.join(", ")}], @datagram_blueprint, @sample_rate)
175
+
176
+ # Clear cache if it grows too large to prevent memory bloat
177
+ if __cache__.size >= @max_cache_size
178
+ StatsD.increment("statsd_instrument.compiled_metric.cache_exceeded_total", tags: { metric_name: @name, max_size: @max_cache_size })
179
+ @tag_combination_cache = nil
180
+ end
181
+
182
+ __new_datagram__
183
+ end
184
+
185
+ # Hash collision detection
186
+ if #{tag_names.map.with_index { |name, i| "#{name} != __cached_datagram__.tag_values[#{i}]" }.join(" || ")}
187
+ # Hash collision - fall back to creating a new datagram
188
+ StatsD.increment("statsd_instrument.compiled_metric.hash_collision_detected", tags: { metric_name: @name })
189
+ __cached_datagram__ = nil
190
+ end
191
+
192
+ __cached_datagram__
193
+ end
194
+
195
+ __datagram__ ||= PrecompiledDatagram.new([#{tag_names.join(", ")}], @datagram_blueprint, @sample_rate)
196
+
197
+ @singleton_client.emit_precompiled_#{method}_metric(__datagram__, __value__)
198
+ __return_value__
199
+ end
200
+ RUBY
201
+
202
+ instance_eval(method_code, __FILE__, __LINE__ + 1)
203
+ end
204
+
205
+ # Defines the metric method for metrics without dynamic tags
206
+ # Uses a single precompiled datagram for all calls
207
+ def define_static_method
208
+ @static_datagram = PrecompiledDatagram.new([], @datagram_blueprint, @sample_rate)
209
+ method = method_name
210
+ default_val = default_value
211
+ allow_block = allow_measuring_latency
212
+
213
+ instance_eval(<<~RUBY, __FILE__, __LINE__ + 1)
214
+ def self.#{method}(__value__ = #{default_val.inspect})
215
+ __return_value__ = StatsD::Instrument::VOID
216
+ #{generate_block_handler if allow_block}
217
+ @singleton_client.emit_precompiled_#{method}_metric(@static_datagram, __value__)
218
+ __return_value__
219
+ end
220
+ RUBY
221
+ end
222
+ end
223
+
224
+ # Helper class to build datagram blueprints at definition time.
225
+ # Handles prefix building, tag compilation, and blueprint construction.
226
+ class DatagramBlueprintBuilder
227
+ class << self
228
+ # Builds a datagram blueprint string
229
+ #
230
+ # @param name [String] The metric name
231
+ # @param type [String] The metric type (e.g., "c" for counter)
232
+ # @param client_prefix [String, nil] The client's prefix
233
+ # @param value_format [String] The sprintf format for the value (e.g., "%d", "%f")
234
+ # @param no_prefix [Boolean] Whether to skip the prefix
235
+ # @param default_tags [String, Hash, Array, nil] The client's default tags
236
+ # @param static_tags [Hash] Static tags with fixed values
237
+ # @param dynamic_tags [Hash] Dynamic tags with type specifications
238
+ # @param sample_rate [Float, nil] The sample rate (0.0-1.0), nil for no sampling
239
+ # @param enable_aggregation [Boolean] Whether aggregation is enabled
240
+ # @return [String] The datagram blueprint with sprintf placeholders
241
+ def build(name:, type:, client_prefix:, no_prefix:, default_tags:, static_tags:, dynamic_tags:, sample_rate:)
242
+ # Normalize and build prefix
243
+ normalized_name = normalize_name(name)
244
+ prefix = build_prefix(client_prefix, no_prefix)
245
+
246
+ # Compile all tags (default, static, dynamic)
247
+ all_tags = compile_all_tags(default_tags, static_tags, dynamic_tags)
248
+
249
+ # Build the datagram blueprint
250
+ # Format: "<prefix><name>:<value_format>|<type>|@<sample_rate>|#<tags>"
251
+ # Note: When aggregation is enabled, sample_rate is applied before aggregation
252
+ if sample_rate && sample_rate < 1
253
+ # Include sample_rate in the blueprint (only when not aggregating)
254
+ if all_tags.empty?
255
+ "#{prefix}#{normalized_name}:%s|#{type}|@#{sample_rate}"
256
+ else
257
+ "#{prefix}#{normalized_name}:%s|#{type}|@#{sample_rate}|##{all_tags}"
258
+ end
259
+ elsif all_tags.empty?
260
+ "#{prefix}#{normalized_name}:%s|#{type}"
261
+ else
262
+ "#{prefix}#{normalized_name}:%s|#{type}|##{all_tags}"
263
+ end
264
+ end
265
+
266
+ # Normalizes metric names by replacing special characters
267
+ # @param name [String] The metric name
268
+ # @return [String] The normalized metric name
269
+ def normalize_name(name)
270
+ name.tr(":|@", "_")
271
+ end
272
+
273
+ private
274
+
275
+ # Builds the metric prefix
276
+ # @param client_prefix [String, nil] The client's prefix
277
+ # @param no_prefix [Boolean] Whether to skip the prefix
278
+ # @return [String] The prefix string (with trailing dot if present)
279
+ def build_prefix(client_prefix, no_prefix)
280
+ return "" if no_prefix || client_prefix.nil?
281
+
282
+ "#{client_prefix}."
283
+ end
284
+
285
+ # Normalizes tag names/values by removing StatsD protocol special characters
286
+ # @param str [Symbol, String, Integer, Float] The string to normalize
287
+ # @return [String] The normalized string
288
+ def normalize_statsd_string(str)
289
+ str = str.to_s
290
+ str = str.tr("|,", "") if /[|,]/.match?(str)
291
+ str
292
+ end
293
+
294
+ # Compiles all tags (default_tags, static_tags, dynamic_tags) into a single string
295
+ # @param default_tags [String, Hash, Array, nil] The client's default tags
296
+ # @param static_tags [Hash] Static tags with fixed values
297
+ # @param dynamic_tags [Hash] Dynamic tags with type specifications
298
+ # @return [String] The comma-separated tags string
299
+ def compile_all_tags(default_tags, static_tags, dynamic_tags)
300
+ default_tags_str = compile_default_tags(default_tags)
301
+ static_tags_str = compile_static_tags(static_tags)
302
+ dynamic_tags_str = compile_dynamic_tags(dynamic_tags)
303
+
304
+ (default_tags_str + static_tags_str + dynamic_tags_str).join(",")
305
+ end
306
+
307
+ # Compiles default tags from the client (can be String, Hash, or Array)
308
+ # @param default_tags [String, Hash, Array, nil] The client's default tags
309
+ # @return [Array<String>] Array of normalized tag strings
310
+ def compile_default_tags(default_tags)
311
+ return [] if default_tags.nil? || default_tags.empty?
312
+
313
+ if default_tags.is_a?(String)
314
+ [normalize_statsd_string(default_tags)]
315
+ elsif default_tags.is_a?(Hash)
316
+ default_tags.map do |key, value|
317
+ "#{normalize_statsd_string(key)}:#{normalize_statsd_string(value)}"
318
+ end
319
+ else
320
+ # Array
321
+ default_tags.map { |tag| normalize_statsd_string(tag) }
322
+ end
323
+ end
324
+
325
+ # Compiles static tags (hash of key => value)
326
+ # @param static_tags [Hash] Static tags with fixed values
327
+ # @return [Array<String>] Array of "key:value" strings
328
+ def compile_static_tags(static_tags)
329
+ static_tags.map do |key, value|
330
+ "#{normalize_statsd_string(key)}:#{normalize_statsd_string(value)}"
331
+ end
332
+ end
333
+
334
+ # Compiles dynamic tags (hash of key => type) into sprintf placeholders
335
+ # @param dynamic_tags [Hash] Dynamic tags with type specifications
336
+ # @return [Array<String>] Array of "key:%s" placeholder strings
337
+ def compile_dynamic_tags(dynamic_tags)
338
+ dynamic_tags.map do |key, type|
339
+ tag_name = normalize_statsd_string(key)
340
+ unless [String, Integer, Float, Symbol, :Boolean].include?(type)
341
+ raise ArgumentError,
342
+ "Unsupported tag value type: #{type}. Use String, Integer, Float, Symbol, or :Boolean."
343
+ end
344
+ "#{tag_name}:%s"
345
+ end
346
+ end
347
+ end
348
+ end
349
+
350
+ # A precompiled datagram that can quickly build the final StatsD datagram
351
+ # string using sprintf formatting with cached tag values.
352
+ class PrecompiledDatagram
353
+ attr_reader :tag_values, :datagram_blueprint, :sample_rate
354
+
355
+ # @param tag_values [Array] The tag values to cache
356
+ # @param datagram_blueprint [String] The sprintf template
357
+ # @param sample_rate [Float] The sample rate (0.0-1.0)
358
+ def initialize(tag_values, datagram_blueprint, sample_rate)
359
+ @tag_values = tag_values
360
+ @datagram_blueprint = datagram_blueprint
361
+ @sample_rate = sample_rate
362
+ end
363
+
364
+ # Builds the final datagram string by substituting values into the blueprint
365
+ # @param value [Numeric | Array[Numeric]] The metric value
366
+ # @return [String] The complete StatsD datagram
367
+ def to_datagram(value)
368
+ packed_value = if value.is_a?(Array)
369
+ value.join(":")
370
+ else
371
+ value.to_s
372
+ end
373
+
374
+ # Fast path: no tag values (static metrics)
375
+ return @datagram_blueprint % packed_value if @tag_values.empty?
376
+
377
+ # Sanitize string and symbol values (other types handled by sprintf %s)
378
+ values = @tag_values.map do |arg|
379
+ if arg.is_a?(String)
380
+ /[|,]/.match?(arg) ? arg.tr("|,", "") : arg
381
+ elsif arg.is_a?(Symbol)
382
+ str = arg.to_s
383
+ /[|,]/.match?(str) ? str.tr("|,", "") : str
384
+ else
385
+ arg
386
+ end
387
+ end
388
+
389
+ # Prepend the metric value
390
+ values.unshift(packed_value)
391
+
392
+ # Use sprintf to build the final datagram
393
+ @datagram_blueprint % values
394
+ end
395
+ end
396
+
397
+ # Counter metric type
398
+ class Counter < CompiledMetric
399
+ class << self
400
+ def type
401
+ "c"
402
+ end
403
+
404
+ def method_name
405
+ :increment
406
+ end
407
+
408
+ def default_value
409
+ 1
410
+ end
411
+
412
+ def increment(__value__ = 1, **tags)
413
+ require_define_to_be_called
414
+ end
415
+ end
416
+ end
417
+
418
+ # Gauge metric type
419
+ class Gauge < CompiledMetric
420
+ class << self
421
+ def type
422
+ "g"
423
+ end
424
+
425
+ def method_name
426
+ :gauge
427
+ end
428
+
429
+ def default_value
430
+ nil
431
+ end
432
+
433
+ def gauge(__value__ = 1, **tags)
434
+ require_define_to_be_called
435
+ end
436
+ end
437
+ end
438
+
439
+ # Distribution metric type
440
+ class Distribution < CompiledMetric
441
+ class << self
442
+ def type
443
+ "d"
444
+ end
445
+
446
+ def method_name
447
+ :distribution
448
+ end
449
+
450
+ def default_value
451
+ 0
452
+ end
453
+
454
+ def allow_measuring_latency
455
+ true
456
+ end
457
+
458
+ def distribution(__value__ = 0, **tags)
459
+ require_define_to_be_called
460
+ end
461
+ end
462
+ end
463
+ end
464
+ end
465
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StatsD
4
4
  module Instrument
5
- VERSION = "3.9.9"
5
+ VERSION = "3.10.0"
6
6
  end
7
7
  end
@@ -362,7 +362,7 @@ module StatsD
362
362
  # @!method distribution(name, value = nil, sample_rate: nil, tags: nil, &block)
363
363
  # (see StatsD::Instrument::Client#distribution)
364
364
  #
365
- # @!method event(title, text, tags: nil, hostname: nil, timestamp: nil, aggregation_key: nil, priority: nil, source_type_name: nil, alert_type: nil) # rubocop:disable Layout/LineLength
365
+ # @!method event(title, text, tags: nil, hostname: nil, timestamp: nil, aggregation_key: nil, priority: nil, source_type_name: nil, alert_type: nil)
366
366
  # (see StatsD::Instrument::Client#event)
367
367
  #
368
368
  # @!method service_check(name, status, tags: nil, hostname: nil, timestamp: nil, message: nil)
@@ -406,6 +406,7 @@ require "statsd/instrument/uds_connection"
406
406
  require "statsd/instrument/udp_connection"
407
407
  require "statsd/instrument/sink"
408
408
  require "statsd/instrument/batched_sink"
409
+ require "statsd/instrument/compiled_metric"
409
410
  require "statsd/instrument/matchers" if defined?(RSpec)
410
411
  require "statsd/instrument/railtie" if defined?(Rails::Railtie)
411
412
  require "statsd/instrument/strict" if ENV["STATSD_STRICT_MODE"]
@@ -30,6 +30,7 @@ class AggregatorTest < Minitest::Test
30
30
  end
31
31
 
32
32
  def teardown
33
+ @subject.instance_variable_get(:@flush_thread)&.kill
33
34
  @sink.clear
34
35
  StatsD.logger = @old_logger
35
36
  end
@@ -46,6 +47,43 @@ class AggregatorTest < Minitest::Test
46
47
  assert_equal(["foo:bar"], datagram.tags)
47
48
  end
48
49
 
50
+ def test_increment_with_sample_rate
51
+ # Test that increment properly passes through sample_rate
52
+ @subject.increment("counter.sampled", 1, tags: { foo: "bar" }, sample_rate: 0.5)
53
+ @subject.increment("counter.sampled", 2, tags: { foo: "bar" }, sample_rate: 0.5)
54
+ @subject.increment("counter.unsampled", 1, tags: { foo: "bar" })
55
+ @subject.flush
56
+
57
+ assert_equal(2, @sink.datagrams.size)
58
+
59
+ sampled_datagram = @sink.datagrams.find { |d| d.name == "counter.sampled" }
60
+ assert_equal(3, sampled_datagram.value) # 1 + 2
61
+ assert_equal(0.5, sampled_datagram.sample_rate)
62
+ assert_includes(sampled_datagram.source, "|@0.5")
63
+
64
+ unsampled_datagram = @sink.datagrams.find { |d| d.name == "counter.unsampled" }
65
+ assert_equal(1, unsampled_datagram.value)
66
+ assert_equal(1.0, unsampled_datagram.sample_rate)
67
+ refute_includes(unsampled_datagram.source, "|@")
68
+ end
69
+
70
+ def test_increment_different_sample_rates_create_different_aggregation_keys
71
+ # Counters with different sample rates should be aggregated separately
72
+ @subject.increment("counter", 1, sample_rate: 0.5)
73
+ @subject.increment("counter", 2, sample_rate: 0.5)
74
+ @subject.increment("counter", 10, sample_rate: 0.1)
75
+ @subject.increment("counter", 20, sample_rate: 0.1)
76
+ @subject.flush
77
+
78
+ assert_equal(2, @sink.datagrams.size)
79
+
80
+ datagram_05 = @sink.datagrams.find { |d| d.sample_rate == 0.5 }
81
+ assert_equal(3, datagram_05.value) # 1 + 2
82
+
83
+ datagram_01 = @sink.datagrams.find { |d| d.sample_rate == 0.1 }
84
+ assert_equal(30, datagram_01.value) # 10 + 20
85
+ end
86
+
49
87
  def test_distribution_simple
50
88
  @subject.aggregate_timing("foo", 1, tags: { foo: "bar" })
51
89
  @subject.aggregate_timing("foo", 100, tags: { foo: "bar" })
@@ -178,6 +216,31 @@ class AggregatorTest < Minitest::Test
178
216
  assert_equal(1, @sink.datagrams.last.value)
179
217
  end
180
218
 
219
+ def test_finalizer_with_prefix
220
+ # Test that the finalizer correctly uses the prefix when flushing metrics
221
+ # IMPORTANT: Use empty tags to ensure datagram_builder is not pre-created by tags_sorted
222
+ aggregator = StatsD::Instrument::Aggregator.new(@sink, StatsD::Instrument::DatagramBuilder, "MyApp", [])
223
+
224
+ aggregator.increment("foo", 1, tags: [])
225
+ aggregator.increment("bar", 1, tags: [], no_prefix: true)
226
+
227
+ # Manually trigger the finalizer (simulates GC cleanup)
228
+ finalizer = StatsD::Instrument::Aggregator.finalize(
229
+ aggregator.instance_variable_get(:@finalizer),
230
+ )
231
+ finalizer.call
232
+
233
+ assert_equal(2, @sink.datagrams.size)
234
+
235
+ prefixed_datagram = @sink.datagrams.find { |d| d.name == "MyApp.foo" }
236
+ refute_nil(prefixed_datagram, "Expected to find datagram with prefix 'MyApp.foo'")
237
+ assert_equal(1, prefixed_datagram.value)
238
+
239
+ unprefixed_datagram = @sink.datagrams.find { |d| d.name == "bar" }
240
+ refute_nil(unprefixed_datagram, "Expected to find datagram without prefix 'bar'")
241
+ assert_equal(1, unprefixed_datagram.value)
242
+ end
243
+
181
244
  def test_synchronous_operation_on_thread_failure
182
245
  # Force thread_healthcheck to return false
183
246
  @subject.stubs(:thread_healthcheck).returns(false)
@@ -185,7 +248,6 @@ class AggregatorTest < Minitest::Test
185
248
  # Stub methods on @aggregation_state to ensure they are not called
186
249
  aggregation_state = @subject.instance_variable_get(:@aggregation_state)
187
250
  aggregation_state.stubs(:[]=).never
188
- aggregation_state.stubs(:clear).never
189
251
 
190
252
  @subject.increment("foo", 1, tags: { foo: "bar" })
191
253
  @subject.aggregate_timing("bar", 100, tags: { foo: "bar" })
@@ -222,6 +284,9 @@ class AggregatorTest < Minitest::Test
222
284
  assert_equal(["foo:bar"], timing_datagram.tags)
223
285
  assert_equal(0.5, timing_datagram.sample_rate)
224
286
 
287
+ after_aggregation_state = @subject.instance_variable_get(:@aggregation_state)
288
+ assert_same(after_aggregation_state, aggregation_state)
289
+
225
290
  # undo the stubbing
226
291
  @subject.unstub(:thread_healthcheck)
227
292
  end
@@ -326,11 +391,7 @@ class AggregatorTest < Minitest::Test
326
391
 
327
392
  # Manually trigger the finalizer
328
393
  finalizer = StatsD::Instrument::Aggregator.finalize(
329
- @subject.instance_variable_get(:@aggregation_state),
330
- @subject.instance_variable_get(:@sink),
331
- @subject.instance_variable_get(:@datagram_builders),
332
- StatsD::Instrument::DatagramBuilder,
333
- [],
394
+ @subject.instance_variable_get(:@finalizer),
334
395
  )
335
396
  finalizer.call
336
397
 
@@ -353,4 +414,95 @@ class AggregatorTest < Minitest::Test
353
414
  assert_equal(100.0, sampled_timing_datagram.value)
354
415
  assert_equal(0.01, sampled_timing_datagram.sample_rate)
355
416
  end
417
+
418
+ def test_aggregator_finalizer_is_called_on_gc
419
+ skip_on_jruby("JRuby's GC does not guarantee finalizers are called promptly")
420
+
421
+ 5.times { @subject.increment("foo", 1, tags: { foo: "bar" }) }
422
+
423
+ @subject.instance_variable_get(:@flush_thread)&.kill
424
+ # Unbind the aggregator to allow GC to collect it.
425
+ @subject = nil
426
+
427
+ # We expect the finalizer to flush the aggregated metrics.
428
+ assert_empty(@sink.datagrams)
429
+
430
+ 5.times do
431
+ GC.start(full_mark: true, immediate_mark: true, immediate_sweep: true)
432
+ break if @sink.datagrams.any?
433
+
434
+ sleep(0.1)
435
+ end
436
+
437
+ counter_datagram = @sink.datagrams.first
438
+ assert_equal("foo", counter_datagram.name)
439
+ assert_equal(5, counter_datagram.value)
440
+ assert_equal(["foo:bar"], counter_datagram.tags)
441
+ end
442
+
443
+ def test_aggregator_finalizer_is_called_on_gc_after_aggregation_state_move
444
+ (StatsD::Instrument::Aggregator::DEFAULT_MAX_CONTEXT_SIZE + 5).times { @subject.aggregate_timing("foo", 1) }
445
+ assert_equal(1, @sink.datagrams.size)
446
+
447
+ # Unbind the aggregator to allow GC to collect it.
448
+ @subject = nil
449
+ 5.times do
450
+ GC.start(full_mark: true, immediate_mark: true, immediate_sweep: true)
451
+ break if @sink.datagrams.size == 2
452
+
453
+ sleep(0.1)
454
+ end
455
+
456
+ assert_equal(2, @sink.datagrams.size)
457
+ counter_datagram = @sink.datagrams.last
458
+
459
+ assert_equal("foo", counter_datagram.name)
460
+ assert_equal([1, 1, 1, 1, 1], counter_datagram.value)
461
+ end
462
+
463
+ def test_signal_trap_context_fallback_to_direct_writes
464
+ skip("#{RUBY_ENGINE} not supported for this test. Reason: signal handling") if RUBY_ENGINE != "ruby"
465
+
466
+ signal_received = false
467
+ metrics_sent_in_trap = []
468
+
469
+ old_trap = Signal.trap("USR1") do
470
+ signal_received = true
471
+ # These operations should now fall back to direct writes
472
+ @subject.increment("trap_counter", 1)
473
+ @subject.gauge("trap_gauge", 42)
474
+ @subject.aggregate_timing("trap_timing", 100)
475
+
476
+ metrics_sent_in_trap = @sink.datagrams.map(&:name)
477
+ end
478
+
479
+ @sink.clear
480
+
481
+ Process.kill("USR1", Process.pid)
482
+
483
+ sleep(0.1)
484
+
485
+ assert(signal_received, "Signal should have been received")
486
+
487
+ assert_includes(metrics_sent_in_trap, "trap_counter")
488
+ assert_includes(metrics_sent_in_trap, "trap_gauge")
489
+ assert_includes(metrics_sent_in_trap, "trap_timing")
490
+
491
+ counter_datagram = @sink.datagrams.find { |d| d.name == "trap_counter" }
492
+ assert_equal(1, counter_datagram.value)
493
+
494
+ gauge_datagram = @sink.datagrams.find { |d| d.name == "trap_gauge" }
495
+ assert_equal(42, gauge_datagram.value)
496
+
497
+ timing_datagram = @sink.datagrams.find { |d| d.name == "trap_timing" }
498
+ assert_equal([100.0], [timing_datagram.value].flatten)
499
+
500
+ debug_messages = @logger.messages.select { |m| m[:severity] == :debug }
501
+ assert(
502
+ debug_messages.any? { |m| m[:message].include?("In trap context, falling back to direct writes") },
503
+ "Expected debug message about trap context fallback",
504
+ )
505
+ ensure
506
+ Signal.trap("USR1", old_trap || "DEFAULT")
507
+ end
356
508
  end