datadog-statsd-schema 0.1.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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+ require "dry-types"
5
+
6
+ module Datadog
7
+ class Statsd
8
+ module Schema
9
+ class MetricDefinition < Dry::Struct
10
+ # Include the types module for easier access
11
+ module Types
12
+ include Dry.Types()
13
+ end
14
+
15
+ # Valid metric types in StatsD
16
+ VALID_METRIC_TYPES = %i[counter gauge histogram distribution timing set].freeze
17
+
18
+ attribute :name, Types::Strict::Symbol
19
+ attribute :type, Types::Strict::Symbol.constrained(included_in: VALID_METRIC_TYPES)
20
+ attribute :description, Types::String.optional.default(nil)
21
+ attribute :allowed_tags, Types::Array.of(Types::Symbol).default([].freeze)
22
+ attribute :required_tags, Types::Array.of(Types::Symbol).default([].freeze)
23
+ attribute :inherit_tags, Types::String.optional.default(nil)
24
+ attribute :units, Types::String.optional.default(nil)
25
+ attribute :namespace, Types::Strict::Symbol.optional.default(nil)
26
+
27
+ # Get the full metric name including namespace path
28
+ def full_name(namespace_path = [])
29
+ return name.to_s if namespace_path.empty?
30
+
31
+ "#{namespace_path.join(".")}.#{name}"
32
+ end
33
+
34
+ # Check if a tag is allowed for this metric
35
+ def allows_tag?(tag_name)
36
+ tag_symbol = tag_name.to_sym
37
+ allowed_tags.empty? || allowed_tags.include?(tag_symbol)
38
+ end
39
+
40
+ # Check if a tag is required for this metric
41
+ def requires_tag?(tag_name)
42
+ tag_symbol = tag_name.to_sym
43
+ required_tags.include?(tag_symbol)
44
+ end
45
+
46
+ # Get all missing required tags from a provided tag set
47
+ def missing_required_tags(provided_tags)
48
+ provided_tag_symbols = provided_tags.keys.map(&:to_sym)
49
+ required_tags - provided_tag_symbols
50
+ end
51
+
52
+ # Get all invalid tags from a provided tag set
53
+ def invalid_tags(provided_tags)
54
+ return [] if allowed_tags.empty? # No restrictions
55
+
56
+ provided_tag_symbols = provided_tags.keys.map(&:to_sym)
57
+ provided_tag_symbols - allowed_tags
58
+ end
59
+
60
+ # Validate a complete tag set for this metric
61
+ def valid_tags?(provided_tags)
62
+ missing_required_tags(provided_tags).empty? && invalid_tags(provided_tags).empty?
63
+ end
64
+
65
+ # Check if this is a timing-based metric
66
+ def timing_metric?
67
+ %i[timing distribution histogram].include?(type)
68
+ end
69
+
70
+ # Check if this is a counting metric
71
+ def counting_metric?
72
+ %i[counter].include?(type)
73
+ end
74
+
75
+ # Check if this is a gauge metric
76
+ def gauge_metric?
77
+ type == :gauge
78
+ end
79
+
80
+ # Check if this is a set metric
81
+ def set_metric?
82
+ type == :set
83
+ end
84
+
85
+ # Get effective tags by merging with inherited tags if present
86
+ def effective_allowed_tags(schema_registry = nil)
87
+ return allowed_tags unless inherit_tags && schema_registry
88
+
89
+ inherited_metric = schema_registry.find_metric(inherit_tags)
90
+ return allowed_tags unless inherited_metric
91
+
92
+ (inherited_metric.effective_allowed_tags(schema_registry) + allowed_tags).uniq
93
+ end
94
+
95
+ def effective_required_tags(schema_registry = nil)
96
+ return required_tags unless inherit_tags && schema_registry
97
+
98
+ inherited_metric = schema_registry.find_metric(inherit_tags)
99
+ return required_tags unless inherited_metric
100
+
101
+ (inherited_metric.effective_required_tags(schema_registry) + required_tags).uniq
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+ require "dry-types"
5
+ require_relative "tag_definition"
6
+ require_relative "metric_definition"
7
+
8
+ module Datadog
9
+ class Statsd
10
+ module Schema
11
+ class Namespace < Dry::Struct
12
+ # Include the types module for easier access
13
+ module Types
14
+ include Dry.Types()
15
+ end
16
+
17
+ attribute :name, Types::Strict::Symbol
18
+ attribute :tags, Types::Hash.map(Types::Symbol, TagDefinition).default({}.freeze)
19
+ attribute :metrics, Types::Hash.map(Types::Symbol, MetricDefinition).default({}.freeze)
20
+ attribute :namespaces, Types::Hash.map(Types::Symbol, Namespace).default({}.freeze)
21
+ attribute :description, Types::String.optional.default(nil)
22
+
23
+ # Get the full path of this namespace
24
+ def full_path(parent_path = [])
25
+ return [name] if parent_path.empty?
26
+
27
+ parent_path + [name]
28
+ end
29
+
30
+ # Find a metric by name within this namespace
31
+ def find_metric(metric_name)
32
+ metric_symbol = metric_name.to_sym
33
+ metrics[metric_symbol]
34
+ end
35
+
36
+ # Find a tag definition by name within this namespace
37
+ def find_tag(tag_name)
38
+ tag_symbol = tag_name.to_sym
39
+ tags[tag_symbol]
40
+ end
41
+
42
+ # Find a nested namespace by name
43
+ def find_namespace(namespace_name)
44
+ namespace_symbol = namespace_name.to_sym
45
+ namespaces[namespace_symbol]
46
+ end
47
+
48
+ # Add a new metric to this namespace
49
+ def add_metric(metric_definition)
50
+ new(metrics: metrics.merge(metric_definition.name => metric_definition))
51
+ end
52
+
53
+ # Add a new tag definition to this namespace
54
+ def add_tag(tag_definition)
55
+ new(tags: tags.merge(tag_definition.name => tag_definition))
56
+ end
57
+
58
+ # Add a nested namespace
59
+ def add_namespace(namespace)
60
+ new(namespaces: namespaces.merge(namespace.name => namespace))
61
+ end
62
+
63
+ # Get all metric names in this namespace (not including nested)
64
+ def metric_names
65
+ metrics.keys
66
+ end
67
+
68
+ # Get all tag names in this namespace
69
+ def tag_names
70
+ tags.keys
71
+ end
72
+
73
+ # Get all nested namespace names
74
+ def namespace_names
75
+ namespaces.keys
76
+ end
77
+
78
+ # Check if this namespace contains a metric
79
+ def has_metric?(metric_name)
80
+ metric_symbol = metric_name.to_sym
81
+ metrics.key?(metric_symbol)
82
+ end
83
+
84
+ # Check if this namespace contains a tag definition
85
+ def has_tag?(tag_name)
86
+ tag_symbol = tag_name.to_sym
87
+ tags.key?(tag_symbol)
88
+ end
89
+
90
+ # Check if this namespace contains a nested namespace
91
+ def has_namespace?(namespace_name)
92
+ namespace_symbol = namespace_name.to_sym
93
+ namespaces.key?(namespace_symbol)
94
+ end
95
+
96
+ # Get all metrics recursively including from nested namespaces
97
+ def all_metrics(path = [])
98
+ # Filter out :root from the path to avoid it appearing in metric names
99
+ current_path = path + [name]
100
+ filtered_path = current_path.reject { |part| part == :root }
101
+ result = {}
102
+
103
+ # Add metrics from this namespace
104
+ metrics.each do |_metric_name, metric_def|
105
+ full_metric_name = metric_def.full_name(filtered_path)
106
+ result[full_metric_name] = {
107
+ definition: metric_def,
108
+ namespace_path: filtered_path,
109
+ namespace: self
110
+ }
111
+ end
112
+
113
+ # Add metrics from nested namespaces recursively
114
+ namespaces.each do |_, nested_namespace|
115
+ result.merge!(nested_namespace.all_metrics(current_path))
116
+ end
117
+
118
+ result
119
+ end
120
+
121
+ # Get all tag definitions including inherited from parent namespaces
122
+ def effective_tags(parent_tags = {})
123
+ parent_tags.merge(tags)
124
+ end
125
+
126
+ # Validate that all tag references in metrics exist
127
+ def validate_tag_references
128
+ errors = []
129
+
130
+ metrics.each do |metric_name, metric_def|
131
+ # Check allowed tags
132
+ metric_def.allowed_tags.each do |tag_name|
133
+ errors << "Metric #{metric_name} references unknown tag: #{tag_name}" unless has_tag?(tag_name)
134
+ end
135
+
136
+ # Check required tags
137
+ metric_def.required_tags.each do |tag_name|
138
+ errors << "Metric #{metric_name} requires unknown tag: #{tag_name}" unless has_tag?(tag_name)
139
+ end
140
+ end
141
+
142
+ # Validate nested namespaces recursively
143
+ namespaces.each do |_, nested_namespace|
144
+ errors.concat(nested_namespace.validate_tag_references)
145
+ end
146
+
147
+ errors
148
+ end
149
+
150
+ # Find metric by path (e.g., "request.duration" within web namespace)
151
+ def find_metric_by_path(path)
152
+ parts = path.split(".")
153
+
154
+ if parts.length == 1
155
+ # Single part, look for metric in this namespace
156
+ find_metric(parts.first)
157
+ else
158
+ # Multiple parts, navigate to nested namespace
159
+ namespace_name = parts.first
160
+ remaining_path = parts[1..-1].join(".")
161
+
162
+ nested_namespace = find_namespace(namespace_name)
163
+ return nil unless nested_namespace
164
+
165
+ nested_namespace.find_metric_by_path(remaining_path)
166
+ end
167
+ end
168
+
169
+ # Get namespace by path (e.g., "web.request")
170
+ def find_namespace_by_path(path)
171
+ return self if path.empty?
172
+
173
+ parts = path.split(".")
174
+
175
+ if parts.length == 1
176
+ find_namespace(parts.first)
177
+ else
178
+ namespace_name = parts.first
179
+ remaining_path = parts[1..-1].join(".")
180
+
181
+ nested_namespace = find_namespace(namespace_name)
182
+ return nil unless nested_namespace
183
+
184
+ nested_namespace.find_namespace_by_path(remaining_path)
185
+ end
186
+ end
187
+
188
+ # Count total metrics including nested namespaces
189
+ def total_metrics_count
190
+ metrics.count + namespaces.values.sum(&:total_metrics_count)
191
+ end
192
+
193
+ # Count total namespaces including nested
194
+ def total_namespaces_count
195
+ namespaces.count + namespaces.values.sum(&:total_namespaces_count)
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "namespace"
4
+ require_relative "tag_definition"
5
+ require_relative "metric_definition"
6
+
7
+ module Datadog
8
+ class Statsd
9
+ module Schema
10
+ class SchemaBuilder
11
+ attr_reader :transformers, :root_namespace
12
+
13
+ def initialize
14
+ @transformers = {}
15
+ @root_namespace = Namespace.new(name: :root)
16
+ end
17
+
18
+ # Define transformers that can be used by tag definitions
19
+ def transformers(&)
20
+ return @transformers unless block_given?
21
+
22
+ TransformerBuilder.new(@transformers).instance_eval(&)
23
+ end
24
+
25
+ # Define a namespace
26
+ def namespace(name, &)
27
+ builder = NamespaceBuilder.new(name, @transformers)
28
+ builder.instance_eval(&) if block_given?
29
+ namespace_def = builder.build
30
+
31
+ @root_namespace = @root_namespace.add_namespace(namespace_def)
32
+ end
33
+
34
+ # Build the final schema (returns the root namespace)
35
+ def build
36
+ @root_namespace
37
+ end
38
+
39
+ # Validate the schema for consistency
40
+ def validate!
41
+ errors = @root_namespace.validate_tag_references
42
+ raise SchemaError, "Schema validation failed: #{errors.join(", ")}" unless errors.empty?
43
+ end
44
+
45
+ # Helper class for building transformers
46
+ class TransformerBuilder
47
+ def initialize(transformers)
48
+ @transformers = transformers
49
+ end
50
+
51
+ def method_missing(name, proc = nil, &block)
52
+ @transformers[name.to_sym] = proc || block
53
+ end
54
+
55
+ def respond_to_missing?(_name, _include_private = false)
56
+ true
57
+ end
58
+ end
59
+
60
+ # Helper class for building namespaces
61
+ class NamespaceBuilder
62
+ attr_reader :name, :transformers, :tags, :metrics, :namespaces, :description
63
+
64
+ def initialize(name, transformers = {})
65
+ @name = name.to_sym
66
+ @transformers = transformers
67
+ @tags = {}
68
+ @metrics = {}
69
+ @namespaces = {}
70
+ @description = nil
71
+ end
72
+
73
+ # Set description for this namespace
74
+ def description(desc)
75
+ @description = desc
76
+ end
77
+
78
+ # Define tags for this namespace
79
+ def tags(&)
80
+ TagsBuilder.new(@tags, @transformers).instance_eval(&)
81
+ end
82
+
83
+ # Define metrics for this namespace
84
+ def metrics(&)
85
+ MetricsBuilder.new(@metrics, @transformers).instance_eval(&)
86
+ end
87
+
88
+ # Define nested namespace
89
+ def namespace(name, &)
90
+ builder = NamespaceBuilder.new(name, @transformers)
91
+ builder.instance_eval(&) if block_given?
92
+ @namespaces[name.to_sym] = builder.build
93
+ end
94
+
95
+ def build
96
+ Namespace.new(
97
+ name: @name,
98
+ description: @description,
99
+ tags: @tags,
100
+ metrics: @metrics,
101
+ namespaces: @namespaces
102
+ )
103
+ end
104
+ end
105
+
106
+ # Helper class for building tags within a namespace
107
+ class TagsBuilder
108
+ def initialize(tags, transformers)
109
+ @tags = tags
110
+ @transformers = transformers
111
+ end
112
+
113
+ def tag(name, **options)
114
+ tag_def = TagDefinition.new(
115
+ name: name.to_sym,
116
+ values: options[:values],
117
+ type: options[:type] || :string,
118
+ transform: Array(options[:transform] || []),
119
+ validate: options[:validate],
120
+ namespace: @current_namespace
121
+ )
122
+ @tags[name.to_sym] = tag_def
123
+ end
124
+ end
125
+
126
+ # Helper class for building metrics within a namespace
127
+ class MetricsBuilder
128
+ def initialize(metrics, transformers)
129
+ @metrics = metrics
130
+ @transformers = transformers
131
+ @current_namespace = nil
132
+ end
133
+
134
+ # Define a nested namespace for metrics
135
+ def namespace(name, &)
136
+ @current_namespace = name.to_sym
137
+ instance_eval(&)
138
+ @current_namespace = nil
139
+ end
140
+
141
+ # Define individual metric types
142
+ %i[counter gauge histogram distribution timing set].each do |metric_type|
143
+ define_method(metric_type) do |name, **options, &block|
144
+ metric_name = @current_namespace ? :"#{@current_namespace}_#{name}" : name.to_sym
145
+
146
+ metric_def = MetricDefinition.new(
147
+ name: metric_name,
148
+ type: metric_type,
149
+ description: options[:description],
150
+ allowed_tags: extract_allowed_tags(options),
151
+ required_tags: extract_required_tags(options),
152
+ inherit_tags: options[:inherit_tags],
153
+ units: options[:units],
154
+ namespace: @current_namespace
155
+ )
156
+
157
+ unless block.nil?
158
+ metric_builder = MetricBuilder.new(metric_def)
159
+ metric_builder.instance_eval(&block)
160
+ metric_def = metric_builder.build
161
+ end
162
+
163
+ @metrics[metric_name] = metric_def
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ def extract_allowed_tags(options)
170
+ tags_option = options[:tags]
171
+ return [] unless tags_option
172
+
173
+ if tags_option.is_a?(Hash)
174
+ Array(tags_option[:allowed] || []).map(&:to_sym)
175
+ else
176
+ Array(tags_option).map(&:to_sym)
177
+ end
178
+ end
179
+
180
+ def extract_required_tags(options)
181
+ tags_option = options[:tags]
182
+ return [] unless tags_option
183
+
184
+ if tags_option.is_a?(Hash)
185
+ Array(tags_option[:required] || []).map(&:to_sym)
186
+ else
187
+ []
188
+ end
189
+ end
190
+ end
191
+
192
+ # Helper class for building individual metrics with block syntax
193
+ class MetricBuilder
194
+ def initialize(metric_def)
195
+ @metric_def = metric_def
196
+ end
197
+
198
+ def description(desc)
199
+ @metric_def = @metric_def.new(description: desc)
200
+ end
201
+
202
+ def tags(**options)
203
+ allowed = Array(options[:allowed] || []).map(&:to_sym)
204
+ required = Array(options[:required] || []).map(&:to_sym)
205
+
206
+ @metric_def = @metric_def.new(
207
+ allowed_tags: allowed,
208
+ required_tags: required
209
+ )
210
+ end
211
+
212
+ def units(unit_name)
213
+ @metric_def = @metric_def.new(units: unit_name)
214
+ end
215
+
216
+ def inherit_tags(metric_path)
217
+ @metric_def = @metric_def.new(inherit_tags: metric_path)
218
+ end
219
+
220
+ def build
221
+ @metric_def
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+ require "dry-types"
5
+
6
+ module Datadog
7
+ class Statsd
8
+ module Schema
9
+ class TagDefinition < Dry::Struct
10
+ # Include the types module for easier access
11
+ module Types
12
+ include Dry.Types()
13
+ end
14
+
15
+ attribute :name, Types::Strict::Symbol
16
+ attribute :values, Types::Any.optional.default(nil) # Allow any type: Array, Regexp, Proc, or single value
17
+ attribute :type, Types::Strict::Symbol.default(:string)
18
+ attribute :transform, Types::Array.of(Types::Symbol).default([].freeze)
19
+ attribute :validate, Types::Any.optional.default(nil) # Proc for custom validation
20
+ attribute :namespace, Types::Strict::Symbol.optional.default(nil)
21
+
22
+ # Check if a value is allowed for this tag
23
+ def allows_value?(value)
24
+ return true if values.nil? # No restrictions
25
+
26
+ case values
27
+ when Array
28
+ values.include?(value) || values.include?(value.to_s) || values.include?(value.to_sym)
29
+ when Regexp
30
+ values.match?(value.to_s)
31
+ when Proc
32
+ values.call(value)
33
+ else
34
+ values == value
35
+ end
36
+ end
37
+
38
+ # Apply transformations to a value
39
+ def transform_value(value, transformers = {})
40
+ return value if transform.empty?
41
+
42
+ transform.reduce(value) do |val, transformer_name|
43
+ transformer = transformers[transformer_name]
44
+ transformer ? transformer.call(val) : val
45
+ end
46
+ end
47
+
48
+ # Validate a value using custom validation if present
49
+ def valid_value?(value, transformers = {})
50
+ transformed_value = transform_value(value, transformers)
51
+
52
+ # Apply type validation
53
+ case type
54
+ when :integer
55
+ return false unless transformed_value.is_a?(Integer) || transformed_value.to_s.match?(/^\d+$/)
56
+ when :string
57
+ # strings are generally permissive
58
+ when :symbol
59
+ # symbols are generally permissive, will be converted
60
+ end
61
+
62
+ # Apply custom validation if present
63
+ return validate.call(transformed_value) if validate.is_a?(Proc)
64
+
65
+ # Apply value restrictions
66
+ allows_value?(transformed_value)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ class Statsd
5
+ module Schema
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "datadog/statsd"
4
+ require "active_support/core_ext/module/delegation"
5
+
6
+ require_relative "schema/version"
7
+ require_relative "schema/errors"
8
+ require_relative "schema/tag_definition"
9
+ require_relative "schema/metric_definition"
10
+ require_relative "schema/namespace"
11
+ require_relative "schema/schema_builder"
12
+ require_relative "emitter"
13
+
14
+ module Datadog
15
+ class << self
16
+ def emitter(...)
17
+ ::Datadog::Statsd::Emitter.new(...)
18
+ end
19
+
20
+ def schema(...)
21
+ ::Datadog::Statsd::Schema.new(...)
22
+ end
23
+ end
24
+
25
+ class Statsd
26
+ module Schema
27
+ class Error < StandardError
28
+ end
29
+
30
+ class << self
31
+ attr_accessor :in_test
32
+ end
33
+
34
+ self.in_test = false
35
+
36
+ # Create a new schema definition
37
+ def self.new(&)
38
+ builder = SchemaBuilder.new
39
+ builder.instance_eval(&) if block_given?
40
+ builder.build
41
+ end
42
+
43
+ # Load schema from a file
44
+ def self.load_file(path)
45
+ builder = SchemaBuilder.new
46
+ builder.instance_eval(File.read(path), path)
47
+ builder.build
48
+ end
49
+
50
+ # Configure the global schema
51
+ def self.configure
52
+ yield configuration
53
+ end
54
+
55
+ def self.configuration
56
+ @configuration ||= Configuration.new
57
+ end
58
+
59
+ # Configuration class for global settings
60
+ class Configuration
61
+ attr_accessor :statsd, :schema, :tags
62
+
63
+ def initialize
64
+ @statsd = nil
65
+ @schema = nil
66
+ @tags = {}
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end