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,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Check if Faraday is available
|
|
4
|
+
begin
|
|
5
|
+
require "faraday"
|
|
6
|
+
require "faraday/retry" # Retry middleware
|
|
7
|
+
rescue LoadError
|
|
8
|
+
raise LoadError, <<~ERROR
|
|
9
|
+
Faraday not available!
|
|
10
|
+
|
|
11
|
+
To use E11y::Adapters::Loki, add to your Gemfile:
|
|
12
|
+
|
|
13
|
+
gem 'faraday'
|
|
14
|
+
gem 'faraday-retry'
|
|
15
|
+
|
|
16
|
+
Then run: bundle install
|
|
17
|
+
ERROR
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
require "json"
|
|
21
|
+
require "zlib"
|
|
22
|
+
require_relative "../metrics/cardinality_protection"
|
|
23
|
+
|
|
24
|
+
module E11y
|
|
25
|
+
module Adapters
|
|
26
|
+
# Loki adapter for shipping logs to Grafana Loki.
|
|
27
|
+
#
|
|
28
|
+
# Features:
|
|
29
|
+
# - Automatic batching for efficiency
|
|
30
|
+
# - Optional gzip compression
|
|
31
|
+
# - Configurable labels
|
|
32
|
+
# - Optional cardinality protection for labels (C04 Resolution, disabled by default)
|
|
33
|
+
# - Multi-tenant support
|
|
34
|
+
# - Thread-safe buffer
|
|
35
|
+
#
|
|
36
|
+
# @example Basic usage
|
|
37
|
+
# adapter = E11y::Adapters::Loki.new(
|
|
38
|
+
# url: "http://loki:3100",
|
|
39
|
+
# labels: { app: "my_app", env: "production" },
|
|
40
|
+
# batch_size: 100,
|
|
41
|
+
# batch_timeout: 5
|
|
42
|
+
# )
|
|
43
|
+
#
|
|
44
|
+
# @example With Registry
|
|
45
|
+
# E11y::Adapters::Registry.register(
|
|
46
|
+
# :loki_logger,
|
|
47
|
+
# E11y::Adapters::Loki.new(url: ENV["LOKI_URL"])
|
|
48
|
+
# )
|
|
49
|
+
#
|
|
50
|
+
# @example With Cardinality Protection (C04 Resolution - Enterprise)
|
|
51
|
+
# # Enable for high-traffic environments to prevent label explosion
|
|
52
|
+
# adapter = E11y::Adapters::Loki.new(
|
|
53
|
+
# url: "http://loki:3100",
|
|
54
|
+
# labels: { app: "my_app", env: "production" },
|
|
55
|
+
# enable_cardinality_protection: true, # Disabled by default
|
|
56
|
+
# max_label_cardinality: 100 # Max unique values per label
|
|
57
|
+
# )
|
|
58
|
+
# # Note: High-cardinality labels (user_id, order_id) will be filtered
|
|
59
|
+
#
|
|
60
|
+
# @see https://grafana.com/docs/loki/latest/api/#push-log-entries-to-loki
|
|
61
|
+
# @see ADR-009 §8 (C04 Resolution - Universal Cardinality Protection)
|
|
62
|
+
class Loki < Base
|
|
63
|
+
# Default batch size (events)
|
|
64
|
+
DEFAULT_BATCH_SIZE = 100
|
|
65
|
+
|
|
66
|
+
# Default batch timeout (seconds)
|
|
67
|
+
DEFAULT_BATCH_TIMEOUT = 5
|
|
68
|
+
|
|
69
|
+
# Loki push endpoint
|
|
70
|
+
PUSH_PATH = "/loki/api/v1/push"
|
|
71
|
+
|
|
72
|
+
attr_reader :url, :labels, :batch_size, :batch_timeout, :compress, :tenant_id
|
|
73
|
+
|
|
74
|
+
# Initialize Loki adapter
|
|
75
|
+
#
|
|
76
|
+
# @param config [Hash] Configuration options
|
|
77
|
+
# @option config [String] :url (required) Loki server URL (e.g., "http://loki:3100")
|
|
78
|
+
# @option config [Hash] :labels ({}) Static labels to attach to all logs
|
|
79
|
+
# @option config [Integer] :batch_size (100) Number of events to batch before sending
|
|
80
|
+
# @option config [Integer] :batch_timeout (5) Max seconds to wait before flushing batch
|
|
81
|
+
# @option config [Boolean] :compress (true) Enable gzip compression
|
|
82
|
+
# @option config [String] :tenant_id (nil) Loki tenant ID (X-Scope-OrgID header)
|
|
83
|
+
# @option config [Boolean] :enable_cardinality_protection (false) Enable cardinality protection for labels (C04)
|
|
84
|
+
# @option config [Integer] :max_label_cardinality (100) Max unique values per label when protection enabled
|
|
85
|
+
def initialize(config = {})
|
|
86
|
+
@url = config[:url]
|
|
87
|
+
@labels = config.fetch(:labels, {})
|
|
88
|
+
@batch_size = config.fetch(:batch_size, DEFAULT_BATCH_SIZE)
|
|
89
|
+
@batch_timeout = config.fetch(:batch_timeout, DEFAULT_BATCH_TIMEOUT)
|
|
90
|
+
@compress = config.fetch(:compress, true)
|
|
91
|
+
@tenant_id = config[:tenant_id]
|
|
92
|
+
@enable_cardinality_protection = config.fetch(:enable_cardinality_protection, false)
|
|
93
|
+
@max_label_cardinality = config.fetch(:max_label_cardinality, 100)
|
|
94
|
+
|
|
95
|
+
@buffer = []
|
|
96
|
+
@buffer_mutex = Mutex.new
|
|
97
|
+
@connection = nil
|
|
98
|
+
@last_flush = Time.now
|
|
99
|
+
|
|
100
|
+
# C04: Optional cardinality protection (disabled by default for logs)
|
|
101
|
+
if @enable_cardinality_protection
|
|
102
|
+
@cardinality_protection = E11y::Metrics::CardinalityProtection.new(
|
|
103
|
+
max_unique_values: @max_label_cardinality
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
super
|
|
108
|
+
|
|
109
|
+
build_connection!
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Write a single event to buffer
|
|
113
|
+
#
|
|
114
|
+
# @param event_data [Hash] Event payload
|
|
115
|
+
# @return [Boolean] Success status
|
|
116
|
+
def write(event_data)
|
|
117
|
+
@buffer_mutex.synchronize do
|
|
118
|
+
@buffer << event_data
|
|
119
|
+
|
|
120
|
+
flush_if_needed!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
true
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
warn "E11y Loki adapter error: #{e.message}"
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Write a batch of events to buffer
|
|
130
|
+
#
|
|
131
|
+
# @param events [Array<Hash>] Array of event payloads
|
|
132
|
+
# @return [Boolean] Success status
|
|
133
|
+
def write_batch(events)
|
|
134
|
+
@buffer_mutex.synchronize do
|
|
135
|
+
@buffer.concat(events)
|
|
136
|
+
|
|
137
|
+
flush_if_needed!
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
true
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
warn "E11y Loki adapter batch error: #{e.message}"
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Close adapter and flush remaining events
|
|
147
|
+
def close
|
|
148
|
+
@buffer_mutex.synchronize do
|
|
149
|
+
flush_buffer! unless @buffer.empty?
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Check if adapter is healthy
|
|
154
|
+
#
|
|
155
|
+
# @return [Boolean] True if connection is established
|
|
156
|
+
def healthy?
|
|
157
|
+
@connection&.respond_to?(:get)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Adapter capabilities
|
|
161
|
+
#
|
|
162
|
+
# @return [Hash] Capability flags
|
|
163
|
+
def capabilities
|
|
164
|
+
super.merge(
|
|
165
|
+
batching: true,
|
|
166
|
+
compression: @compress,
|
|
167
|
+
async: true,
|
|
168
|
+
streaming: false
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
# Validate configuration
|
|
175
|
+
def validate_config!
|
|
176
|
+
raise ArgumentError, "Loki adapter requires :url" unless @url
|
|
177
|
+
raise ArgumentError, "batch_size must be positive" if @batch_size <= 0
|
|
178
|
+
raise ArgumentError, "batch_timeout must be positive" if @batch_timeout <= 0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Build Faraday connection with retry middleware
|
|
182
|
+
#
|
|
183
|
+
# Uses Faraday's built-in retry middleware for exponential backoff
|
|
184
|
+
# on transient network errors and 5xx responses.
|
|
185
|
+
#
|
|
186
|
+
# Note: Connection pooling is handled at HTTP client level (Net::HTTP persistent).
|
|
187
|
+
# Faraday reuses persistent connections by default. For advanced pooling,
|
|
188
|
+
# configure Faraday adapter (e.g., :net_http_persistent, :typhoeus).
|
|
189
|
+
#
|
|
190
|
+
# @see ADR-004 Section 7.1 (Retry Policy via gem-level middleware)
|
|
191
|
+
# @see ADR-004 Section 6.1 (Connection pooling via HTTP client)
|
|
192
|
+
def build_connection!
|
|
193
|
+
@connection = Faraday.new(url: @url) do |f|
|
|
194
|
+
# Retry middleware (exponential backoff: 1s, 2s, 4s)
|
|
195
|
+
f.request :retry,
|
|
196
|
+
max: 3,
|
|
197
|
+
interval: 1.0,
|
|
198
|
+
backoff_factor: 2,
|
|
199
|
+
interval_randomness: 0.2, # ±20% jitter
|
|
200
|
+
retry_statuses: [429, 500, 502, 503, 504],
|
|
201
|
+
methods: [:post],
|
|
202
|
+
exceptions: [
|
|
203
|
+
Faraday::TimeoutError,
|
|
204
|
+
Faraday::ConnectionFailed,
|
|
205
|
+
Errno::ECONNREFUSED,
|
|
206
|
+
Errno::ETIMEDOUT
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
f.request :json
|
|
210
|
+
f.response :raise_error
|
|
211
|
+
f.adapter Faraday.default_adapter
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Check if buffer should be flushed
|
|
216
|
+
def flush_if_needed!
|
|
217
|
+
should_flush = @buffer.size >= @batch_size ||
|
|
218
|
+
(Time.now - @last_flush) >= @batch_timeout
|
|
219
|
+
|
|
220
|
+
flush_buffer! if should_flush
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Flush buffer to Loki
|
|
224
|
+
def flush_buffer!
|
|
225
|
+
return if @buffer.empty?
|
|
226
|
+
|
|
227
|
+
events = @buffer.dup
|
|
228
|
+
@buffer.clear
|
|
229
|
+
@last_flush = Time.now
|
|
230
|
+
|
|
231
|
+
# Release mutex before I/O
|
|
232
|
+
@buffer_mutex.unlock
|
|
233
|
+
begin
|
|
234
|
+
send_to_loki(events)
|
|
235
|
+
ensure
|
|
236
|
+
@buffer_mutex.lock
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Send events to Loki
|
|
241
|
+
#
|
|
242
|
+
# @param events [Array<Hash>] Events to send
|
|
243
|
+
def send_to_loki(events)
|
|
244
|
+
payload = format_loki_payload(events)
|
|
245
|
+
body = JSON.generate(payload)
|
|
246
|
+
|
|
247
|
+
body = compress_body(body) if @compress
|
|
248
|
+
|
|
249
|
+
headers = build_headers
|
|
250
|
+
|
|
251
|
+
@connection.post(PUSH_PATH, body, headers)
|
|
252
|
+
rescue Faraday::Error => e
|
|
253
|
+
warn "E11y Loki adapter HTTP error: #{e.message}"
|
|
254
|
+
false
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Format events as Loki payload
|
|
258
|
+
#
|
|
259
|
+
# @param events [Array<Hash>] Events to format
|
|
260
|
+
# @return [Hash] Loki push API payload
|
|
261
|
+
def format_loki_payload(events)
|
|
262
|
+
# Group events by labels
|
|
263
|
+
streams = events.group_by { |e| extract_labels(e) }.map do |labels, group_events|
|
|
264
|
+
{
|
|
265
|
+
stream: labels,
|
|
266
|
+
values: group_events.map { |e| format_loki_entry(e) }
|
|
267
|
+
}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
{ streams: streams }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Extract labels from event
|
|
274
|
+
#
|
|
275
|
+
# @param event_data [Hash] Event data
|
|
276
|
+
# @return [Hash] Labels for Loki stream
|
|
277
|
+
def extract_labels(event_data)
|
|
278
|
+
event_labels = {
|
|
279
|
+
event_name: event_data[:event_name].to_s,
|
|
280
|
+
severity: event_data[:severity].to_s
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
# Merge static and event labels
|
|
284
|
+
all_labels = @labels.merge(event_labels)
|
|
285
|
+
|
|
286
|
+
# C04: Apply cardinality protection if enabled (enterprise use case)
|
|
287
|
+
# Disabled by default - Loki is a log system, labels are for stream filtering only
|
|
288
|
+
if @enable_cardinality_protection && @cardinality_protection
|
|
289
|
+
all_labels = @cardinality_protection.filter(all_labels, "loki.stream")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
all_labels.transform_keys(&:to_s)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Format single event as Loki entry
|
|
296
|
+
#
|
|
297
|
+
# @param event_data [Hash] Event data
|
|
298
|
+
# @return [Array] [timestamp_ns, line]
|
|
299
|
+
def format_loki_entry(event_data)
|
|
300
|
+
timestamp_ns = (event_data[:timestamp] || Time.now).to_f * 1_000_000_000
|
|
301
|
+
line = event_data.to_json
|
|
302
|
+
|
|
303
|
+
[timestamp_ns.to_i.to_s, line]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Compress body with gzip
|
|
307
|
+
#
|
|
308
|
+
# @param body [String] Body to compress
|
|
309
|
+
# @return [String] Compressed body
|
|
310
|
+
def compress_body(body)
|
|
311
|
+
io = StringIO.new
|
|
312
|
+
gz = Zlib::GzipWriter.new(io)
|
|
313
|
+
gz.write(body)
|
|
314
|
+
gz.close
|
|
315
|
+
io.string
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Build HTTP headers
|
|
319
|
+
#
|
|
320
|
+
# @return [Hash] Headers for Loki request
|
|
321
|
+
def build_headers
|
|
322
|
+
headers = {
|
|
323
|
+
"Content-Type" => "application/json"
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
headers["Content-Encoding"] = "gzip" if @compress
|
|
327
|
+
headers["X-Scope-OrgID"] = @tenant_id if @tenant_id
|
|
328
|
+
|
|
329
|
+
headers
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Check if OpenTelemetry SDK is available
|
|
4
|
+
begin
|
|
5
|
+
require "opentelemetry/sdk"
|
|
6
|
+
require "opentelemetry/logs"
|
|
7
|
+
rescue LoadError
|
|
8
|
+
raise LoadError, <<~ERROR
|
|
9
|
+
OpenTelemetry SDK not available!
|
|
10
|
+
|
|
11
|
+
To use E11y::Adapters::OTelLogs, add to your Gemfile:
|
|
12
|
+
|
|
13
|
+
gem 'opentelemetry-sdk'
|
|
14
|
+
gem 'opentelemetry-logs'
|
|
15
|
+
|
|
16
|
+
Then run: bundle install
|
|
17
|
+
ERROR
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module E11y
|
|
21
|
+
module Adapters
|
|
22
|
+
# OpenTelemetry Logs Adapter (ADR-007, UC-008)
|
|
23
|
+
#
|
|
24
|
+
# Sends E11y events to OpenTelemetry Logs API.
|
|
25
|
+
# Events are converted to OTel log records with proper severity mapping.
|
|
26
|
+
#
|
|
27
|
+
# **Features:**
|
|
28
|
+
# - Severity mapping (E11y → OTel)
|
|
29
|
+
# - Attributes mapping (E11y payload → OTel attributes)
|
|
30
|
+
# - Baggage PII protection (C08 Resolution)
|
|
31
|
+
# - Cardinality protection for attributes (C04 Resolution)
|
|
32
|
+
# - Optional dependency (requires opentelemetry-sdk gem)
|
|
33
|
+
#
|
|
34
|
+
# **ADR References:**
|
|
35
|
+
# - ADR-007 §4 (OpenTelemetry Integration)
|
|
36
|
+
# - ADR-006 §5 (Baggage PII Protection - C08 Resolution)
|
|
37
|
+
# - ADR-009 §8 (Cardinality Protection - C04 Resolution)
|
|
38
|
+
#
|
|
39
|
+
# **Use Case:** UC-008 (OpenTelemetry Integration)
|
|
40
|
+
#
|
|
41
|
+
# @example Configuration
|
|
42
|
+
# # Gemfile
|
|
43
|
+
# gem 'opentelemetry-sdk'
|
|
44
|
+
# gem 'opentelemetry-logs'
|
|
45
|
+
#
|
|
46
|
+
# # config/initializers/e11y.rb
|
|
47
|
+
# E11y.configure do |config|
|
|
48
|
+
# config.adapters[:otel_logs] = E11y::Adapters::OTelLogs.new(
|
|
49
|
+
# service_name: 'my-app',
|
|
50
|
+
# baggage_allowlist: [:trace_id, :span_id, :user_id]
|
|
51
|
+
# )
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
# @example Baggage PII Protection (C08)
|
|
55
|
+
# # Only allowlisted keys are sent to baggage
|
|
56
|
+
# # PII keys (email, phone, etc.) are automatically dropped
|
|
57
|
+
#
|
|
58
|
+
# @see ADR-007 for OpenTelemetry integration architecture
|
|
59
|
+
# @see UC-008 for use cases
|
|
60
|
+
class OTelLogs < Base
|
|
61
|
+
# E11y severity → OTel severity mapping
|
|
62
|
+
SEVERITY_MAPPING = {
|
|
63
|
+
debug: OpenTelemetry::SDK::Logs::Severity::DEBUG,
|
|
64
|
+
info: OpenTelemetry::SDK::Logs::Severity::INFO,
|
|
65
|
+
success: OpenTelemetry::SDK::Logs::Severity::INFO, # OTel has no "success"
|
|
66
|
+
warn: OpenTelemetry::SDK::Logs::Severity::WARN,
|
|
67
|
+
error: OpenTelemetry::SDK::Logs::Severity::ERROR,
|
|
68
|
+
fatal: OpenTelemetry::SDK::Logs::Severity::FATAL
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
# Default baggage allowlist (safe keys that don't contain PII)
|
|
72
|
+
DEFAULT_BAGGAGE_ALLOWLIST = %i[
|
|
73
|
+
trace_id
|
|
74
|
+
span_id
|
|
75
|
+
request_id
|
|
76
|
+
environment
|
|
77
|
+
service_name
|
|
78
|
+
].freeze
|
|
79
|
+
|
|
80
|
+
# Initialize OTel Logs adapter
|
|
81
|
+
#
|
|
82
|
+
# @param service_name [String] Service name for OTel (default: from config)
|
|
83
|
+
# @param baggage_allowlist [Array<Symbol>] Allowlist of safe baggage keys
|
|
84
|
+
# @param max_attributes [Integer] Max attributes per log (cardinality protection)
|
|
85
|
+
def initialize(service_name: nil, baggage_allowlist: DEFAULT_BAGGAGE_ALLOWLIST, max_attributes: 50, **)
|
|
86
|
+
super(**)
|
|
87
|
+
@service_name = service_name
|
|
88
|
+
@baggage_allowlist = baggage_allowlist
|
|
89
|
+
@max_attributes = max_attributes
|
|
90
|
+
|
|
91
|
+
setup_logger_provider
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Write event to OTel Logs API
|
|
95
|
+
#
|
|
96
|
+
# @param event_data [Hash] Event payload
|
|
97
|
+
# @return [Boolean] true on success
|
|
98
|
+
def write(event_data)
|
|
99
|
+
log_record = build_log_record(event_data)
|
|
100
|
+
@logger.emit_log_record(log_record)
|
|
101
|
+
true
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
warn "[E11y::OTelLogs] Failed to write event: #{e.message}"
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check if adapter is healthy
|
|
108
|
+
#
|
|
109
|
+
# @return [Boolean] true if OTel SDK available and configured
|
|
110
|
+
def healthy?
|
|
111
|
+
@logger_provider && @logger
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Adapter capabilities
|
|
115
|
+
#
|
|
116
|
+
# @return [Hash] Capabilities hash
|
|
117
|
+
def capabilities
|
|
118
|
+
{
|
|
119
|
+
batching: false, # OTel SDK handles batching internally
|
|
120
|
+
compression: false,
|
|
121
|
+
async: true, # OTel SDK is async by default
|
|
122
|
+
streaming: false
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Setup OTel Logger Provider
|
|
129
|
+
def setup_logger_provider
|
|
130
|
+
@logger_provider = OpenTelemetry::SDK::Logs::LoggerProvider.new
|
|
131
|
+
@logger = @logger_provider.logger(
|
|
132
|
+
name: "e11y",
|
|
133
|
+
version: E11y::VERSION
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Build OTel log record from E11y event
|
|
138
|
+
#
|
|
139
|
+
# @param event_data [Hash] E11y event payload
|
|
140
|
+
# @return [OpenTelemetry::SDK::Logs::LogRecord] OTel log record
|
|
141
|
+
def build_log_record(event_data)
|
|
142
|
+
OpenTelemetry::SDK::Logs::LogRecord.new(
|
|
143
|
+
timestamp: event_data[:timestamp] || Time.now.utc,
|
|
144
|
+
observed_timestamp: Time.now.utc,
|
|
145
|
+
severity_number: map_severity(event_data[:severity]),
|
|
146
|
+
severity_text: event_data[:severity].to_s.upcase,
|
|
147
|
+
body: event_data[:event_name],
|
|
148
|
+
attributes: build_attributes(event_data),
|
|
149
|
+
trace_id: event_data[:trace_id],
|
|
150
|
+
span_id: event_data[:span_id],
|
|
151
|
+
trace_flags: nil
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Map E11y severity to OTel severity
|
|
156
|
+
#
|
|
157
|
+
# @param severity [Symbol] E11y severity (:debug, :info, etc.)
|
|
158
|
+
# @return [Integer] OTel severity number
|
|
159
|
+
def map_severity(severity)
|
|
160
|
+
SEVERITY_MAPPING[severity] || OpenTelemetry::SDK::Logs::Severity::INFO
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Build OTel attributes from E11y payload
|
|
164
|
+
#
|
|
165
|
+
# Applies:
|
|
166
|
+
# - Cardinality protection (C04 Resolution)
|
|
167
|
+
# - Baggage PII filtering (C08 Resolution)
|
|
168
|
+
#
|
|
169
|
+
# @param event_data [Hash] E11y event payload
|
|
170
|
+
# @return [Hash] OTel attributes
|
|
171
|
+
def build_attributes(event_data)
|
|
172
|
+
attributes = {}
|
|
173
|
+
|
|
174
|
+
# Add event metadata
|
|
175
|
+
attributes["event.name"] = event_data[:event_name]
|
|
176
|
+
attributes["event.version"] = event_data[:v] if event_data[:v]
|
|
177
|
+
attributes["service.name"] = @service_name if @service_name
|
|
178
|
+
|
|
179
|
+
# Add payload (with cardinality protection)
|
|
180
|
+
payload = event_data[:payload] || {}
|
|
181
|
+
payload.each do |key, value|
|
|
182
|
+
# C04: Cardinality protection - limit attributes
|
|
183
|
+
break if attributes.size >= @max_attributes
|
|
184
|
+
|
|
185
|
+
# C08: Baggage PII protection - only allowlisted keys
|
|
186
|
+
next unless baggage_allowed?(key)
|
|
187
|
+
|
|
188
|
+
attributes["event.#{key}"] = value
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
attributes
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Check if key is allowed in baggage (C08 Resolution)
|
|
195
|
+
#
|
|
196
|
+
# @param key [Symbol, String] Attribute key
|
|
197
|
+
# @return [Boolean] true if key is in allowlist
|
|
198
|
+
def baggage_allowed?(key)
|
|
199
|
+
@baggage_allowlist.include?(key.to_sym)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module E11y
|
|
4
|
+
module Adapters
|
|
5
|
+
# Adapter Registry - Global registry for adapter instances
|
|
6
|
+
#
|
|
7
|
+
# Provides thread-safe registration and resolution of adapters.
|
|
8
|
+
# Adapters are registered once during configuration and reused
|
|
9
|
+
# throughout application lifetime.
|
|
10
|
+
#
|
|
11
|
+
# **Features:**
|
|
12
|
+
# - Thread-safe registration
|
|
13
|
+
# - Adapter validation
|
|
14
|
+
# - Resolution by name
|
|
15
|
+
# - Cleanup on exit
|
|
16
|
+
#
|
|
17
|
+
# @example Registration
|
|
18
|
+
# E11y::Adapters::Registry.register :stdout, E11y::Adapters::Stdout.new
|
|
19
|
+
# E11y::Adapters::Registry.register :loki, E11y::Adapters::Loki.new(url: "...")
|
|
20
|
+
#
|
|
21
|
+
# @example Resolution
|
|
22
|
+
# adapter = E11y::Adapters::Registry.resolve(:stdout)
|
|
23
|
+
# adapter.write(event_data)
|
|
24
|
+
#
|
|
25
|
+
# @see ADR-004 §5 (Adapter Registry)
|
|
26
|
+
class Registry
|
|
27
|
+
# Registry error
|
|
28
|
+
class Error < E11y::Error; end
|
|
29
|
+
|
|
30
|
+
# Adapter not found error
|
|
31
|
+
class AdapterNotFoundError < Error; end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Register adapter instance
|
|
35
|
+
#
|
|
36
|
+
# @param name [Symbol] Adapter name (e.g., :stdout, :loki)
|
|
37
|
+
# @param adapter_instance [Adapters::Base] Adapter instance
|
|
38
|
+
# @raise [ArgumentError] if adapter does not respond to required methods
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# Registry.register :stdout, E11y::Adapters::Stdout.new
|
|
42
|
+
def register(name, adapter_instance)
|
|
43
|
+
validate_adapter!(adapter_instance)
|
|
44
|
+
|
|
45
|
+
adapters[name] = adapter_instance
|
|
46
|
+
|
|
47
|
+
# Register cleanup hook
|
|
48
|
+
at_exit { adapter_instance.close }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Resolve adapter by name
|
|
52
|
+
#
|
|
53
|
+
# @param name [Symbol] Adapter name
|
|
54
|
+
# @return [Adapters::Base] Adapter instance
|
|
55
|
+
# @raise [AdapterNotFoundError] if adapter not found
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# adapter = Registry.resolve(:stdout)
|
|
59
|
+
def resolve(name)
|
|
60
|
+
adapters.fetch(name) do
|
|
61
|
+
raise AdapterNotFoundError, "Adapter not found: #{name}. Registered: #{names.join(', ')}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Resolve multiple adapters by names
|
|
66
|
+
#
|
|
67
|
+
# @param names [Array<Symbol>] Adapter names
|
|
68
|
+
# @return [Array<Adapters::Base>] Adapter instances
|
|
69
|
+
# @raise [AdapterNotFoundError] if any adapter not found
|
|
70
|
+
#
|
|
71
|
+
# @example
|
|
72
|
+
# adapters = Registry.resolve_all([:stdout, :loki])
|
|
73
|
+
def resolve_all(names)
|
|
74
|
+
names.map { |name| resolve(name) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get all registered adapters
|
|
78
|
+
#
|
|
79
|
+
# @return [Array<Adapters::Base>] All adapter instances
|
|
80
|
+
def all
|
|
81
|
+
adapters.values
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get all registered adapter names
|
|
85
|
+
#
|
|
86
|
+
# @return [Array<Symbol>] Adapter names
|
|
87
|
+
def names
|
|
88
|
+
adapters.keys
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if adapter is registered
|
|
92
|
+
#
|
|
93
|
+
# @param name [Symbol] Adapter name
|
|
94
|
+
# @return [Boolean] true if registered
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# Registry.registered?(:stdout) #=> true
|
|
98
|
+
def registered?(name)
|
|
99
|
+
adapters.key?(name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Clear all registered adapters
|
|
103
|
+
#
|
|
104
|
+
# Calls close() on all adapters and clears registry.
|
|
105
|
+
# Useful for testing.
|
|
106
|
+
#
|
|
107
|
+
# @return [void]
|
|
108
|
+
#
|
|
109
|
+
# @example
|
|
110
|
+
# Registry.clear!
|
|
111
|
+
def clear!
|
|
112
|
+
adapters.each_value(&:close)
|
|
113
|
+
adapters.clear
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Registry storage (thread-safe Hash)
|
|
119
|
+
#
|
|
120
|
+
# @return [Hash<Symbol, Adapters::Base>]
|
|
121
|
+
def adapters
|
|
122
|
+
@adapters ||= {}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Validate adapter implements required interface
|
|
126
|
+
#
|
|
127
|
+
# @param adapter [Object] Adapter instance
|
|
128
|
+
# @raise [ArgumentError] if adapter invalid
|
|
129
|
+
def validate_adapter!(adapter)
|
|
130
|
+
raise ArgumentError, "Adapter must respond to #write" unless adapter.respond_to?(:write)
|
|
131
|
+
|
|
132
|
+
raise ArgumentError, "Adapter must respond to #write_batch" unless adapter.respond_to?(:write_batch)
|
|
133
|
+
|
|
134
|
+
return if adapter.respond_to?(:healthy?)
|
|
135
|
+
|
|
136
|
+
raise ArgumentError, "Adapter must respond to #healthy?"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|