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.
- checksums.yaml +4 -4
- data/.github/workflows/tests.yml +1 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +21 -0
- data/lib/statsd/instrument/aggregator.rb +166 -45
- data/lib/statsd/instrument/client.rb +85 -4
- data/lib/statsd/instrument/compiled_metric.rb +465 -0
- data/lib/statsd/instrument/version.rb +1 -1
- data/lib/statsd/instrument.rb +2 -1
- data/test/aggregator_test.rb +158 -6
- data/test/client_test.rb +75 -4
- data/test/compiled_metric/counter_test.rb +396 -0
- data/test/compiled_metric/distribution_test.rb +503 -0
- data/test/compiled_metric/gauge_test.rb +395 -0
- data/test/compiled_metric_test.rb +447 -0
- data/test/dispatcher_stats_test.rb +6 -6
- data/test/integration_test.rb +52 -0
- metadata +12 -3
|
@@ -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
|
data/lib/statsd/instrument.rb
CHANGED
|
@@ -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)
|
|
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"]
|
data/test/aggregator_test.rb
CHANGED
|
@@ -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(:@
|
|
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
|