datadog-statsd-schema 0.1.2 → 0.2.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,29 @@
1
+ namespace "marathon" do
2
+ tags do
3
+ tag :course, values: %w[sf-marathon new-york austin]
4
+ tag :length, values: %w[full half]
5
+ end
6
+
7
+ namespace "started" do
8
+ metrics do
9
+ counter "total" do
10
+ description "Number of people who started the Marathon"
11
+ tags required: %i[course length]
12
+ end
13
+ end
14
+ end
15
+
16
+ namespace "finished" do
17
+ metrics do
18
+ counter "total" do
19
+ description "Number of people who finished the Marathon"
20
+ inherit_tags "marathon.started.total"
21
+ end
22
+
23
+ distribution "duration" do
24
+ description "Marathon duration"
25
+ inherit_tags "marathon.started.total"
26
+ end
27
+ end
28
+ end
29
+ end
data/exe/dss ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "datadog/statsd/schema"
7
+
8
+ Dry::CLI.new(Datadog::Statsd::Schema::CLI).call
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "colored2"
5
+ module Datadog
6
+ class Statsd
7
+ module Schema
8
+ # Result structure for schema analysis
9
+ # @!attribute [r] total_unique_metrics
10
+ # @return [Integer] Total number of unique metric names (including expansions)
11
+ # @!attribute [r] metrics_analysis
12
+ # @return [Array<MetricAnalysis>] Analysis for each metric
13
+ # @!attribute [r] total_possible_custom_metrics
14
+ # @return [Integer] Total number of possible custom metric combinations
15
+ AnalysisResult = Data.define(
16
+ :total_unique_metrics,
17
+ :metrics_analysis,
18
+ :total_possible_custom_metrics
19
+ )
20
+
21
+ # Analysis data for individual metrics
22
+ # @!attribute [r] metric_name
23
+ # @return [String] Full metric name
24
+ # @!attribute [r] metric_type
25
+ # @return [Symbol] Type of metric (:counter, :gauge, etc.)
26
+ # @!attribute [r] expanded_names
27
+ # @return [Array<String>] All expanded metric names (for gauge/distribution/histogram)
28
+ # @!attribute [r] unique_tags
29
+ # @return [Integer] Number of unique tags for this metric
30
+ # @!attribute [r] unique_tag_values
31
+ # @return [Integer] Total number of unique tag values across all tags
32
+ # @!attribute [r] total_combinations
33
+ # @return [Integer] Total possible tag value combinations for this metric
34
+ MetricAnalysis = Data.define(
35
+ :metric_name,
36
+ :metric_type,
37
+ :expanded_names,
38
+ :unique_tags,
39
+ :unique_tag_values,
40
+ :total_combinations
41
+ )
42
+
43
+ # Analyzes schema instances to provide comprehensive metrics statistics
44
+ class Analyzer
45
+ # Metric suffixes for different metric types that create multiple metrics
46
+ METRIC_EXPANSIONS = {
47
+ gauge: %w[count min max sum avg],
48
+ distribution: %w[count min max sum avg p50 p75 p90 p95 p99],
49
+ histogram: %w[count min max sum avg]
50
+ }.freeze
51
+
52
+ attr_reader :schemas, :stdout, :stderr, :color
53
+
54
+ # Initialize analyzer with schema(s)
55
+ # @param schemas [Datadog::Statsd::Schema::Namespace, Array<Datadog::Statsd::Schema::Namespace>]
56
+ # Single schema or array of schemas to analyze
57
+ def initialize(schemas, stdout: $stdout, stderr: $stderr, color: true)
58
+ @schemas = Array(schemas)
59
+ @stdout = stdout
60
+ @stderr = stderr
61
+ @color = color
62
+ if color
63
+ Colored2.enable!
64
+ else
65
+ Colored2.disable!
66
+ end
67
+ end
68
+
69
+ # Perform comprehensive analysis of the schemas
70
+ # @return [AnalysisResult] Complete analysis results
71
+ def analyze
72
+ all_metrics = collect_all_metrics
73
+ metrics_analysis = analyze_metrics(all_metrics)
74
+
75
+ total_unique_metrics = metrics_analysis.sum { |analysis| analysis.expanded_names.size }
76
+ total_possible_custom_metrics = metrics_analysis.sum(&:total_combinations)
77
+
78
+ stdout.puts format_analysis_output(metrics_analysis, total_unique_metrics, total_possible_custom_metrics)
79
+
80
+ AnalysisResult.new(
81
+ total_unique_metrics: total_unique_metrics,
82
+ metrics_analysis: metrics_analysis,
83
+ total_possible_custom_metrics: total_possible_custom_metrics
84
+ )
85
+ end
86
+
87
+ private
88
+
89
+ # Collect all metrics from all schemas with their context
90
+ # @return [Array<Hash>] Array of metric info hashes
91
+ def collect_all_metrics
92
+ all_metrics = []
93
+
94
+ @schemas.each do |schema|
95
+ schema_metrics = schema.all_metrics
96
+ schema_metrics.each do |metric_full_name, metric_info|
97
+ all_metrics << {
98
+ full_name: metric_full_name,
99
+ definition: metric_info[:definition],
100
+ namespace: metric_info[:namespace],
101
+ namespace_path: metric_info[:namespace_path]
102
+ }
103
+ end
104
+ end
105
+
106
+ all_metrics
107
+ end
108
+
109
+ # Analyze each metric for tags and combinations
110
+ # @param all_metrics [Array<Hash>] Collected metrics
111
+ # @return [Array<MetricAnalysis>] Analysis for each metric
112
+ def analyze_metrics(all_metrics)
113
+ all_metrics.map do |metric_info|
114
+ analyze_single_metric(metric_info)
115
+ end
116
+ end
117
+
118
+ # Analyze a single metric
119
+ # @param metric_info [Hash] Metric information
120
+ # @return [MetricAnalysis] Analysis for this metric
121
+ def analyze_single_metric(metric_info)
122
+ definition = metric_info[:definition]
123
+ namespace = metric_info[:namespace]
124
+ namespace_path = metric_info[:namespace_path]
125
+ full_name = metric_info[:full_name]
126
+
127
+ # Get expanded metric names based on type
128
+ expanded_names = get_expanded_metric_names(full_name, definition.type)
129
+
130
+ # Build effective tags including parent namespace tags
131
+ effective_tags = build_effective_tags_for_metric(namespace, namespace_path)
132
+ available_tag_definitions = collect_available_tags(definition, effective_tags)
133
+
134
+ # Calculate tag statistics
135
+ unique_tags = available_tag_definitions.size
136
+ unique_tag_values = available_tag_definitions.values.sum { |tag_def| count_tag_values(tag_def) }
137
+
138
+ # Calculate total combinations (cartesian product of all tag values)
139
+ total_combinations = calculate_tag_combinations(available_tag_definitions) * expanded_names.size
140
+
141
+ MetricAnalysis.new(
142
+ metric_name: full_name,
143
+ metric_type: definition.type,
144
+ expanded_names: expanded_names,
145
+ unique_tags: unique_tags,
146
+ unique_tag_values: unique_tag_values,
147
+ total_combinations: total_combinations
148
+ )
149
+ end
150
+
151
+ # Build effective tags for a metric including parent namespace tags
152
+ # @param namespace [Namespace] The immediate namespace containing the metric
153
+ # @param namespace_path [Array<Symbol>] Full path to the namespace
154
+ # @return [Hash] Hash of effective tag definitions
155
+ def build_effective_tags_for_metric(namespace, namespace_path)
156
+ effective_tags = {}
157
+
158
+ # Start from the root and build up tags through the hierarchy
159
+ current_path = []
160
+
161
+ # Find and traverse parent namespaces to collect their tags
162
+ @schemas.each do |schema|
163
+ # Traverse the namespace path to collect parent tags
164
+ namespace_path.each do |path_segment|
165
+ # Skip :root as it's just the schema root
166
+ next if path_segment == :root
167
+
168
+ current_path << path_segment
169
+
170
+ # Find the namespace at this path
171
+ path_str = current_path.join(".")
172
+ found_namespace = schema.find_namespace_by_path(path_str)
173
+
174
+ next unless found_namespace
175
+
176
+ # Add tags from this namespace level
177
+ found_namespace.tags.each do |tag_name, tag_def|
178
+ effective_tags[tag_name] = tag_def
179
+ end
180
+ end
181
+
182
+ break if effective_tags.any? # Found the schema with our namespaces
183
+ end
184
+
185
+ # Add the immediate namespace's tags (these take precedence)
186
+ namespace.tags.each do |tag_name, tag_def|
187
+ effective_tags[tag_name] = tag_def
188
+ end
189
+
190
+ effective_tags
191
+ end
192
+
193
+ # Get expanded metric names for types that create multiple metrics
194
+ # @param base_name [String] Base metric name
195
+ # @param metric_type [Symbol] Type of the metric
196
+ # @return [Array<String>] All metric names this creates
197
+ def get_expanded_metric_names(base_name, metric_type)
198
+ expansions = METRIC_EXPANSIONS[metric_type]
199
+
200
+ if expansions
201
+ # For metrics that expand, create name.suffix for each expansion
202
+ expansions.map { |suffix| "#{base_name}.#{suffix}" }
203
+ else
204
+ # For simple metrics, just return the base name
205
+ [base_name]
206
+ end
207
+ end
208
+
209
+ # Collect all tags available to a metric
210
+ # @param definition [MetricDefinition] The metric definition
211
+ # @param effective_tags [Hash] Tags available in the namespace
212
+ # @return [Hash] Hash of tag name to tag definition
213
+ def collect_available_tags(definition, effective_tags)
214
+ available_tags = {}
215
+
216
+ # Handle tag inheritance from other metrics first
217
+ if definition.inherit_tags
218
+ inherited_tags = resolve_inherited_tags(definition.inherit_tags)
219
+ inherited_tags.keys
220
+ inherited_tags.each do |tag_name, tag_def|
221
+ available_tags[tag_name] = tag_def
222
+ end
223
+ end
224
+
225
+ # Determine which additional tags to include based on metric's tag specification
226
+ if definition.allowed_tags.any? || definition.required_tags.any?
227
+ # If metric specifies allowed or required tags, only include those + inherited tags
228
+ additional_tag_names = (definition.allowed_tags + definition.required_tags).map(&:to_sym).uniq
229
+
230
+ additional_tag_names.each do |tag_name|
231
+ available_tags[tag_name] = effective_tags[tag_name] if effective_tags[tag_name]
232
+ end
233
+ else
234
+ # If no allowed or required tags specified, include all effective namespace tags
235
+ # (This is the case when a metric doesn't restrict its tags)
236
+ effective_tags.each do |tag_name, tag_def|
237
+ available_tags[tag_name] = tag_def unless available_tags[tag_name]
238
+ end
239
+ end
240
+
241
+ available_tags
242
+ end
243
+
244
+ # Resolve inherited tags from a parent metric path
245
+ # @param inherit_path [String] Dot-separated path to parent metric
246
+ # @return [Hash] Hash of inherited tag definitions
247
+ def resolve_inherited_tags(inherit_path)
248
+ inherited_tags = {}
249
+
250
+ @schemas.each do |schema|
251
+ # Find the parent metric in the schema
252
+ all_metrics = schema.all_metrics
253
+ parent_metric_info = all_metrics[inherit_path]
254
+
255
+ next unless parent_metric_info
256
+
257
+ parent_definition = parent_metric_info[:definition]
258
+ parent_namespace = parent_metric_info[:namespace]
259
+ parent_namespace_path = parent_metric_info[:namespace_path]
260
+
261
+ # Build effective tags for the parent metric (including its own parent namespace tags)
262
+ parent_effective_tags = build_effective_tags_for_metric(parent_namespace, parent_namespace_path)
263
+
264
+ # Recursively resolve parent's inherited tags first
265
+ if parent_definition.inherit_tags
266
+ parent_inherited = resolve_inherited_tags(parent_definition.inherit_tags)
267
+ inherited_tags.merge!(parent_inherited)
268
+ end
269
+
270
+ # Get the tags that are actually available to the parent metric
271
+ parent_available_tags = collect_parent_available_tags(parent_definition, parent_effective_tags)
272
+ inherited_tags.merge!(parent_available_tags)
273
+
274
+ break # Found the parent metric, stop searching
275
+ end
276
+
277
+ inherited_tags
278
+ end
279
+
280
+ # Collect available tags for a parent metric (without recursion to avoid infinite loops)
281
+ # @param definition [MetricDefinition] The parent metric definition
282
+ # @param effective_tags [Hash] Tags available in the parent's namespace
283
+ # @return [Hash] Hash of tag name to tag definition
284
+ def collect_parent_available_tags(definition, effective_tags)
285
+ available_tags = {}
286
+
287
+ # Start with all effective tags from namespace
288
+ effective_tags.each do |tag_name, tag_def|
289
+ available_tags[tag_name] = tag_def
290
+ end
291
+
292
+ # Apply parent metric's tag restrictions
293
+ if definition.allowed_tags.any?
294
+ allowed_and_required_tags = (definition.allowed_tags + definition.required_tags).map(&:to_sym).uniq
295
+ available_tags.select! { |tag_name, _| allowed_and_required_tags.include?(tag_name) }
296
+ end
297
+
298
+ available_tags
299
+ end
300
+
301
+ # Count the number of possible values for a tag
302
+ # @param tag_definition [TagDefinition] The tag definition
303
+ # @return [Integer] Number of possible values
304
+ def count_tag_values(tag_definition)
305
+ if tag_definition.values.nil?
306
+ # If no values specified, assume it can have any value (estimate)
307
+ 100 # Conservative estimate for open-ended tags
308
+ elsif tag_definition.values.is_a?(Array)
309
+ tag_definition.values.size
310
+ elsif tag_definition.values.is_a?(Regexp)
311
+ # For regex, we can't know exact count, use estimate
312
+ 50 # Conservative estimate for regex patterns
313
+ else
314
+ 1 # Single value
315
+ end
316
+ end
317
+
318
+ # Calculate total possible combinations of tag values
319
+ # @param tag_definitions [Hash] Hash of tag name to definition
320
+ # @return [Integer] Total combinations possible
321
+ def calculate_tag_combinations(tag_definitions)
322
+ return 1 if tag_definitions.empty?
323
+
324
+ # Multiply the number of possible values for each tag
325
+ tag_definitions.values.reduce(1) do |total, tag_def|
326
+ total * count_tag_values(tag_def)
327
+ end
328
+ end
329
+
330
+ # Format the analysis output for display
331
+ # @param metrics_analysis [Array<MetricAnalysis>] All metric analyses
332
+ # @param total_unique_metrics [Integer] Total unique metrics
333
+ # @param total_possible_custom_metrics [Integer] Total possible combinations
334
+ # @return [String] Formatted output
335
+ def format_analysis_output(
336
+ metrics_analysis,
337
+ total_unique_metrics,
338
+ total_possible_custom_metrics
339
+ )
340
+ output = StringIO.new
341
+
342
+ format_metric_analysis_header(output)
343
+ metrics_analysis.each do |analysis|
344
+ output.puts
345
+ format_metric_analysis(output, analysis)
346
+ line(output, placement: :flat)
347
+ end
348
+ summary(output, total_unique_metrics, total_possible_custom_metrics)
349
+ output.string
350
+ end
351
+
352
+ def line(output, placement: :top)
353
+ if placement == :top
354
+ output.puts "┌──────────────────────────────────────────────────────────────────────────────────────────────┐".white.on.blue
355
+ elsif placement == :bottom
356
+ output.puts "└──────────────────────────────────────────────────────────────────────────────────────────────┘".white.on.blue
357
+ elsif placement == :middle
358
+ output.puts "├──────────────────────────────────────────────────────────────────────────────────────────────┤".white.on.blue
359
+ elsif placement == :flat
360
+ output.puts " ──────────────────────────────────────────────────────────────────────────────────────────────".white.bold
361
+ end
362
+ end
363
+
364
+ def summary(output, total_unique_metrics, total_possible_custom_metrics)
365
+ line(output)
366
+ output.puts "│ Schema Analysis Results: │".yellow.bold.on.blue
367
+ output.puts "│ SUMMARY │".white.on.blue
368
+ line(output, placement: :bottom)
369
+ output.puts
370
+ output.puts " Total unique metrics: #{("%3d" % total_unique_metrics).bold.green}"
371
+ output.puts "Total possible custom metric combinations: #{("%3d" % total_possible_custom_metrics).bold.green}"
372
+ output.puts
373
+ end
374
+
375
+ def format_metric_analysis_header(output)
376
+ line(output)
377
+ output.puts "│ Detailed Metric Analysis: │".white.on.blue
378
+ line(output, placement: :bottom)
379
+ end
380
+
381
+ def format_metric_analysis(output, analysis)
382
+ output.puts " • #{analysis.metric_type.to_s.cyan}('#{analysis.metric_name.yellow.bold}')"
383
+ if analysis.expanded_names.size > 1
384
+ output.puts " Expanded names:"
385
+ output.print " • ".yellow
386
+ output.puts analysis.expanded_names.join("\n • ").yellow
387
+ end
388
+ output.puts
389
+ output.puts " Unique tags: #{("%3d" % analysis.unique_tags).bold.green}"
390
+ output.puts " Total tag values: #{("%3d" % analysis.unique_tag_values).bold.green}"
391
+ output.puts " Possible combinations: #{("%3d" % analysis.total_combinations).bold.green}"
392
+ output.puts
393
+ end
394
+ end
395
+ end
396
+ end
397
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "commands"
5
+
6
+ module Datadog
7
+ class Statsd
8
+ module Schema
9
+ module CLI
10
+ extend Dry::CLI::Registry
11
+
12
+ register "analyze", Commands::Analyze
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+
5
+ module Datadog
6
+ class Statsd
7
+ module Schema
8
+ module Commands
9
+ # @description Analyze a schema file for metrics and validation
10
+ class Analyze < Dry::CLI::Command
11
+ class << self
12
+ attr_accessor :stdout, :stderr
13
+ end
14
+
15
+ self.stdout = $stdout
16
+ self.stderr = $stderr
17
+
18
+ desc "Analyze a schema file for metrics and validation"
19
+
20
+ option :file, aliases: %w[-f], type: :string, required: true, desc: "Path to the schema file to analyze"
21
+ option :color, aliases: %w[-c], type: :boolean, required: false, desc: "Enable/Disable color output"
22
+
23
+ # @description Analyze a schema file for metrics and validation
24
+ # @param options [Hash] The options for the command
25
+ # @option options [String] :file The path to the schema file to analyze
26
+ # @option options [Boolean] :color Enable/Disable color output
27
+ # @return [void]
28
+ def call(**options)
29
+ file = options[:file]
30
+
31
+ unless file
32
+ warn "Error: --file option is required"
33
+ warn "Usage: dss analyze --file <schema.rb>"
34
+ exit 1
35
+ end
36
+
37
+ warn "Analyzing schema file: #{file}, color: #{options[:color]}"
38
+ @schema = ::Datadog::Statsd::Schema.load_file(file)
39
+ ::Datadog::Statsd::Schema::Analyzer.new([@schema],
40
+ stdout: self.class.stdout,
41
+ stderr: self.class.stderr,
42
+ color: options[:color]).analyze
43
+ end
44
+
45
+ def warn(message)
46
+ self.class.stderr.puts message
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ class Statsd
5
+ module Schema
6
+ module Commands
7
+ # CLI commands will be defined here
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ # Require all command files
14
+ require_relative "commands/analyze"
@@ -27,7 +27,7 @@ module Datadog
27
27
  # @author Datadog Team
28
28
  # @since 0.1.0
29
29
  class Namespace < Dry::Struct
30
- # Include the types module for easier access to Dry::Types
30
+ # Include the types module for easier access to Dry::Typesa
31
31
  module Types
32
32
  include Dry.Types()
33
33
  end
@@ -8,7 +8,7 @@ module Datadog
8
8
  module Schema
9
9
  # Current version of the datadog-statsd-schema gem
10
10
  # @return [String] The semantic version string
11
- VERSION = "0.1.2"
11
+ VERSION = "0.2.0"
12
12
  end
13
13
  end
14
14
  end
@@ -9,6 +9,8 @@ require_relative "schema/tag_definition"
9
9
  require_relative "schema/metric_definition"
10
10
  require_relative "schema/namespace"
11
11
  require_relative "schema/schema_builder"
12
+ require_relative "schema/analyzer"
13
+ require_relative "schema/cli"
12
14
  require_relative "emitter"
13
15
 
14
16
  # @author Konstantin Gredeskoul @ https://github.com/kigster
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: datadog-statsd-schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-06-06 00:00:00.000000000 Z
10
+ date: 2025-06-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: dry-cli
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: dry-schema
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -144,7 +158,7 @@ description: This gem is an adapter for the dogstatsd-ruby gem. Unlike the Datad
144
158
  email:
145
159
  - kigster@gmail.com
146
160
  executables:
147
- - datadog-statsd-schema
161
+ - dss
148
162
  extensions: []
149
163
  extra_rdoc_files: []
150
164
  files:
@@ -158,13 +172,18 @@ files:
158
172
  - README.md
159
173
  - Rakefile
160
174
  - examples/README.md
175
+ - examples/schema/example_marathon.rb
161
176
  - examples/schema_emitter.png
162
177
  - examples/schema_emitter.rb
163
178
  - examples/shared.rb
164
179
  - examples/simple_emitter.rb
165
- - exe/datadog-statsd-schema
180
+ - exe/dss
166
181
  - lib/datadog/statsd/emitter.rb
167
182
  - lib/datadog/statsd/schema.rb
183
+ - lib/datadog/statsd/schema/analyzer.rb
184
+ - lib/datadog/statsd/schema/cli.rb
185
+ - lib/datadog/statsd/schema/commands.rb
186
+ - lib/datadog/statsd/schema/commands/analyze.rb
168
187
  - lib/datadog/statsd/schema/errors.rb
169
188
  - lib/datadog/statsd/schema/metric_definition.rb
170
189
  - lib/datadog/statsd/schema/namespace.rb
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require "datadog/statsd/schema"