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.
Files changed (157) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +4 -0
  3. data/.rubocop.yml +69 -0
  4. data/CHANGELOG.md +26 -0
  5. data/CODE_OF_CONDUCT.md +64 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +179 -0
  8. data/Rakefile +37 -0
  9. data/benchmarks/run_all.rb +33 -0
  10. data/config/README.md +83 -0
  11. data/config/loki-local-config.yaml +35 -0
  12. data/config/prometheus.yml +15 -0
  13. data/docker-compose.yml +78 -0
  14. data/docs/00-ICP-AND-TIMELINE.md +483 -0
  15. data/docs/01-SCALE-REQUIREMENTS.md +858 -0
  16. data/docs/ADR-001-architecture.md +2617 -0
  17. data/docs/ADR-002-metrics-yabeda.md +1395 -0
  18. data/docs/ADR-003-slo-observability.md +3337 -0
  19. data/docs/ADR-004-adapter-architecture.md +2385 -0
  20. data/docs/ADR-005-tracing-context.md +1372 -0
  21. data/docs/ADR-006-security-compliance.md +4143 -0
  22. data/docs/ADR-007-opentelemetry-integration.md +1385 -0
  23. data/docs/ADR-008-rails-integration.md +1911 -0
  24. data/docs/ADR-009-cost-optimization.md +2993 -0
  25. data/docs/ADR-010-developer-experience.md +2166 -0
  26. data/docs/ADR-011-testing-strategy.md +1836 -0
  27. data/docs/ADR-012-event-evolution.md +958 -0
  28. data/docs/ADR-013-reliability-error-handling.md +2750 -0
  29. data/docs/ADR-014-event-driven-slo.md +1533 -0
  30. data/docs/ADR-015-middleware-order.md +1061 -0
  31. data/docs/ADR-016-self-monitoring-slo.md +1234 -0
  32. data/docs/API-REFERENCE-L28.md +914 -0
  33. data/docs/COMPREHENSIVE-CONFIGURATION.md +2366 -0
  34. data/docs/IMPLEMENTATION_NOTES.md +2804 -0
  35. data/docs/IMPLEMENTATION_PLAN.md +1971 -0
  36. data/docs/IMPLEMENTATION_PLAN_ARCHITECTURE.md +586 -0
  37. data/docs/PLAN.md +148 -0
  38. data/docs/QUICK-START.md +934 -0
  39. data/docs/README.md +296 -0
  40. data/docs/design/00-memory-optimization.md +593 -0
  41. data/docs/guides/MIGRATION-L27-L28.md +692 -0
  42. data/docs/guides/PERFORMANCE-BENCHMARKS.md +434 -0
  43. data/docs/guides/README.md +44 -0
  44. data/docs/prd/01-overview-vision.md +440 -0
  45. data/docs/use_cases/README.md +119 -0
  46. data/docs/use_cases/UC-001-request-scoped-debug-buffering.md +813 -0
  47. data/docs/use_cases/UC-002-business-event-tracking.md +1953 -0
  48. data/docs/use_cases/UC-003-pattern-based-metrics.md +1627 -0
  49. data/docs/use_cases/UC-004-zero-config-slo-tracking.md +728 -0
  50. data/docs/use_cases/UC-005-sentry-integration.md +759 -0
  51. data/docs/use_cases/UC-006-trace-context-management.md +905 -0
  52. data/docs/use_cases/UC-007-pii-filtering.md +2648 -0
  53. data/docs/use_cases/UC-008-opentelemetry-integration.md +1153 -0
  54. data/docs/use_cases/UC-009-multi-service-tracing.md +1043 -0
  55. data/docs/use_cases/UC-010-background-job-tracking.md +1018 -0
  56. data/docs/use_cases/UC-011-rate-limiting.md +1906 -0
  57. data/docs/use_cases/UC-012-audit-trail.md +2301 -0
  58. data/docs/use_cases/UC-013-high-cardinality-protection.md +2127 -0
  59. data/docs/use_cases/UC-014-adaptive-sampling.md +1940 -0
  60. data/docs/use_cases/UC-015-cost-optimization.md +735 -0
  61. data/docs/use_cases/UC-016-rails-logger-migration.md +785 -0
  62. data/docs/use_cases/UC-017-local-development.md +867 -0
  63. data/docs/use_cases/UC-018-testing-events.md +1081 -0
  64. data/docs/use_cases/UC-019-tiered-storage-migration.md +562 -0
  65. data/docs/use_cases/UC-020-event-versioning.md +708 -0
  66. data/docs/use_cases/UC-021-error-handling-retry-dlq.md +956 -0
  67. data/docs/use_cases/UC-022-event-registry.md +648 -0
  68. data/docs/use_cases/backlog.md +226 -0
  69. data/e11y.gemspec +76 -0
  70. data/lib/e11y/adapters/adaptive_batcher.rb +207 -0
  71. data/lib/e11y/adapters/audit_encrypted.rb +239 -0
  72. data/lib/e11y/adapters/base.rb +580 -0
  73. data/lib/e11y/adapters/file.rb +224 -0
  74. data/lib/e11y/adapters/in_memory.rb +216 -0
  75. data/lib/e11y/adapters/loki.rb +333 -0
  76. data/lib/e11y/adapters/otel_logs.rb +203 -0
  77. data/lib/e11y/adapters/registry.rb +141 -0
  78. data/lib/e11y/adapters/sentry.rb +230 -0
  79. data/lib/e11y/adapters/stdout.rb +108 -0
  80. data/lib/e11y/adapters/yabeda.rb +370 -0
  81. data/lib/e11y/buffers/adaptive_buffer.rb +339 -0
  82. data/lib/e11y/buffers/base_buffer.rb +40 -0
  83. data/lib/e11y/buffers/request_scoped_buffer.rb +246 -0
  84. data/lib/e11y/buffers/ring_buffer.rb +267 -0
  85. data/lib/e11y/buffers.rb +14 -0
  86. data/lib/e11y/console.rb +122 -0
  87. data/lib/e11y/current.rb +48 -0
  88. data/lib/e11y/event/base.rb +894 -0
  89. data/lib/e11y/event/value_sampling_config.rb +84 -0
  90. data/lib/e11y/events/base_audit_event.rb +43 -0
  91. data/lib/e11y/events/base_payment_event.rb +33 -0
  92. data/lib/e11y/events/rails/cache/delete.rb +21 -0
  93. data/lib/e11y/events/rails/cache/read.rb +23 -0
  94. data/lib/e11y/events/rails/cache/write.rb +22 -0
  95. data/lib/e11y/events/rails/database/query.rb +45 -0
  96. data/lib/e11y/events/rails/http/redirect.rb +21 -0
  97. data/lib/e11y/events/rails/http/request.rb +26 -0
  98. data/lib/e11y/events/rails/http/send_file.rb +21 -0
  99. data/lib/e11y/events/rails/http/start_processing.rb +26 -0
  100. data/lib/e11y/events/rails/job/completed.rb +22 -0
  101. data/lib/e11y/events/rails/job/enqueued.rb +22 -0
  102. data/lib/e11y/events/rails/job/failed.rb +22 -0
  103. data/lib/e11y/events/rails/job/scheduled.rb +23 -0
  104. data/lib/e11y/events/rails/job/started.rb +22 -0
  105. data/lib/e11y/events/rails/log.rb +56 -0
  106. data/lib/e11y/events/rails/view/render.rb +23 -0
  107. data/lib/e11y/events.rb +18 -0
  108. data/lib/e11y/instruments/active_job.rb +201 -0
  109. data/lib/e11y/instruments/rails_instrumentation.rb +141 -0
  110. data/lib/e11y/instruments/sidekiq.rb +175 -0
  111. data/lib/e11y/logger/bridge.rb +205 -0
  112. data/lib/e11y/metrics/cardinality_protection.rb +172 -0
  113. data/lib/e11y/metrics/cardinality_tracker.rb +134 -0
  114. data/lib/e11y/metrics/registry.rb +234 -0
  115. data/lib/e11y/metrics/relabeling.rb +226 -0
  116. data/lib/e11y/metrics.rb +102 -0
  117. data/lib/e11y/middleware/audit_signing.rb +174 -0
  118. data/lib/e11y/middleware/base.rb +140 -0
  119. data/lib/e11y/middleware/event_slo.rb +167 -0
  120. data/lib/e11y/middleware/pii_filter.rb +266 -0
  121. data/lib/e11y/middleware/pii_filtering.rb +280 -0
  122. data/lib/e11y/middleware/rate_limiting.rb +214 -0
  123. data/lib/e11y/middleware/request.rb +163 -0
  124. data/lib/e11y/middleware/routing.rb +157 -0
  125. data/lib/e11y/middleware/sampling.rb +254 -0
  126. data/lib/e11y/middleware/slo.rb +168 -0
  127. data/lib/e11y/middleware/trace_context.rb +131 -0
  128. data/lib/e11y/middleware/validation.rb +118 -0
  129. data/lib/e11y/middleware/versioning.rb +132 -0
  130. data/lib/e11y/middleware.rb +12 -0
  131. data/lib/e11y/pii/patterns.rb +90 -0
  132. data/lib/e11y/pii.rb +13 -0
  133. data/lib/e11y/pipeline/builder.rb +155 -0
  134. data/lib/e11y/pipeline/zone_validator.rb +110 -0
  135. data/lib/e11y/pipeline.rb +12 -0
  136. data/lib/e11y/presets/audit_event.rb +65 -0
  137. data/lib/e11y/presets/debug_event.rb +34 -0
  138. data/lib/e11y/presets/high_value_event.rb +51 -0
  139. data/lib/e11y/presets.rb +19 -0
  140. data/lib/e11y/railtie.rb +138 -0
  141. data/lib/e11y/reliability/circuit_breaker.rb +216 -0
  142. data/lib/e11y/reliability/dlq/file_storage.rb +277 -0
  143. data/lib/e11y/reliability/dlq/filter.rb +117 -0
  144. data/lib/e11y/reliability/retry_handler.rb +207 -0
  145. data/lib/e11y/reliability/retry_rate_limiter.rb +117 -0
  146. data/lib/e11y/sampling/error_spike_detector.rb +225 -0
  147. data/lib/e11y/sampling/load_monitor.rb +161 -0
  148. data/lib/e11y/sampling/stratified_tracker.rb +92 -0
  149. data/lib/e11y/sampling/value_extractor.rb +82 -0
  150. data/lib/e11y/self_monitoring/buffer_monitor.rb +79 -0
  151. data/lib/e11y/self_monitoring/performance_monitor.rb +97 -0
  152. data/lib/e11y/self_monitoring/reliability_monitor.rb +146 -0
  153. data/lib/e11y/slo/event_driven.rb +150 -0
  154. data/lib/e11y/slo/tracker.rb +119 -0
  155. data/lib/e11y/version.rb +9 -0
  156. data/lib/e11y.rb +283 -0
  157. 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