statsd-instrument 4.0.0 → 4.0.1

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: 5ba68947d3fa5cec0f604135dbf0fdda92ba4d056262ae2864e3144f8ad395ba
4
- data.tar.gz: 63200c83b270a8d840eb88ba3d58a8b615e3f1d4c20aaacb15946ce745f42a90
3
+ metadata.gz: c7184b3fb0852d7afea02fa71a292812ef26b491eb94ab2427f603b49f01202c
4
+ data.tar.gz: e40e7aa3d4cb034467b6d59fdfdb3bcbf55a2901e82c48368fd1e904d0aa67c5
5
5
  SHA512:
6
- metadata.gz: cf8edd51de1582c7b90e9ae07d74277ff093a6ba6d9e896f502bdfab6f94abe1c5d9fb1ed019a8cdf0a700774606595182086dee310659512ac18c17ff8efd16
7
- data.tar.gz: de8619df52375bf5aa0a544ee18f5849b892ce82f2ae3ebac77c30ca62c5cb9851ff267aff286e0f8eff2b2ce9f225af925d1d6d8a56cb5a7a8fd4ff9371e744
6
+ metadata.gz: e1d01801fcbedc88df3e57bf948e6a4b599b52365e80d55e66ced52d4b308427632d5dc8161614705a7524e9a7f1b56e85e717ef9e3f0509969091b801ec30c2
7
+ data.tar.gz: 97bf3cf218ef6be51d72dc96f067464cc8f21eb0b070be9e48d7097cc4863735f41a58a3ed6967829524b6df64af1d8a1122197fec46944ce8154dcf539a4721
data/CHANGELOG.md CHANGED
@@ -6,6 +6,10 @@ section below.
6
6
 
7
7
  ## Unreleased changes
8
8
 
9
+ ## Version 4.0.1
10
+
11
+ - Normalize ASCII whitespace in metric names and prefixes, and newlines (`\n` and `\r`) in tags and set values, including aggregated and compiled metrics.
12
+
9
13
  ## Version 4.0.0
10
14
 
11
15
  - Make the internal aggregator interface positional-only and injectable through `Client`, with fixed-arity `increment`, `gauge`, and `aggregate_timing` entrypoints for native aggregation backends. Public Client metric methods retain their keyword API.
data/README.md CHANGED
@@ -114,6 +114,11 @@ Please note that since aggregation is an experimental feature, it should be used
114
114
  ## StatsD keys
115
115
 
116
116
  StatsD keys look like 'admin.logins.api.success'. Dots are used as namespace separators.
117
+ Metric names and prefixes normalize `:`, `|`, `@`, and ASCII whitespace (spaces,
118
+ tabs, and line breaks) to `_`, one underscore per character.
119
+ Newlines (`\n`, `\r`) in set values are also replaced with `_`; in array/hash tags,
120
+ newlines are removed alongside `|` and `,`. Other tag/value whitespace is preserved.
121
+ Normalization is silent.
117
122
 
118
123
  ## Usage
119
124
 
@@ -213,8 +213,8 @@ module StatsD
213
213
  #
214
214
  # - We recommend using `snake_case.metric_names` as naming scheme.
215
215
  # - A `.` should be used for namespacing, e.g. `foo.bar.baz`
216
- # - A metric name should not include the following characters: `|`, `@`, and `:`.
217
- # The library will convert these characters to `_`.
216
+ # - A metric name should not include `|`, `@`, `:`, or ASCII whitespace.
217
+ # The library converts each such character to `_`, including in prefixes.
218
218
  #
219
219
  # @param value [Integer] (default: 1) The value to increment the counter by.
220
220
  #
@@ -70,7 +70,8 @@ module StatsD
70
70
  # Create a new class for this specific metric
71
71
  # Using classes instead of instances for better YJIT optimization
72
72
  metric_class = tap do
73
- @name = DatagramBlueprintBuilder.normalize_name(name).freeze
73
+ # Own a frozen name without freezing a mutable string from the caller.
74
+ @name = -DatagramBlueprintBuilder.normalize_name(name)
74
75
  @datagram_blueprint = datagram_blueprint
75
76
  @tag_combination_cache = {}
76
77
  @max_cache_size = max_cache_size
@@ -300,9 +301,10 @@ module StatsD
300
301
 
301
302
  # Normalizes metric names by replacing special characters
302
303
  # @param name [String] The metric name
303
- # @return [String] The normalized metric name
304
+ # @return [String] The original string if clean, otherwise a new string.
305
+ # Does not copy or freeze clean input; retaining callers own that step.
304
306
  def normalize_name(name)
305
- name.tr(":|@", "_")
307
+ Sanitization.name(name)
306
308
  end
307
309
 
308
310
  private
@@ -314,16 +316,14 @@ module StatsD
314
316
  def build_prefix(client_prefix, no_prefix)
315
317
  return "" if no_prefix || client_prefix.nil?
316
318
 
317
- "#{client_prefix}."
319
+ "#{Sanitization.name(client_prefix.to_s)}."
318
320
  end
319
321
 
320
322
  # Normalizes tag names/values by removing StatsD protocol special characters
321
323
  # @param str [Symbol, String, Integer, Float] The string to normalize
322
324
  # @return [String] The normalized string
323
325
  def normalize_statsd_string(str)
324
- str = str.to_s
325
- str = str.tr("|,", "") if /[|,]/.match?(str)
326
- str
326
+ Sanitization.tag(str.to_s)
327
327
  end
328
328
 
329
329
  # Compiles all tags (default_tags, static_tags, dynamic_tags) into a single string
@@ -412,10 +412,10 @@ module StatsD
412
412
  # Sanitize string and symbol values (other types handled by sprintf %s)
413
413
  values = @tag_values.map do |arg|
414
414
  if arg.is_a?(String)
415
- /[|,]/.match?(arg) ? arg.tr("|,", "") : arg
415
+ Sanitization::TAG_PATTERN.match?(arg) ? arg.tr(Sanitization::TAG_CHARACTERS, "") : arg
416
416
  elsif arg.is_a?(Symbol)
417
417
  str = arg.to_s
418
- /[|,]/.match?(str) ? str.tr("|,", "") : str
418
+ Sanitization::TAG_PATTERN.match?(str) ? str.tr(Sanitization::TAG_CHARACTERS, "") : str
419
419
  else
420
420
  arg
421
421
  end
@@ -72,9 +72,9 @@ module StatsD
72
72
 
73
73
  PARSER = %r{
74
74
  \A
75
- (?<name>[^\:\|\@]+)\:(?<value>(?:[^\:\|\@]+:)*[^\:\|\@]+)\|(?<type>c|ms|g|s|h|d)
76
- (?:\|\@(?<sample_rate>\d*(?:\.\d*)?))?
77
- (?:\|\#(?<tags>(?:[^\|,]+(?:,[^\|,]+)*)))?
75
+ (?<name>[^:|@]+):(?<value>(?:[^:|@]+:)*[^:|@]+)\|(?<type>c|ms|g|s|h|d)
76
+ (?:\|@(?<sample_rate>\d*(?:\.\d*)?))?
77
+ (?:\|\#(?<tags>(?:[^|,]+(?:,[^|,]+)*)))?
78
78
  \n? # In some implementations, the datagram may include a trailing newline.
79
79
  \z
80
80
  }x.freeze
@@ -20,13 +20,13 @@ module StatsD
20
20
  end
21
21
 
22
22
  def normalize_string(string)
23
- string = string.tr("|#", "_") if /[|#]/.match?(string)
23
+ string = string.tr("|#\r\n", "_") if /[|#\r\n]/.match?(string)
24
24
  string
25
25
  end
26
26
  end
27
27
 
28
28
  def initialize(prefix: nil, default_tags: nil)
29
- @prefix = prefix.nil? ? "" : "#{prefix}.".tr(":|@", "_")
29
+ @prefix = prefix.nil? ? "" : "#{Sanitization.name(prefix.to_s)}."
30
30
  @default_tags = default_tags.nil? || default_tags.empty? ? nil : compile_tags(default_tags, "|#".b)
31
31
  end
32
32
 
@@ -43,6 +43,8 @@ module StatsD
43
43
  end
44
44
 
45
45
  def s(name, value, sample_rate, tags)
46
+ value = value.to_s
47
+ value = value.tr("\r\n", "_") if /[\r\n]/.match?(value)
46
48
  generate_generic_datagram(name, value, "s", sample_rate, tags)
47
49
  end
48
50
 
@@ -76,16 +78,13 @@ module StatsD
76
78
 
77
79
  # Utility function to remove invalid characters from a StatsD metric name
78
80
  def normalize_name(name)
79
- # Fast path when no normalization is needed to avoid copying the string
80
- return name unless /[:|@]/.match?(name)
81
-
82
- name.tr(":|@", "_")
81
+ Sanitization.name(name)
83
82
  end
84
83
 
85
84
  def generate_generic_datagram(name, value, type, sample_rate, tags)
86
85
  datagram = "".b <<
87
86
  @prefix <<
88
- (/[:|@]/.match?(name) ? name.tr(":|@", "_") : name) <<
87
+ (Sanitization::NAME_PATTERN.match?(name) ? name.tr(Sanitization::NAME_CHARACTERS, "_") : name) <<
89
88
  ":" << value.to_s <<
90
89
  "|" << type
91
90
 
@@ -105,7 +104,8 @@ module StatsD
105
104
 
106
105
  def compile_tags(tags, buffer = "".b)
107
106
  if tags.is_a?(String)
108
- tags = self.class.normalize_string(tags) if /[|,]/.match?(tags)
107
+ # String tags are already serialized: commas separate tags here.
108
+ tags = self.class.normalize_string(tags) if Sanitization::TAG_PATTERN.match?(tags)
109
109
  buffer << tags
110
110
  return buffer
111
111
  end
@@ -118,14 +118,14 @@ module StatsD
118
118
  buffer << ","
119
119
  end
120
120
  key = key.to_s
121
- key = key.tr("|,", "") if /[|,]/.match?(key)
121
+ key = key.tr(Sanitization::TAG_CHARACTERS, "") if Sanitization::TAG_PATTERN.match?(key)
122
122
  value = value.to_s
123
- value = value.tr("|,", "") if /[|,]/.match?(value)
123
+ value = value.tr(Sanitization::TAG_CHARACTERS, "") if Sanitization::TAG_PATTERN.match?(value)
124
124
  buffer << key << ":" << value
125
125
  end
126
126
  else
127
- if tags.any? { |tag| /[|,]/.match?(tag) }
128
- tags = tags.map { |tag| tag.tr("|,", "") }
127
+ if tags.any? { |tag| Sanitization::TAG_PATTERN.match?(tag) }
128
+ tags = tags.map { |tag| Sanitization.tag(tag) }
129
129
  end
130
130
  buffer << tags.join(",")
131
131
  end
@@ -62,11 +62,11 @@ module StatsD
62
62
 
63
63
  SERVICE_CHECK_PARSER = %r{
64
64
  \A
65
- (?<type>_sc)\|(?<name>[^\|]+)\|(?<value>\d+)
66
- (?:\|h:(?<hostname>[^\|]+))?
65
+ (?<type>_sc)\|(?<name>[^|]+)\|(?<value>\d+)
66
+ (?:\|h:(?<hostname>[^|]+))?
67
67
  (?:\|d:(?<timestamp>\d+))?
68
- (?:\|\#(?<tags>(?:[^\|,]+(?:,[^\|,]+)*)))?
69
- (?:\|m:(?<message>[^\|]+))?
68
+ (?:\|\#(?<tags>(?:[^|,]+(?:,[^|,]+)*)))?
69
+ (?:\|m:(?<message>[^|]+))?
70
70
  \n? # In some implementations, the datagram may include a trailing newline.
71
71
  \z
72
72
  }x.freeze
@@ -74,14 +74,14 @@ module StatsD
74
74
  # |k:my-key|p:low|s:source|t:success|
75
75
  EVENT_PARSER = %r{
76
76
  \A
77
- (?<type>_e)\{\d+\,\d+\}:(?<name>[^\|]+)\|(?<value>[^\|]+)
78
- (?:\|h:(?<hostname>[^\|]+))?
77
+ (?<type>_e)\{\d+,\d+\}:(?<name>[^|]+)\|(?<value>[^|]+)
78
+ (?:\|h:(?<hostname>[^|]+))?
79
79
  (?:\|d:(?<timestamp>\d+))?
80
- (?:\|k:(?<aggregation_key>[^\|]+))?
81
- (?:\|p:(?<priority>[^\|]+))?
82
- (?:\|s:(?<source_type_name>[^\|]+))?
83
- (?:\|t:(?<alert_type>[^\|]+))?
84
- (?:\|\#(?<tags>(?:[^\|,]+(?:,[^\|,]+)*)))?
80
+ (?:\|k:(?<aggregation_key>[^|]+))?
81
+ (?:\|p:(?<priority>[^|]+))?
82
+ (?:\|s:(?<source_type_name>[^|]+))?
83
+ (?:\|t:(?<alert_type>[^|]+))?
84
+ (?:\|\#(?<tags>(?:[^|,]+(?:,[^|,]+)*)))?
85
85
  \n? # In some implementations, the datagram may include a trailing newline.
86
86
  \z
87
87
  }x.freeze
@@ -84,7 +84,7 @@ module StatsD
84
84
  compile_tags(tags, datagram)
85
85
  end
86
86
 
87
- datagram << "|m:#{normalize_name(message)}" if message
87
+ datagram << "|m:#{Sanitization.service_check_message(message)}" if message
88
88
  datagram
89
89
  end
90
90
 
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatsD
4
+ module Instrument
5
+ # Field-specific wire normalization. Clean strings are returned unchanged;
6
+ # callers retaining a frozen value must take ownership themselves.
7
+ # @api private
8
+ module Sanitization
9
+ # Hot serializers use these same rules inline to avoid per-field Ruby calls.
10
+ NAME_PATTERN = /[:|@\s]/.freeze
11
+ NAME_CHARACTERS = ":|@ \t\r\n\f\v"
12
+ TAG_PATTERN = /[|,\r\n]/.freeze
13
+ TAG_CHARACTERS = "|,\r\n"
14
+
15
+ class << self
16
+ # Replace each ASCII whitespace or protocol delimiter with one underscore.
17
+ # @return [String] The original string if clean, otherwise a new string.
18
+ def name(string)
19
+ NAME_PATTERN.match?(string) ? string.tr(NAME_CHARACTERS, "_") : string
20
+ end
21
+
22
+ # Tag components allow spaces and colons, but not field/tag separators.
23
+ def tag(string)
24
+ TAG_PATTERN.match?(string) ? string.tr(TAG_CHARACTERS, "") : string
25
+ end
26
+
27
+ # Keep message whitespace intact; this is not a metric name.
28
+ def service_check_message(string)
29
+ /[:|@\r\n]/.match?(string) ? string.tr(":|@\r\n", "_") : string
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StatsD
4
4
  module Instrument
5
- VERSION = "4.0.0"
5
+ VERSION = "4.0.1"
6
6
  end
7
7
  end
@@ -391,6 +391,7 @@ require "statsd/instrument/client"
391
391
  require "statsd/instrument/datagram"
392
392
  require "statsd/instrument/aggregator"
393
393
  require "statsd/instrument/dogstatsd_datagram"
394
+ require "statsd/instrument/sanitization"
394
395
  require "statsd/instrument/datagram_builder"
395
396
  require "statsd/instrument/statsd_datagram_builder"
396
397
  require "statsd/instrument/dogstatsd_datagram_builder"
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class NewlineNormalizationTest < Minitest::Test
6
+ def setup
7
+ @old_client = StatsD.singleton_client
8
+ @sink = StatsD::Instrument::CaptureSink.new(parent: StatsD::Instrument::NullSink.new)
9
+ @clients = []
10
+ end
11
+
12
+ def teardown
13
+ StatsD.singleton_client = @old_client
14
+ @clients.each do |client|
15
+ aggregator = client.instance_variable_get(:@aggregator)
16
+ next unless aggregator
17
+
18
+ aggregator.instance_variable_get(:@flush_thread)&.kill
19
+ ObjectSpace.undefine_finalizer(aggregator)
20
+ end
21
+ end
22
+
23
+ def test_all_generic_metric_types_keep_line_breaks_inside_the_prefix
24
+ builder = StatsD::Instrument::DatagramBuilder.new(prefix: "storefront")
25
+ [:c, :g, :ms, :s, :h, :d, :kv].each do |type|
26
+ ["\n", "\r", "\r\n"].each do |delimiter|
27
+ datagram = builder.public_send(type, "http.requests#{delimiter}checkout", 1, nil, nil)
28
+ assert_equal("storefront.http.requests#{"_" * delimiter.size}checkout:1|#{type}", datagram)
29
+ end
30
+ end
31
+ end
32
+
33
+ def test_prefix_and_name_are_sanitized_without_mutating_inputs
34
+ prefix = "app\r\nnamespace"
35
+ name = "name\r\nchild"
36
+ builder = StatsD::Instrument::DatagramBuilder.new(prefix: prefix)
37
+ assert_equal("app__namespace.name__child:1|c", builder.c(name, 1, nil, nil))
38
+ assert_equal("app\r\nnamespace", prefix)
39
+ assert_equal("name\r\nchild", name)
40
+ assert_equal("name__child", builder.send(:normalize_name, name))
41
+ end
42
+
43
+ def test_array_and_hash_tags_remove_delimiters_without_mutating_inputs
44
+ builder = StatsD::Instrument::DatagramBuilder.new
45
+ array = ["k\r\ney:v\r\na|l,ue", "good:tag"].freeze
46
+ hash = { "k\r\ney" => "v\r\na|l,ue", good: "tag" }.freeze
47
+ [array, hash].each do |tags|
48
+ assert_equal("name:1|c|#key:value,good:tag", builder.c("name", 1, nil, tags))
49
+ end
50
+ assert_equal("k\r\ney:v\r\na|l,ue", array.first)
51
+ assert_equal("v\r\na|l,ue", hash.values.first)
52
+ end
53
+
54
+ def test_serialized_tags_preserve_commas_as_separators
55
+ builder = StatsD::Instrument::DatagramBuilder.new
56
+ assert_equal("name:1|c|#a:one_,b:two_", builder.c("name", 1, nil, "a:one\r,b:two\n"))
57
+ assert_equal("name:1|c|#a:one,b:two", builder.c("name", 1, nil, "a:one,b:two"))
58
+ end
59
+
60
+ def test_default_tags_in_each_supported_representation
61
+ [["k:v\n"], { k: "v\n" }, "k:v\n"].each do |tags|
62
+ builder = StatsD::Instrument::DatagramBuilder.new(default_tags: tags)
63
+ datagram = builder.c("name", 1, nil, ["extra:tag"])
64
+ refute_match(/[\r\n]/, datagram)
65
+ assert_includes(datagram, ",extra:tag")
66
+ end
67
+ end
68
+
69
+ def test_set_values_replace_newlines
70
+ builder = StatsD::Instrument::DatagramBuilder.new(prefix: "app")
71
+ assert_equal("app.users:alice__smith|s", builder.s("users", "alice\r\nsmith", nil, nil))
72
+ assert_equal("app.users:42|s", builder.s("users", 42, nil, nil))
73
+ end
74
+
75
+ def test_unicode_and_valid_inputs_are_unchanged
76
+ builder = StatsD::Instrument::DatagramBuilder.new(prefix: "shop")
77
+ assert_equal("shop.café:1|c|#city:東京", builder.c("café", 1, nil, ["city:東京"]))
78
+ name = +"safe.name"
79
+ assert_same(name, builder.send(:normalize_name, name))
80
+ end
81
+
82
+ def test_dogstatsd_service_check_name_and_tags
83
+ builder = StatsD::Instrument::DogStatsDDatagramBuilder.new(prefix: "app\n")
84
+ assert_equal("_sc|app_.service_check|0|#key:value", builder._sc("service\rcheck", :ok, tags: ["key:va\nlue"]))
85
+ # Event text has its own documented escaping; tag normalization must preserve it.
86
+ event = builder._e("title", "first\nsecond", tags: ["key:va\nlue"])
87
+ refute_match(/[\r\n]/, event)
88
+ assert_includes(event, 'first\nsecond')
89
+ assert_includes(event, "|#key:value")
90
+ end
91
+
92
+ def test_normal_and_aggregated_client_paths
93
+ [false, true].each do |aggregate|
94
+ client = new_client(aggregate)
95
+ [:increment, :gauge, :measure, :distribution, :histogram].each do |method|
96
+ client.public_send(method, "metric\r\nname", 1, tags: { "k\r\ney" => "va\nlue" })
97
+ client.public_send(method, "unprefixed\nname", 1, tags: ["key:va\nlue"], no_prefix: true)
98
+ end
99
+ client.set("set\nname", "v\nalue", tags: ["key:va\nlue"])
100
+ client.force_flush
101
+ assert_equal(11, @sink.datagrams.size)
102
+ @sink.datagrams.each do |datagram|
103
+ refute_match(/[\r\n]/, datagram.source)
104
+ assert_includes(datagram.source, "key:value")
105
+ end
106
+ @sink.clear
107
+ end
108
+ end
109
+
110
+ def test_aggregator_finalizer_and_unhealthy_thread_fallback
111
+ client = new_client(true)
112
+ aggregator = client.instance_variable_get(:@aggregator)
113
+ client.increment("name\nchild", tags: ["key:va\nlue"])
114
+ finalizer = aggregator.instance_variable_get(:@finalizer)
115
+ StatsD::Instrument::Aggregator.finalize(finalizer).call
116
+ assert_equal(1, @sink.datagrams.size)
117
+ refute_match(/[\r\n]/, @sink.datagrams.first.source)
118
+
119
+ @sink.clear
120
+ aggregator.stubs(:thread_healthcheck).returns(false)
121
+ client.increment("name\nchild", tags: ["key:va\nlue"])
122
+ assert_equal(1, @sink.datagrams.size)
123
+ refute_match(/[\r\n]/, @sink.datagrams.first.source)
124
+ end
125
+
126
+ def test_compiled_static_and_dynamic_fields_with_and_without_aggregation
127
+ [false, true].each do |aggregate|
128
+ client = new_client(aggregate)
129
+ StatsD.singleton_client = client
130
+ [
131
+ StatsD::Instrument::CompiledMetric::Counter,
132
+ StatsD::Instrument::CompiledMetric::Gauge,
133
+ StatsD::Instrument::CompiledMetric::Distribution,
134
+ ].each do |base|
135
+ metric = Class.new(base) do
136
+ define(name: "name\r\nchild", static_tags: { "static\nkey" => "va\rlue" }, tags: { dynamic: String, symbol: Symbol })
137
+ end
138
+ 2.times { metric.public_send(base.method_name, 1, dynamic: "va\nlue", symbol: :"sym\rbol") }
139
+ client.force_flush
140
+ assert_equal(aggregate ? 1 : 2, @sink.datagrams.size)
141
+ @sink.datagrams.each do |datagram|
142
+ refute_match(/[\r\n]/, datagram.source)
143
+ assert_includes(datagram.source, "app_.name__child:")
144
+ assert_includes(datagram.source, "statickey:value")
145
+ assert_includes(datagram.source, "dynamic:value,symbol:symbol")
146
+ end
147
+ @sink.clear
148
+
149
+ metric = Class.new(base) { define(name: "static\nname", static_tags: { key: "va\nlue" }) }
150
+ metric.public_send(base.method_name, 1)
151
+ client.force_flush
152
+ assert_equal(1, @sink.datagrams.size)
153
+ refute_match(/[\r\n]/, @sink.datagrams.first.source)
154
+ @sink.clear
155
+ end
156
+ end
157
+ end
158
+
159
+ def test_compiled_default_tags_and_dynamic_keys
160
+ builder = StatsD::Instrument::CompiledMetric::DatagramBlueprintBuilder
161
+ [["default:va\nlue"], { default: "va\nlue" }, "default:va\nlue"].each do |tags|
162
+ blueprint = builder.build(
163
+ name: "name\n",
164
+ type: "c",
165
+ client_prefix: "app\r",
166
+ no_prefix: false,
167
+ default_tags: tags,
168
+ static_tags: {},
169
+ dynamic_tags: { "dy\nnamic" => String },
170
+ sample_rate: 1,
171
+ )
172
+ datagram = StatsD::Instrument::CompiledMetric::PrecompiledDatagram.new(["va\nlue"], blueprint, 1).to_datagram(1)
173
+ assert_equal("app_.name_:1|c|#default:value,dynamic:value", datagram)
174
+ end
175
+ end
176
+
177
+ def test_compiled_dynamic_tags_are_checked_after_mutation_and_with_cache_disabled
178
+ StatsD.singleton_client = new_client(false)
179
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter) do
180
+ define(name: "name", tags: { key: String }, max_cache_size: 0)
181
+ end
182
+ tag = +"value"
183
+ metric.increment(key: tag)
184
+ @sink.clear
185
+ tag << "\nwith|newline"
186
+ metric.increment(key: tag)
187
+ assert_equal(1, @sink.datagrams.size)
188
+ refute_match(/[\r\n]/, @sink.datagrams.first.source)
189
+ assert_includes(@sink.datagrams.first.source, "|#default:value,key:valuewithnewline")
190
+ end
191
+
192
+ def test_compiled_name_does_not_freeze_the_input_string
193
+ StatsD.singleton_client = new_client(false)
194
+ name = +"name"
195
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter)
196
+ metric.define(name: name)
197
+ name << ".changed"
198
+ assert_equal("name", metric.metric_name)
199
+ assert_predicate(metric.metric_name, :frozen?)
200
+ end
201
+
202
+ def test_normalization_is_silent
203
+ StatsD.logger.expects(:warn).never
204
+ StatsD.logger.expects(:error).never
205
+ client = new_client(false)
206
+ client.increment("name\n", tags: ["key:va\nlue"])
207
+ assert_equal(1, @sink.datagrams.size)
208
+ assert_equal("app_.name_:1|c|#default:value,key:value", @sink.datagrams.first.source)
209
+ end
210
+
211
+ def test_names_and_prefixes_share_normalization_across_all_paths
212
+ [false, true].each do |aggregate|
213
+ [" ", "\t", "\n", "\r", "\f", "\v", ":", "|", "@"].each do |character|
214
+ client = new_client(aggregate).clone_with_options(prefix: "app#{character}prefix")
215
+ @clients << client
216
+ StatsD.singleton_client = client
217
+ client.increment("my#{character}value")
218
+ client.increment("my#{character}value", no_prefix: true)
219
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter)
220
+ metric.define(name: "my#{character}value")
221
+ metric.increment
222
+ unprefixed = Class.new(StatsD::Instrument::CompiledMetric::Counter)
223
+ unprefixed.define(name: "my#{character}value", no_prefix: true)
224
+ unprefixed.increment
225
+ client.force_flush
226
+ assert_equal(4, @sink.datagrams.size)
227
+ assert_equal(["app_prefix.my_value", "app_prefix.my_value", "my_value", "my_value"], @sink.datagrams.map(&:name).sort)
228
+ assert_equal("my_value", metric.metric_name)
229
+ @sink.clear
230
+ end
231
+ end
232
+ end
233
+
234
+ def test_tag_values_and_service_check_messages_keep_spaces
235
+ builder = StatsD::Instrument::DogStatsDDatagramBuilder.new(prefix: "my app")
236
+ assert_equal("my_app.my_metric:1|c|#key:hello world", builder.c("my metric", 1, nil, ["key:hello world"]))
237
+ assert_equal("my_app.my_metric:1|c|#key:hello world", builder.c("my metric", 1, nil, { key: "hello world" }))
238
+ assert_equal("my_app.users:Alice Smith|s", builder.s("users", "Alice Smith", nil, nil))
239
+ assert_equal("_sc|my_app.my_service|0|m:hello world_", builder._sc("my service", :ok, message: "hello world\n"))
240
+
241
+ StatsD.singleton_client = new_client(false)
242
+ metric = Class.new(StatsD::Instrument::CompiledMetric::Counter)
243
+ metric.define(name: "my metric", static_tags: { static: "hello world" }, tags: { dynamic: String })
244
+ metric.increment(dynamic: "hello world")
245
+ assert_includes(@sink.datagrams.last.tags, "static:hello world")
246
+ assert_includes(@sink.datagrams.last.tags, "dynamic:hello world")
247
+ end
248
+
249
+ private
250
+
251
+ def new_client(aggregate)
252
+ client = StatsD::Instrument::Client.new(
253
+ sink: @sink,
254
+ prefix: "app\n",
255
+ default_tags: ["default:va\nlue"],
256
+ enable_aggregation: aggregate,
257
+ aggregation_flush_interval: 3600,
258
+ )
259
+ @clients << client
260
+ client
261
+ end
262
+ end
@@ -19,9 +19,7 @@ module Rubocop
19
19
  def test_offense_statsd_prefix
20
20
  assert_offense('StatsD.prefix = "foo"')
21
21
  # The interpolation is intentionally literal source passed to the cop under test.
22
- # rubocop:disable Lint/InterpolationCheck
23
- assert_offense('"#{StatsD.prefix}.foo"')
24
- # rubocop:enable Lint/InterpolationCheck
22
+ assert_offense("\"\#{StatsD.prefix}.foo\"")
25
23
  end
26
24
 
27
25
  def test_offense_statsd_default_tags
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class SanitizationTest < Minitest::Test
6
+ def test_clean_input_is_returned_without_copying_or_freezing
7
+ [:name, :tag, :service_check_message].each do |method|
8
+ mutable = +"café.safe_value"
9
+ assert_same(mutable, StatsD::Instrument::Sanitization.public_send(method, mutable))
10
+ refute_predicate(mutable, :frozen?)
11
+ assert_same(mutable.freeze, StatsD::Instrument::Sanitization.public_send(method, mutable))
12
+ end
13
+ end
14
+
15
+ def test_name_replaces_each_ascii_whitespace_and_protocol_delimiter
16
+ [" ", "\t", "\n", "\r", "\f", "\v", ":", "|", "@"].each do |character|
17
+ name = +"prefix.#{character}my#{character}value"
18
+ expected_original = name.dup
19
+ assert_equal("prefix._my_value", StatsD::Instrument::Sanitization.name(name))
20
+ assert_equal(expected_original, name)
21
+ refute_predicate(name, :frozen?)
22
+ end
23
+ assert_equal("name___value", StatsD::Instrument::Sanitization.name("name \t\nvalue"))
24
+ end
25
+
26
+ def test_name_does_not_expand_whitespace_matching_to_unicode
27
+ name = "café\u00a0value"
28
+ assert_same(name, StatsD::Instrument::Sanitization.name(name))
29
+ end
30
+
31
+ def test_tags_and_values_keep_ordinary_whitespace
32
+ text = "hello \tworld\f\v"
33
+ [:tag, :service_check_message].each do |method|
34
+ assert_same(text, StatsD::Instrument::Sanitization.public_send(method, text))
35
+ end
36
+ end
37
+
38
+ def test_each_field_preserves_its_existing_replacement_rules
39
+ sanitizer = StatsD::Instrument::Sanitization
40
+ assert_equal("tag:value", sanitizer.tag("tag:va|,\r\nlue"))
41
+ assert_equal("hello world____", sanitizer.service_check_message("hello world:|@\n"))
42
+ end
43
+
44
+ def test_compiled_name_helper_returns_clean_input_unchanged
45
+ name = +"name"
46
+ assert_same(name, StatsD::Instrument::CompiledMetric::DatagramBlueprintBuilder.normalize_name(name))
47
+ refute_predicate(name, :frozen?)
48
+ end
49
+ 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: 4.0.0
4
+ version: 4.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jesse Storimer
@@ -71,6 +71,7 @@ files:
71
71
  - lib/statsd/instrument/rubocop/positional_arguments.rb
72
72
  - lib/statsd/instrument/rubocop/singleton_configuration.rb
73
73
  - lib/statsd/instrument/rubocop/splat_arguments.rb
74
+ - lib/statsd/instrument/sanitization.rb
74
75
  - lib/statsd/instrument/sink.rb
75
76
  - lib/statsd/instrument/statsd_datagram_builder.rb
76
77
  - lib/statsd/instrument/strict.rb
@@ -101,6 +102,7 @@ files:
101
102
  - test/integration_test.rb
102
103
  - test/log_sink_test.rb
103
104
  - test/matchers_test.rb
105
+ - test/newline_normalization_test.rb
104
106
  - test/null_sink_test.rb
105
107
  - test/rubocop/measure_as_dist_argument_test.rb
106
108
  - test/rubocop/metaprogramming_positional_arguments_test.rb
@@ -110,6 +112,7 @@ files:
110
112
  - test/rubocop/positional_arguments_test.rb
111
113
  - test/rubocop/singleton_configuration_test.rb
112
114
  - test/rubocop/splat_arguments_test.rb
115
+ - test/sanitization_test.rb
113
116
  - test/statsd_datagram_builder_test.rb
114
117
  - test/statsd_instrumentation_test.rb
115
118
  - test/statsd_test.rb
@@ -135,7 +138,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
135
138
  - !ruby/object:Gem::Version
136
139
  version: '0'
137
140
  requirements: []
138
- rubygems_version: 4.0.18
141
+ rubygems_version: 4.0.21
139
142
  specification_version: 4
140
143
  summary: A StatsD client for Ruby apps
141
144
  test_files:
@@ -161,6 +164,7 @@ test_files:
161
164
  - test/integration_test.rb
162
165
  - test/log_sink_test.rb
163
166
  - test/matchers_test.rb
167
+ - test/newline_normalization_test.rb
164
168
  - test/null_sink_test.rb
165
169
  - test/rubocop/measure_as_dist_argument_test.rb
166
170
  - test/rubocop/metaprogramming_positional_arguments_test.rb
@@ -170,6 +174,7 @@ test_files:
170
174
  - test/rubocop/positional_arguments_test.rb
171
175
  - test/rubocop/singleton_configuration_test.rb
172
176
  - test/rubocop/splat_arguments_test.rb
177
+ - test/sanitization_test.rb
173
178
  - test/statsd_datagram_builder_test.rb
174
179
  - test/statsd_instrumentation_test.rb
175
180
  - test/statsd_test.rb