e11y 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.
- checksums.yaml +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +69 -0
- data/CHANGELOG.md +26 -0
- data/CODE_OF_CONDUCT.md +64 -0
- data/LICENSE.txt +21 -0
- data/README.md +179 -0
- data/Rakefile +37 -0
- data/benchmarks/run_all.rb +33 -0
- data/config/README.md +83 -0
- data/config/loki-local-config.yaml +35 -0
- data/config/prometheus.yml +15 -0
- data/docker-compose.yml +78 -0
- data/docs/00-ICP-AND-TIMELINE.md +483 -0
- data/docs/01-SCALE-REQUIREMENTS.md +858 -0
- data/docs/ADR-001-architecture.md +2617 -0
- data/docs/ADR-002-metrics-yabeda.md +1395 -0
- data/docs/ADR-003-slo-observability.md +3337 -0
- data/docs/ADR-004-adapter-architecture.md +2385 -0
- data/docs/ADR-005-tracing-context.md +1372 -0
- data/docs/ADR-006-security-compliance.md +4143 -0
- data/docs/ADR-007-opentelemetry-integration.md +1385 -0
- data/docs/ADR-008-rails-integration.md +1911 -0
- data/docs/ADR-009-cost-optimization.md +2993 -0
- data/docs/ADR-010-developer-experience.md +2166 -0
- data/docs/ADR-011-testing-strategy.md +1836 -0
- data/docs/ADR-012-event-evolution.md +958 -0
- data/docs/ADR-013-reliability-error-handling.md +2750 -0
- data/docs/ADR-014-event-driven-slo.md +1533 -0
- data/docs/ADR-015-middleware-order.md +1061 -0
- data/docs/ADR-016-self-monitoring-slo.md +1234 -0
- data/docs/API-REFERENCE-L28.md +914 -0
- data/docs/COMPREHENSIVE-CONFIGURATION.md +2366 -0
- data/docs/IMPLEMENTATION_NOTES.md +2804 -0
- data/docs/IMPLEMENTATION_PLAN.md +1971 -0
- data/docs/IMPLEMENTATION_PLAN_ARCHITECTURE.md +586 -0
- data/docs/PLAN.md +148 -0
- data/docs/QUICK-START.md +934 -0
- data/docs/README.md +296 -0
- data/docs/design/00-memory-optimization.md +593 -0
- data/docs/guides/MIGRATION-L27-L28.md +692 -0
- data/docs/guides/PERFORMANCE-BENCHMARKS.md +434 -0
- data/docs/guides/README.md +44 -0
- data/docs/prd/01-overview-vision.md +440 -0
- data/docs/use_cases/README.md +119 -0
- data/docs/use_cases/UC-001-request-scoped-debug-buffering.md +813 -0
- data/docs/use_cases/UC-002-business-event-tracking.md +1953 -0
- data/docs/use_cases/UC-003-pattern-based-metrics.md +1627 -0
- data/docs/use_cases/UC-004-zero-config-slo-tracking.md +728 -0
- data/docs/use_cases/UC-005-sentry-integration.md +759 -0
- data/docs/use_cases/UC-006-trace-context-management.md +905 -0
- data/docs/use_cases/UC-007-pii-filtering.md +2648 -0
- data/docs/use_cases/UC-008-opentelemetry-integration.md +1153 -0
- data/docs/use_cases/UC-009-multi-service-tracing.md +1043 -0
- data/docs/use_cases/UC-010-background-job-tracking.md +1018 -0
- data/docs/use_cases/UC-011-rate-limiting.md +1906 -0
- data/docs/use_cases/UC-012-audit-trail.md +2301 -0
- data/docs/use_cases/UC-013-high-cardinality-protection.md +2127 -0
- data/docs/use_cases/UC-014-adaptive-sampling.md +1940 -0
- data/docs/use_cases/UC-015-cost-optimization.md +735 -0
- data/docs/use_cases/UC-016-rails-logger-migration.md +785 -0
- data/docs/use_cases/UC-017-local-development.md +867 -0
- data/docs/use_cases/UC-018-testing-events.md +1081 -0
- data/docs/use_cases/UC-019-tiered-storage-migration.md +562 -0
- data/docs/use_cases/UC-020-event-versioning.md +708 -0
- data/docs/use_cases/UC-021-error-handling-retry-dlq.md +956 -0
- data/docs/use_cases/UC-022-event-registry.md +648 -0
- data/docs/use_cases/backlog.md +226 -0
- data/e11y.gemspec +76 -0
- data/lib/e11y/adapters/adaptive_batcher.rb +207 -0
- data/lib/e11y/adapters/audit_encrypted.rb +239 -0
- data/lib/e11y/adapters/base.rb +580 -0
- data/lib/e11y/adapters/file.rb +224 -0
- data/lib/e11y/adapters/in_memory.rb +216 -0
- data/lib/e11y/adapters/loki.rb +333 -0
- data/lib/e11y/adapters/otel_logs.rb +203 -0
- data/lib/e11y/adapters/registry.rb +141 -0
- data/lib/e11y/adapters/sentry.rb +230 -0
- data/lib/e11y/adapters/stdout.rb +108 -0
- data/lib/e11y/adapters/yabeda.rb +370 -0
- data/lib/e11y/buffers/adaptive_buffer.rb +339 -0
- data/lib/e11y/buffers/base_buffer.rb +40 -0
- data/lib/e11y/buffers/request_scoped_buffer.rb +246 -0
- data/lib/e11y/buffers/ring_buffer.rb +267 -0
- data/lib/e11y/buffers.rb +14 -0
- data/lib/e11y/console.rb +122 -0
- data/lib/e11y/current.rb +48 -0
- data/lib/e11y/event/base.rb +894 -0
- data/lib/e11y/event/value_sampling_config.rb +84 -0
- data/lib/e11y/events/base_audit_event.rb +43 -0
- data/lib/e11y/events/base_payment_event.rb +33 -0
- data/lib/e11y/events/rails/cache/delete.rb +21 -0
- data/lib/e11y/events/rails/cache/read.rb +23 -0
- data/lib/e11y/events/rails/cache/write.rb +22 -0
- data/lib/e11y/events/rails/database/query.rb +45 -0
- data/lib/e11y/events/rails/http/redirect.rb +21 -0
- data/lib/e11y/events/rails/http/request.rb +26 -0
- data/lib/e11y/events/rails/http/send_file.rb +21 -0
- data/lib/e11y/events/rails/http/start_processing.rb +26 -0
- data/lib/e11y/events/rails/job/completed.rb +22 -0
- data/lib/e11y/events/rails/job/enqueued.rb +22 -0
- data/lib/e11y/events/rails/job/failed.rb +22 -0
- data/lib/e11y/events/rails/job/scheduled.rb +23 -0
- data/lib/e11y/events/rails/job/started.rb +22 -0
- data/lib/e11y/events/rails/log.rb +56 -0
- data/lib/e11y/events/rails/view/render.rb +23 -0
- data/lib/e11y/events.rb +18 -0
- data/lib/e11y/instruments/active_job.rb +201 -0
- data/lib/e11y/instruments/rails_instrumentation.rb +141 -0
- data/lib/e11y/instruments/sidekiq.rb +175 -0
- data/lib/e11y/logger/bridge.rb +205 -0
- data/lib/e11y/metrics/cardinality_protection.rb +172 -0
- data/lib/e11y/metrics/cardinality_tracker.rb +134 -0
- data/lib/e11y/metrics/registry.rb +234 -0
- data/lib/e11y/metrics/relabeling.rb +226 -0
- data/lib/e11y/metrics.rb +102 -0
- data/lib/e11y/middleware/audit_signing.rb +174 -0
- data/lib/e11y/middleware/base.rb +140 -0
- data/lib/e11y/middleware/event_slo.rb +167 -0
- data/lib/e11y/middleware/pii_filter.rb +266 -0
- data/lib/e11y/middleware/pii_filtering.rb +280 -0
- data/lib/e11y/middleware/rate_limiting.rb +214 -0
- data/lib/e11y/middleware/request.rb +163 -0
- data/lib/e11y/middleware/routing.rb +157 -0
- data/lib/e11y/middleware/sampling.rb +254 -0
- data/lib/e11y/middleware/slo.rb +168 -0
- data/lib/e11y/middleware/trace_context.rb +131 -0
- data/lib/e11y/middleware/validation.rb +118 -0
- data/lib/e11y/middleware/versioning.rb +132 -0
- data/lib/e11y/middleware.rb +12 -0
- data/lib/e11y/pii/patterns.rb +90 -0
- data/lib/e11y/pii.rb +13 -0
- data/lib/e11y/pipeline/builder.rb +155 -0
- data/lib/e11y/pipeline/zone_validator.rb +110 -0
- data/lib/e11y/pipeline.rb +12 -0
- data/lib/e11y/presets/audit_event.rb +65 -0
- data/lib/e11y/presets/debug_event.rb +34 -0
- data/lib/e11y/presets/high_value_event.rb +51 -0
- data/lib/e11y/presets.rb +19 -0
- data/lib/e11y/railtie.rb +138 -0
- data/lib/e11y/reliability/circuit_breaker.rb +216 -0
- data/lib/e11y/reliability/dlq/file_storage.rb +277 -0
- data/lib/e11y/reliability/dlq/filter.rb +117 -0
- data/lib/e11y/reliability/retry_handler.rb +207 -0
- data/lib/e11y/reliability/retry_rate_limiter.rb +117 -0
- data/lib/e11y/sampling/error_spike_detector.rb +225 -0
- data/lib/e11y/sampling/load_monitor.rb +161 -0
- data/lib/e11y/sampling/stratified_tracker.rb +92 -0
- data/lib/e11y/sampling/value_extractor.rb +82 -0
- data/lib/e11y/self_monitoring/buffer_monitor.rb +79 -0
- data/lib/e11y/self_monitoring/performance_monitor.rb +97 -0
- data/lib/e11y/self_monitoring/reliability_monitor.rb +146 -0
- data/lib/e11y/slo/event_driven.rb +150 -0
- data/lib/e11y/slo/tracker.rb +119 -0
- data/lib/e11y/version.rb +9 -0
- data/lib/e11y.rb +283 -0
- metadata +452 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
|
|
5
|
+
module E11y
|
|
6
|
+
module Metrics
|
|
7
|
+
# Registry for metric configurations.
|
|
8
|
+
#
|
|
9
|
+
# Stores metric definitions and provides pattern-based matching.
|
|
10
|
+
# This is a singleton class - use Registry.instance to access it.
|
|
11
|
+
# All metrics (global, event-level, preset) are registered here for validation.
|
|
12
|
+
#
|
|
13
|
+
# @example Register metrics
|
|
14
|
+
# registry = E11y::Metrics::Registry.instance
|
|
15
|
+
# registry.register(
|
|
16
|
+
# type: :counter,
|
|
17
|
+
# pattern: 'order.*',
|
|
18
|
+
# name: :orders_total,
|
|
19
|
+
# tags: [:status, :currency],
|
|
20
|
+
# source: 'config/initializers/e11y.rb'
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# @example Find matching metrics
|
|
24
|
+
# metrics = registry.find_matching('order.paid')
|
|
25
|
+
# # => [{ type: :counter, name: :orders_total, ... }]
|
|
26
|
+
class Registry
|
|
27
|
+
include Singleton
|
|
28
|
+
|
|
29
|
+
# Custom error for label conflicts
|
|
30
|
+
class LabelConflictError < StandardError; end
|
|
31
|
+
|
|
32
|
+
# Custom error for type conflicts
|
|
33
|
+
class TypeConflictError < StandardError; end
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@metrics = []
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Register a new metric configuration
|
|
41
|
+
#
|
|
42
|
+
# Validates for conflicts with existing metrics:
|
|
43
|
+
# - Same metric name must have same type
|
|
44
|
+
# - Same metric name must have same labels (tags)
|
|
45
|
+
# - Same metric name must have same buckets (for histograms)
|
|
46
|
+
#
|
|
47
|
+
# @param config [Hash] Metric configuration
|
|
48
|
+
# @option config [Symbol] :type Metric type (:counter, :histogram, :gauge)
|
|
49
|
+
# @option config [String] :pattern Glob pattern for event names
|
|
50
|
+
# @option config [Symbol] :name Metric name
|
|
51
|
+
# @option config [Array<Symbol>] :tags Label names to extract
|
|
52
|
+
# @option config [Proc, Symbol] :value Value extractor (for histogram/gauge)
|
|
53
|
+
# @option config [Array<Numeric>] :buckets Histogram buckets
|
|
54
|
+
# @option config [String] :source Source of the metric (for error messages)
|
|
55
|
+
# @return [void]
|
|
56
|
+
#
|
|
57
|
+
# @raise [LabelConflictError] if metric already registered with different labels
|
|
58
|
+
# @raise [TypeConflictError] if metric already registered with different type
|
|
59
|
+
def register(config)
|
|
60
|
+
validate_config!(config)
|
|
61
|
+
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
# Check for conflicts with existing metrics (find within lock)
|
|
64
|
+
existing = @metrics.find { |m| m[:name] == config[:name] }
|
|
65
|
+
validate_no_conflicts!(existing, config) if existing
|
|
66
|
+
|
|
67
|
+
@metrics << config.merge(
|
|
68
|
+
pattern_regex: compile_pattern(config[:pattern])
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Find all metrics matching the event name
|
|
74
|
+
# @param event_name [String] Event name to match
|
|
75
|
+
# @return [Array<Hash>] Matching metric configurations
|
|
76
|
+
def find_matching(event_name)
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
@metrics.select do |metric|
|
|
79
|
+
metric[:pattern_regex].match?(event_name)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Find metric by name (for conflict detection)
|
|
85
|
+
# @param name [Symbol] Metric name
|
|
86
|
+
# @return [Hash, nil] Metric configuration or nil
|
|
87
|
+
def find_by_name(name)
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
@metrics.find { |m| m[:name] == name }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Get all registered metrics
|
|
94
|
+
# @return [Array<Hash>] All metric configurations
|
|
95
|
+
def all
|
|
96
|
+
@mutex.synchronize { @metrics.dup }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Clear all registered metrics
|
|
100
|
+
# @return [void]
|
|
101
|
+
def clear!
|
|
102
|
+
@mutex.synchronize { @metrics.clear }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Get count of registered metrics
|
|
106
|
+
# @return [Integer] Number of registered metrics
|
|
107
|
+
def size
|
|
108
|
+
@mutex.synchronize { @metrics.size }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Validate all registered metrics for conflicts
|
|
112
|
+
#
|
|
113
|
+
# This method is called at Rails boot time to catch configuration errors early.
|
|
114
|
+
# It re-validates all metrics to ensure no conflicts exist.
|
|
115
|
+
#
|
|
116
|
+
# @raise [LabelConflictError] if metrics have conflicting labels
|
|
117
|
+
# @raise [TypeConflictError] if metrics have conflicting types
|
|
118
|
+
# @return [void]
|
|
119
|
+
#
|
|
120
|
+
# @example Manual validation
|
|
121
|
+
# E11y::Metrics::Registry.instance.validate_all!
|
|
122
|
+
def validate_all!
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
# Group metrics by name
|
|
125
|
+
metrics_by_name = @metrics.group_by { |m| m[:name] }
|
|
126
|
+
|
|
127
|
+
# Check each group for conflicts
|
|
128
|
+
metrics_by_name.each_value do |metrics|
|
|
129
|
+
next if metrics.size == 1 # No conflicts possible
|
|
130
|
+
|
|
131
|
+
# Compare first metric with all others
|
|
132
|
+
first = metrics.first
|
|
133
|
+
metrics[1..].each do |metric|
|
|
134
|
+
validate_no_conflicts!(first, metric)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Validate metric configuration
|
|
143
|
+
# @param config [Hash] Metric configuration
|
|
144
|
+
# @raise [ArgumentError] if configuration is invalid
|
|
145
|
+
# rubocop:disable Metrics/AbcSize
|
|
146
|
+
def validate_config!(config)
|
|
147
|
+
raise ArgumentError, "Metric type is required" unless config[:type]
|
|
148
|
+
raise ArgumentError, "Invalid metric type: #{config[:type]}" unless %i[counter histogram
|
|
149
|
+
gauge].include?(config[:type])
|
|
150
|
+
raise ArgumentError, "Pattern is required" unless config[:pattern]
|
|
151
|
+
raise ArgumentError, "Metric name is required" unless config[:name]
|
|
152
|
+
|
|
153
|
+
return unless %i[histogram gauge].include?(config[:type]) && !config[:value]
|
|
154
|
+
|
|
155
|
+
raise ArgumentError, "Value extractor is required for #{config[:type]} metrics"
|
|
156
|
+
end
|
|
157
|
+
# rubocop:enable Metrics/AbcSize
|
|
158
|
+
|
|
159
|
+
# Validate that new metric doesn't conflict with existing one
|
|
160
|
+
# @param existing [Hash] Existing metric configuration
|
|
161
|
+
# @param new_config [Hash] New metric configuration
|
|
162
|
+
# @raise [TypeConflictError] if types don't match
|
|
163
|
+
# @raise [LabelConflictError] if labels don't match
|
|
164
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
165
|
+
def validate_no_conflicts!(existing, new_config)
|
|
166
|
+
# Check 1: Type must match
|
|
167
|
+
if existing[:type] != new_config[:type]
|
|
168
|
+
raise TypeConflictError, <<~ERROR
|
|
169
|
+
Metric "#{new_config[:name]}" type conflict!
|
|
170
|
+
|
|
171
|
+
Existing: #{existing[:type]} (from #{existing[:source] || 'unknown'})
|
|
172
|
+
New: #{new_config[:type]} (from #{new_config[:source] || 'unknown'})
|
|
173
|
+
|
|
174
|
+
Fix: Use the same type everywhere or rename the metric.
|
|
175
|
+
ERROR
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Check 2: Labels (tags) must match
|
|
179
|
+
existing_tags = (existing[:tags] || []).sort
|
|
180
|
+
new_tags = (new_config[:tags] || []).sort
|
|
181
|
+
|
|
182
|
+
if existing_tags != new_tags
|
|
183
|
+
raise LabelConflictError, <<~ERROR
|
|
184
|
+
Metric "#{new_config[:name]}" label conflict!
|
|
185
|
+
|
|
186
|
+
Existing: #{existing_tags.inspect} (from #{existing[:source] || 'unknown'})
|
|
187
|
+
New: #{new_tags.inspect} (from #{new_config[:source] || 'unknown'})
|
|
188
|
+
|
|
189
|
+
Fix: Use the same labels everywhere or rename the metric.
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
# Event 1
|
|
193
|
+
counter :#{new_config[:name]}, tags: #{existing_tags.inspect}
|
|
194
|
+
#{' '}
|
|
195
|
+
# Event 2 (must match!)
|
|
196
|
+
counter :#{new_config[:name]}, tags: #{existing_tags.inspect}
|
|
197
|
+
ERROR
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Check 3: For histograms, buckets should match (warn only)
|
|
201
|
+
return unless new_config[:type] == :histogram
|
|
202
|
+
|
|
203
|
+
existing_buckets = existing[:buckets]
|
|
204
|
+
new_buckets = new_config[:buckets]
|
|
205
|
+
|
|
206
|
+
return if existing_buckets == new_buckets
|
|
207
|
+
|
|
208
|
+
warn <<~WARNING
|
|
209
|
+
Metric "#{new_config[:name]}" has different buckets!
|
|
210
|
+
Existing: #{existing_buckets.inspect}
|
|
211
|
+
New: #{new_buckets.inspect}
|
|
212
|
+
Using existing buckets.
|
|
213
|
+
WARNING
|
|
214
|
+
end
|
|
215
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
216
|
+
|
|
217
|
+
# Compile glob pattern to regex
|
|
218
|
+
# @param pattern [String] Glob pattern (e.g., "order.*", "user.*.created")
|
|
219
|
+
# @return [Regexp] Compiled regex
|
|
220
|
+
def compile_pattern(pattern)
|
|
221
|
+
# Convert glob pattern to regex
|
|
222
|
+
# - '*' matches any segment (e.g., "order.*" matches "order.paid", "order.created")
|
|
223
|
+
# - '**' matches any number of segments (e.g., "order.**" matches "order.paid.usd")
|
|
224
|
+
regex_pattern = pattern
|
|
225
|
+
.gsub(".", '\.') # Escape dots
|
|
226
|
+
.gsub("**", "__DOUBLE__") # Temporarily replace **
|
|
227
|
+
.gsub("*", "[^.]+") # * matches single segment
|
|
228
|
+
.gsub("__DOUBLE__", ".*") # ** matches multiple segments
|
|
229
|
+
|
|
230
|
+
Regexp.new("\\A#{regex_pattern}\\z")
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module E11y
|
|
4
|
+
module Metrics
|
|
5
|
+
# Universal label relabeling mechanism via DSL.
|
|
6
|
+
#
|
|
7
|
+
# Transforms high-cardinality label values into low-cardinality categories
|
|
8
|
+
# to prevent metric explosions while preserving signal quality.
|
|
9
|
+
#
|
|
10
|
+
# @example HTTP status codes to classes
|
|
11
|
+
# relabeler = Relabeling.new
|
|
12
|
+
# relabeler.define(:http_status) do |value|
|
|
13
|
+
# case value.to_i
|
|
14
|
+
# when 100..199 then '1xx'
|
|
15
|
+
# when 200..299 then '2xx'
|
|
16
|
+
# when 300..399 then '3xx'
|
|
17
|
+
# when 400..499 then '4xx'
|
|
18
|
+
# when 500..599 then '5xx'
|
|
19
|
+
# else 'unknown'
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# relabeler.apply(:http_status, 200) # => '2xx'
|
|
24
|
+
# relabeler.apply(:http_status, 404) # => '4xx'
|
|
25
|
+
#
|
|
26
|
+
# @example Path normalization
|
|
27
|
+
# relabeler.define(:path) do |value|
|
|
28
|
+
# value.gsub(/\/\d+/, '/:id')
|
|
29
|
+
# .gsub(/\/[a-f0-9-]{36}/, '/:uuid')
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# relabeler.apply(:path, '/users/123/orders/456') # => '/users/:id/orders/:id'
|
|
33
|
+
#
|
|
34
|
+
# @example Region grouping
|
|
35
|
+
# relabeler.define(:region) do |value|
|
|
36
|
+
# case value.to_s
|
|
37
|
+
# when /^us-/ then 'us'
|
|
38
|
+
# when /^eu-/ then 'eu'
|
|
39
|
+
# when /^ap-/ then 'ap'
|
|
40
|
+
# else 'other'
|
|
41
|
+
# end
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# @see ADR-002 §4.6 (Relabeling Rules)
|
|
45
|
+
class Relabeling
|
|
46
|
+
# Initialize relabeler with optional rules
|
|
47
|
+
#
|
|
48
|
+
# @param rules [Hash{Symbol => Proc}] Initial relabeling rules
|
|
49
|
+
def initialize(rules = {})
|
|
50
|
+
@rules = {}
|
|
51
|
+
@mutex = Mutex.new
|
|
52
|
+
rules.each { |label_key, block| define(label_key, &block) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Define relabeling rule for a label
|
|
56
|
+
#
|
|
57
|
+
# Rule is applied via block that receives original value
|
|
58
|
+
# and returns transformed value.
|
|
59
|
+
#
|
|
60
|
+
# @param label_key [Symbol, String] Label key to relabel
|
|
61
|
+
# @yield [value] Block that transforms label value
|
|
62
|
+
# @yieldparam value [Object] Original label value
|
|
63
|
+
# @yieldreturn [String, Symbol] Transformed label value
|
|
64
|
+
# @return [void]
|
|
65
|
+
#
|
|
66
|
+
# @example Simple mapping
|
|
67
|
+
# relabeler.define(:environment) do |value|
|
|
68
|
+
# value == 'production' ? 'prod' : 'non-prod'
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# @example Range-based classification
|
|
72
|
+
# relabeler.define(:duration_ms) do |value|
|
|
73
|
+
# case value.to_i
|
|
74
|
+
# when 0..100 then 'fast'
|
|
75
|
+
# when 101..1000 then 'medium'
|
|
76
|
+
# else 'slow'
|
|
77
|
+
# end
|
|
78
|
+
# end
|
|
79
|
+
def define(label_key, &block)
|
|
80
|
+
raise ArgumentError, "Block required for relabeling rule" unless block_given?
|
|
81
|
+
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
@rules[label_key.to_sym] = block
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Apply relabeling to label value
|
|
88
|
+
#
|
|
89
|
+
# If rule exists for label_key, applies transformation.
|
|
90
|
+
# Otherwise returns original value unchanged.
|
|
91
|
+
#
|
|
92
|
+
# Thread-safe operation.
|
|
93
|
+
#
|
|
94
|
+
# @param label_key [Symbol, String] Label key
|
|
95
|
+
# @param value [Object] Original value
|
|
96
|
+
# @return [Object] Relabeled value or original if no rule defined
|
|
97
|
+
def apply(label_key, value)
|
|
98
|
+
rule = @mutex.synchronize { @rules[label_key.to_sym] }
|
|
99
|
+
return value unless rule
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
rule.call(value)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
warn "[E11y] Relabeling error for #{label_key}=#{value}: #{e.message}"
|
|
105
|
+
value # Return original on error
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Apply relabeling to hash of labels
|
|
110
|
+
#
|
|
111
|
+
# Transforms all labels that have defined rules.
|
|
112
|
+
# Labels without rules pass through unchanged.
|
|
113
|
+
#
|
|
114
|
+
# @param labels [Hash] Hash of label_key => value
|
|
115
|
+
# @return [Hash] Hash with relabeled values
|
|
116
|
+
#
|
|
117
|
+
# @example
|
|
118
|
+
# labels = { http_status: 200, path: '/users/123', env: 'production' }
|
|
119
|
+
# relabeler.apply_all(labels)
|
|
120
|
+
# # => { http_status: '2xx', path: '/users/:id', env: 'production' }
|
|
121
|
+
def apply_all(labels)
|
|
122
|
+
labels.transform_keys(&:to_sym).transform_values do |value|
|
|
123
|
+
label_key = begin
|
|
124
|
+
labels.key(value).to_sym
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
label_key ? apply(label_key, value) : value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Check if relabeling rule exists for label
|
|
133
|
+
#
|
|
134
|
+
# @param label_key [Symbol, String] Label key
|
|
135
|
+
# @return [Boolean] true if rule defined
|
|
136
|
+
def defined?(label_key)
|
|
137
|
+
@mutex.synchronize { @rules.key?(label_key.to_sym) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Remove relabeling rule
|
|
141
|
+
#
|
|
142
|
+
# @param label_key [Symbol, String] Label key
|
|
143
|
+
# @return [void]
|
|
144
|
+
def remove(label_key)
|
|
145
|
+
@mutex.synchronize { @rules.delete(label_key.to_sym) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Get all defined rule keys
|
|
149
|
+
#
|
|
150
|
+
# @return [Array<Symbol>] List of label keys with rules
|
|
151
|
+
def keys
|
|
152
|
+
@mutex.synchronize { @rules.keys }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Reset all relabeling rules
|
|
156
|
+
#
|
|
157
|
+
# @return [void]
|
|
158
|
+
def reset!
|
|
159
|
+
@mutex.synchronize { @rules.clear }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Get number of defined rules
|
|
163
|
+
#
|
|
164
|
+
# @return [Integer] Rule count
|
|
165
|
+
def size
|
|
166
|
+
@mutex.synchronize { @rules.size }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Predefined common relabeling rules
|
|
170
|
+
module CommonRules
|
|
171
|
+
# HTTP status code to status class (1xx, 2xx, 3xx, 4xx, 5xx)
|
|
172
|
+
#
|
|
173
|
+
# @param value [Integer, String] HTTP status code
|
|
174
|
+
# @return [String] Status class
|
|
175
|
+
def self.http_status_class(value)
|
|
176
|
+
code = value.to_i
|
|
177
|
+
return "unknown" if code < 100 || code >= 600
|
|
178
|
+
|
|
179
|
+
"#{code / 100}xx"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Path normalization - replace numeric IDs and UUIDs with placeholders
|
|
183
|
+
#
|
|
184
|
+
# @param value [String] URL path
|
|
185
|
+
# @return [String] Normalized path
|
|
186
|
+
def self.normalize_path(value)
|
|
187
|
+
value.to_s
|
|
188
|
+
.gsub(%r{/[a-f0-9-]{36}}, "/:uuid") # UUIDs (must be before :id to avoid partial match)
|
|
189
|
+
.gsub(%r{/[a-f0-9]{32}}, "/:hash") # MD5 hashes (must be before :id)
|
|
190
|
+
.gsub(%r{/\d+}, "/:id") # /users/123 -> /users/:id
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Region to region group (us-east-1 -> us, eu-west-2 -> eu)
|
|
194
|
+
#
|
|
195
|
+
# @param value [String] AWS-style region
|
|
196
|
+
# @return [String] Region group
|
|
197
|
+
def self.region_group(value)
|
|
198
|
+
case value.to_s
|
|
199
|
+
when /^us-/ then "us"
|
|
200
|
+
when /^eu-/ then "eu"
|
|
201
|
+
when /^ap-/ then "ap"
|
|
202
|
+
when /^sa-/ then "sa"
|
|
203
|
+
when /^ca-/ then "ca"
|
|
204
|
+
when /^af-/ then "af"
|
|
205
|
+
when /^me-/ then "me"
|
|
206
|
+
else "other"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Duration classification (ms to fast/medium/slow)
|
|
211
|
+
#
|
|
212
|
+
# @param value [Numeric] Duration in milliseconds
|
|
213
|
+
# @return [String] Classification
|
|
214
|
+
def self.duration_class(value)
|
|
215
|
+
ms = value.to_f
|
|
216
|
+
case ms
|
|
217
|
+
when 0..100 then "fast"
|
|
218
|
+
when 101..1000 then "medium"
|
|
219
|
+
when 1001..5000 then "slow"
|
|
220
|
+
else "very_slow"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
data/lib/e11y/metrics.rb
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "e11y/metrics/registry"
|
|
4
|
+
require "e11y/metrics/cardinality_protection"
|
|
5
|
+
|
|
6
|
+
module E11y
|
|
7
|
+
# Public API for tracking metrics.
|
|
8
|
+
#
|
|
9
|
+
# This is a facade that delegates to the configured metrics backend (e.g., Yabeda).
|
|
10
|
+
# If no backend is configured, metrics are silently discarded (noop).
|
|
11
|
+
#
|
|
12
|
+
# @example Track a counter
|
|
13
|
+
# E11y::Metrics.increment(:http_requests_total, { method: 'GET', status: 200 })
|
|
14
|
+
#
|
|
15
|
+
# @example Track a histogram
|
|
16
|
+
# E11y::Metrics.histogram(:http_request_duration_seconds, 0.042, { method: 'GET' })
|
|
17
|
+
#
|
|
18
|
+
# @example Track a gauge
|
|
19
|
+
# E11y::Metrics.gauge(:active_connections, 42, { server: 'web-01' })
|
|
20
|
+
#
|
|
21
|
+
# @see ADR-002 §3 (Metrics Integration)
|
|
22
|
+
# @see ADR-016 §3 (Self-Monitoring Metrics)
|
|
23
|
+
module Metrics
|
|
24
|
+
class << self
|
|
25
|
+
# Track a counter metric (monotonically increasing value).
|
|
26
|
+
#
|
|
27
|
+
# @param name [Symbol] Metric name (e.g., :http_requests_total)
|
|
28
|
+
# @param labels [Hash] Metric labels (e.g., { method: 'GET', status: 200 })
|
|
29
|
+
# @param value [Integer] Increment value (default: 1)
|
|
30
|
+
# @return [void]
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# E11y::Metrics.increment(:e11y_events_tracked, { event_type: 'order.created' })
|
|
34
|
+
def increment(name, labels = {}, value: 1)
|
|
35
|
+
backend&.increment(name, labels, value: value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Track a histogram metric (distribution of values).
|
|
39
|
+
#
|
|
40
|
+
# @param name [Symbol] Metric name (e.g., :http_request_duration_seconds)
|
|
41
|
+
# @param value [Numeric] Observed value (e.g., 0.042 for 42ms)
|
|
42
|
+
# @param labels [Hash] Metric labels (e.g., { method: 'GET' })
|
|
43
|
+
# @param buckets [Array<Numeric>, nil] Optional histogram buckets (for backend config)
|
|
44
|
+
# @return [void]
|
|
45
|
+
#
|
|
46
|
+
# @example
|
|
47
|
+
# E11y::Metrics.histogram(:e11y_track_duration_seconds, 0.0005, { event_type: 'order.created' })
|
|
48
|
+
def histogram(name, value, labels = {}, buckets: nil)
|
|
49
|
+
backend&.histogram(name, value, labels, buckets: buckets)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Track a gauge metric (current value that can go up or down).
|
|
53
|
+
#
|
|
54
|
+
# @param name [Symbol] Metric name (e.g., :active_connections)
|
|
55
|
+
# @param value [Numeric] Current value (e.g., 42)
|
|
56
|
+
# @param labels [Hash] Metric labels (e.g., { server: 'web-01' })
|
|
57
|
+
# @return [void]
|
|
58
|
+
#
|
|
59
|
+
# @example
|
|
60
|
+
# E11y::Metrics.gauge(:e11y_buffer_size, 128, { buffer_type: 'ring' })
|
|
61
|
+
def gauge(name, value, labels = {})
|
|
62
|
+
backend&.gauge(name, value, labels)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get the configured metrics backend.
|
|
66
|
+
#
|
|
67
|
+
# The backend is determined by checking for configured adapters:
|
|
68
|
+
# - If Yabeda adapter is configured, use it
|
|
69
|
+
# - Otherwise, return nil (noop)
|
|
70
|
+
#
|
|
71
|
+
# @return [Object, nil] Metrics backend or nil
|
|
72
|
+
# @api private
|
|
73
|
+
def backend
|
|
74
|
+
return @backend if defined?(@backend)
|
|
75
|
+
|
|
76
|
+
@backend = detect_backend
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reset the backend (useful for testing).
|
|
80
|
+
#
|
|
81
|
+
# @api private
|
|
82
|
+
def reset_backend!
|
|
83
|
+
remove_instance_variable(:@backend) if defined?(@backend)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Detect the metrics backend from configured adapters.
|
|
89
|
+
#
|
|
90
|
+
# @return [Object, nil] Metrics backend or nil
|
|
91
|
+
def detect_backend
|
|
92
|
+
# Check if Yabeda adapter is configured
|
|
93
|
+
# Use class name string to avoid LoadError if Yabeda gem not installed
|
|
94
|
+
yabeda_adapter = E11y.config.adapters.values.find { |adapter| adapter.class.name == "E11y::Adapters::Yabeda" }
|
|
95
|
+
return yabeda_adapter if yabeda_adapter
|
|
96
|
+
|
|
97
|
+
# No backend configured → noop
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|