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,2385 @@
|
|
|
1
|
+
# ADR-004: Adapter Architecture
|
|
2
|
+
|
|
3
|
+
**Status:** ✅ Implemented (Phase L2.5 Complete)
|
|
4
|
+
**Date:** January 12, 2026 | **Updated:** January 19, 2026
|
|
5
|
+
**Covers:** UC-005 (Sentry Integration), All Adapters (Loki, File, Stdout, InMemory, AuditEncrypted)
|
|
6
|
+
**Depends On:** ADR-001 (Core Architecture), ADR-002 (Metrics)
|
|
7
|
+
|
|
8
|
+
**Implementation Status:**
|
|
9
|
+
- ✅ §3.1: Base Adapter Contract - Implemented
|
|
10
|
+
- ✅ §4.1: Stdout Adapter - Implemented (29 tests)
|
|
11
|
+
- ✅ §4.2: File Adapter - Implemented (35 tests)
|
|
12
|
+
- ✅ §4.3: Loki Adapter - Implemented (34 tests)
|
|
13
|
+
- ✅ §4.4: Sentry Adapter - Implemented (39 tests)
|
|
14
|
+
- ❌ §4.5: Elasticsearch Adapter - Cancelled (not needed now)
|
|
15
|
+
- ✅ §5: Adapter Registry - Implemented (26 tests)
|
|
16
|
+
- ✅ §9.1: InMemory Test Adapter - Implemented (51 tests)
|
|
17
|
+
- ✅ AuditEncrypted Adapter - Updated to new contract (13 tests)
|
|
18
|
+
|
|
19
|
+
**Test Coverage:** 249/249 adapter tests passing
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 📋 Table of Contents
|
|
24
|
+
|
|
25
|
+
1. [Context & Problem](#1-context--problem)
|
|
26
|
+
2. [Architecture Overview](#2-architecture-overview)
|
|
27
|
+
3. [Adapter Interface](#3-adapter-interface)
|
|
28
|
+
4. [Built-in Adapters](#4-built-in-adapters)
|
|
29
|
+
- 4.1. [Stdout Adapter](#41-stdout-adapter)
|
|
30
|
+
- 4.2. [File Adapter](#42-file-adapter)
|
|
31
|
+
- 4.3. [Loki Adapter](#43-loki-adapter)
|
|
32
|
+
- 4.4. [Sentry Adapter](#44-sentry-adapter)
|
|
33
|
+
- 4.5. [Elasticsearch Adapter](#45-elasticsearch-adapter)
|
|
34
|
+
5. [Adapter Registry](#5-adapter-registry)
|
|
35
|
+
6. [Connection Management](#6-connection-management)
|
|
36
|
+
7. [Error Handling & Retry](#7-error-handling--retry)
|
|
37
|
+
8. [Performance & Batching](#8-performance--batching)
|
|
38
|
+
9. [Testing Strategy](#9-testing-strategy)
|
|
39
|
+
10. [Custom Adapters](#10-custom-adapters)
|
|
40
|
+
11. [Trade-offs](#11-trade-offs)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 1. Context & Problem
|
|
45
|
+
|
|
46
|
+
### 1.1. Problem Statement
|
|
47
|
+
|
|
48
|
+
**Current Pain Points:**
|
|
49
|
+
|
|
50
|
+
1. **Tight Coupling:**
|
|
51
|
+
```ruby
|
|
52
|
+
# ❌ Hard-coded destinations
|
|
53
|
+
Events::OrderPaid.track(...) do
|
|
54
|
+
send_to_loki(...)
|
|
55
|
+
send_to_sentry(...)
|
|
56
|
+
send_to_file(...)
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
2. **No Unified Interface:**
|
|
61
|
+
- Each destination has different API
|
|
62
|
+
- No standard error handling
|
|
63
|
+
- Inconsistent retry logic
|
|
64
|
+
|
|
65
|
+
3. **Configuration Duplication:**
|
|
66
|
+
```ruby
|
|
67
|
+
# ❌ Configure same adapter multiple times
|
|
68
|
+
Events::OrderPaid.track(..., adapters: [Loki.new(...)])
|
|
69
|
+
Events::PaymentFailed.track(..., adapters: [Loki.new(...)])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
4. **Testing Complexity:**
|
|
73
|
+
- Can't easily mock adapters
|
|
74
|
+
- Integration tests require real services
|
|
75
|
+
|
|
76
|
+
### 1.2. Goals
|
|
77
|
+
|
|
78
|
+
**Primary Goals:**
|
|
79
|
+
- ✅ Unified adapter interface (contract-based)
|
|
80
|
+
- ✅ Global adapter registry (configure once, reference by name)
|
|
81
|
+
- ✅ Per-event adapter overrides
|
|
82
|
+
- ✅ Built-in error handling & retry
|
|
83
|
+
- ✅ Connection pooling & reuse
|
|
84
|
+
- ✅ Easy testing (in-memory adapter)
|
|
85
|
+
|
|
86
|
+
**Non-Goals:**
|
|
87
|
+
- ❌ Support every possible destination (only critical ones)
|
|
88
|
+
- ❌ Real-time guarantees (async buffering is OK)
|
|
89
|
+
- ❌ Perfect delivery (at-least-once, not exactly-once)
|
|
90
|
+
|
|
91
|
+
### 1.3. Success Metrics
|
|
92
|
+
|
|
93
|
+
| Metric | Target | Critical? |
|
|
94
|
+
|--------|--------|-----------|
|
|
95
|
+
| **Adapter overhead** | <0.5ms per event | ✅ Yes |
|
|
96
|
+
| **Connection reuse** | 100% | ✅ Yes |
|
|
97
|
+
| **Retry success rate** | >95% | ✅ Yes |
|
|
98
|
+
| **Test coverage** | 100% for base | ✅ Yes |
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 2. Architecture Overview
|
|
103
|
+
|
|
104
|
+
### 2.1. System Context
|
|
105
|
+
|
|
106
|
+
```mermaid
|
|
107
|
+
C4Context
|
|
108
|
+
title Adapter System Context
|
|
109
|
+
|
|
110
|
+
Person(dev, "Developer", "Tracks events")
|
|
111
|
+
|
|
112
|
+
System(e11y, "E11y Gem", "Event tracking system")
|
|
113
|
+
|
|
114
|
+
System_Ext(loki, "Grafana Loki", "Log aggregation")
|
|
115
|
+
System_Ext(sentry, "Sentry", "Error tracking")
|
|
116
|
+
System_Ext(es, "Elasticsearch", "Search & analytics")
|
|
117
|
+
System_Ext(file, "File System", "Local logs")
|
|
118
|
+
System_Ext(stdout, "Stdout", "Console output")
|
|
119
|
+
|
|
120
|
+
Rel(dev, e11y, "Tracks events", "Events::OrderPaid.track(...)")
|
|
121
|
+
|
|
122
|
+
Rel(e11y, loki, "Ships logs", "HTTP POST /loki/api/v1/push")
|
|
123
|
+
Rel(e11y, sentry, "Reports errors", "Sentry SDK")
|
|
124
|
+
Rel(e11y, es, "Indexes events", "HTTP POST /_bulk")
|
|
125
|
+
Rel(e11y, file, "Writes logs", "File I/O")
|
|
126
|
+
Rel(e11y, stdout, "Prints events", "STDOUT")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 2.2. Component Architecture
|
|
130
|
+
|
|
131
|
+
```mermaid
|
|
132
|
+
graph TB
|
|
133
|
+
subgraph "E11y Core"
|
|
134
|
+
Event[Event.track] --> Pipeline[Middleware Chain]
|
|
135
|
+
Pipeline --> Adapters[Adapter Middleware]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
subgraph "Adapter Layer"
|
|
139
|
+
Adapters --> Registry[Adapter Registry]
|
|
140
|
+
Registry --> Router[Adapter Router]
|
|
141
|
+
|
|
142
|
+
Router --> Batch[Batching Layer]
|
|
143
|
+
Batch --> Pool[Connection Pool]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
subgraph "Built-in Adapters"
|
|
147
|
+
Pool --> Stdout[Stdout Adapter]
|
|
148
|
+
Pool --> File[File Adapter]
|
|
149
|
+
Pool --> Loki[Loki Adapter]
|
|
150
|
+
Pool --> Sentry[Sentry Adapter]
|
|
151
|
+
Pool --> ES[Elasticsearch Adapter]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
subgraph "Support Components"
|
|
155
|
+
Pool --> Retry[Retry Handler]
|
|
156
|
+
Pool --> Circuit[Circuit Breaker]
|
|
157
|
+
Pool --> Compress[Compression]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
Retry --> DLQ[Dead Letter Queue]
|
|
161
|
+
|
|
162
|
+
style Registry fill:#d1ecf1
|
|
163
|
+
style Router fill:#fff3cd
|
|
164
|
+
style Batch fill:#d4edda
|
|
165
|
+
style Pool fill:#f8d7da
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 2.3. Data Flow
|
|
169
|
+
|
|
170
|
+
```mermaid
|
|
171
|
+
sequenceDiagram
|
|
172
|
+
participant App as Application
|
|
173
|
+
participant Event as Events::OrderPaid
|
|
174
|
+
participant Router as Adapter Router
|
|
175
|
+
participant Batch as Batching Layer
|
|
176
|
+
participant Loki as Loki Adapter
|
|
177
|
+
participant HTTP as HTTP Client
|
|
178
|
+
participant Backend as Loki Backend
|
|
179
|
+
|
|
180
|
+
App->>Event: .track(order_id: '123', amount: 99.99)
|
|
181
|
+
Event->>Router: route(event_data, adapters: [:loki, :sentry])
|
|
182
|
+
|
|
183
|
+
Router->>Router: Resolve adapter names → instances
|
|
184
|
+
|
|
185
|
+
loop For each adapter
|
|
186
|
+
Router->>Batch: enqueue(event_data, adapter: loki)
|
|
187
|
+
Batch->>Batch: Buffer until threshold
|
|
188
|
+
|
|
189
|
+
Note over Batch: Batch size = 100 or 5s timeout
|
|
190
|
+
|
|
191
|
+
Batch->>Loki: write_batch([event1, event2, ...])
|
|
192
|
+
Loki->>Loki: Format for Loki API
|
|
193
|
+
Loki->>Loki: Compress (gzip)
|
|
194
|
+
|
|
195
|
+
Loki->>HTTP: POST /loki/api/v1/push
|
|
196
|
+
HTTP->>Backend: Send batch
|
|
197
|
+
|
|
198
|
+
alt Success
|
|
199
|
+
Backend-->>HTTP: 204 No Content
|
|
200
|
+
HTTP-->>Loki: OK
|
|
201
|
+
Loki-->>Batch: Success
|
|
202
|
+
else Failure (retriable)
|
|
203
|
+
Backend-->>HTTP: 429 Rate Limited
|
|
204
|
+
HTTP-->>Loki: Retry
|
|
205
|
+
Loki->>Loki: Exponential backoff
|
|
206
|
+
Loki->>HTTP: POST /loki/api/v1/push (retry)
|
|
207
|
+
else Failure (permanent)
|
|
208
|
+
Backend-->>HTTP: 400 Bad Request
|
|
209
|
+
HTTP-->>Loki: Fail
|
|
210
|
+
Loki->>DLQ: Send to DLQ
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 3. Adapter Interface
|
|
218
|
+
|
|
219
|
+
### 3.1. Base Adapter Contract
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
module E11y
|
|
223
|
+
module Adapters
|
|
224
|
+
class Base
|
|
225
|
+
# Initialize adapter with config
|
|
226
|
+
# @param config [Hash] Adapter-specific configuration
|
|
227
|
+
def initialize(config = {})
|
|
228
|
+
@config = config
|
|
229
|
+
validate_config!
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Write a single event (synchronous)
|
|
233
|
+
# @param event_data [Hash] Event payload
|
|
234
|
+
# @return [Boolean] Success
|
|
235
|
+
def write(event_data)
|
|
236
|
+
raise NotImplementedError, "Adapters must implement #write"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Write a batch of events (preferred for performance)
|
|
240
|
+
# @param events [Array<Hash>] Array of event payloads
|
|
241
|
+
# @return [Boolean] Success
|
|
242
|
+
def write_batch(events)
|
|
243
|
+
# Default: call write for each event
|
|
244
|
+
events.all? { |event| write(event) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Check if adapter is healthy
|
|
248
|
+
# @return [Boolean] Health status
|
|
249
|
+
def healthy?
|
|
250
|
+
true
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Close connections, flush buffers
|
|
254
|
+
def close
|
|
255
|
+
# Default: no-op
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Adapter capabilities
|
|
259
|
+
# @return [Hash] Capability flags
|
|
260
|
+
def capabilities
|
|
261
|
+
{
|
|
262
|
+
batching: false,
|
|
263
|
+
compression: false,
|
|
264
|
+
async: false,
|
|
265
|
+
streaming: false
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
# Validate adapter config
|
|
272
|
+
def validate_config!
|
|
273
|
+
# Override in subclasses
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Format event for this adapter
|
|
277
|
+
# @param event_data [Hash]
|
|
278
|
+
# @return [Hash, String] Formatted event
|
|
279
|
+
def format_event(event_data)
|
|
280
|
+
event_data
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### 3.2. Adapter Lifecycle
|
|
288
|
+
|
|
289
|
+
```mermaid
|
|
290
|
+
stateDiagram-v2
|
|
291
|
+
[*] --> Initializing
|
|
292
|
+
|
|
293
|
+
Initializing --> Connecting: initialize(config)
|
|
294
|
+
Connecting --> Ready: Connection established
|
|
295
|
+
Connecting --> Failed: Connection failed
|
|
296
|
+
|
|
297
|
+
Ready --> Writing: write(event)
|
|
298
|
+
Writing --> Ready: Success
|
|
299
|
+
Writing --> Retrying: Retriable error
|
|
300
|
+
Writing --> Failed: Permanent error
|
|
301
|
+
|
|
302
|
+
Retrying --> Writing: Retry attempt
|
|
303
|
+
Retrying --> Failed: Max retries exceeded
|
|
304
|
+
|
|
305
|
+
Ready --> Closing: close()
|
|
306
|
+
Closing --> Closed: Cleanup complete
|
|
307
|
+
|
|
308
|
+
Failed --> Closing: Manual close
|
|
309
|
+
Closed --> [*]
|
|
310
|
+
|
|
311
|
+
note right of Ready
|
|
312
|
+
Healthy state
|
|
313
|
+
Can process events
|
|
314
|
+
end note
|
|
315
|
+
|
|
316
|
+
note right of Failed
|
|
317
|
+
Circuit breaker open
|
|
318
|
+
Events go to DLQ
|
|
319
|
+
end note
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## 4. Built-in Adapters
|
|
325
|
+
|
|
326
|
+
### 4.1. Stdout Adapter
|
|
327
|
+
|
|
328
|
+
**Purpose:** Console output for development and debugging.
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
module E11y
|
|
332
|
+
module Adapters
|
|
333
|
+
class Stdout < Base
|
|
334
|
+
def initialize(config = {})
|
|
335
|
+
super
|
|
336
|
+
@colorize = config.fetch(:colorize, true)
|
|
337
|
+
@pretty_print = config.fetch(:pretty_print, true)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def write(event_data)
|
|
341
|
+
output = format_event(event_data)
|
|
342
|
+
|
|
343
|
+
if @colorize
|
|
344
|
+
puts colorize_output(output)
|
|
345
|
+
else
|
|
346
|
+
puts output
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
true
|
|
350
|
+
rescue => e
|
|
351
|
+
warn "Stdout adapter error: #{e.message}"
|
|
352
|
+
false
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def capabilities
|
|
356
|
+
{
|
|
357
|
+
batching: false,
|
|
358
|
+
compression: false,
|
|
359
|
+
async: false,
|
|
360
|
+
streaming: true
|
|
361
|
+
}
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
private
|
|
365
|
+
|
|
366
|
+
def format_event(event_data)
|
|
367
|
+
if @pretty_print
|
|
368
|
+
JSON.pretty_generate(event_data)
|
|
369
|
+
else
|
|
370
|
+
event_data.to_json
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def colorize_output(output)
|
|
375
|
+
severity = event_data[:severity]
|
|
376
|
+
|
|
377
|
+
case severity
|
|
378
|
+
when :debug then "\e[37m#{output}\e[0m" # Gray
|
|
379
|
+
when :info then "\e[36m#{output}\e[0m" # Cyan
|
|
380
|
+
when :success then "\e[32m#{output}\e[0m" # Green
|
|
381
|
+
when :warn then "\e[33m#{output}\e[0m" # Yellow
|
|
382
|
+
when :error then "\e[31m#{output}\e[0m" # Red
|
|
383
|
+
when :fatal then "\e[35m#{output}\e[0m" # Magenta
|
|
384
|
+
else output
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Configuration:**
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
E11y.configure do |config|
|
|
396
|
+
config.adapters do
|
|
397
|
+
register :stdout, E11y::Adapters::Stdout.new(
|
|
398
|
+
colorize: true,
|
|
399
|
+
pretty_print: true
|
|
400
|
+
)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
### 4.2. File Adapter
|
|
408
|
+
|
|
409
|
+
**Purpose:** Write events to local files with rotation.
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
module E11y
|
|
413
|
+
module Adapters
|
|
414
|
+
class File < Base
|
|
415
|
+
def initialize(config = {})
|
|
416
|
+
super
|
|
417
|
+
@path = config.fetch(:path)
|
|
418
|
+
@rotation = config.fetch(:rotation, :daily)
|
|
419
|
+
@max_size = config.fetch(:max_size, 100.megabytes)
|
|
420
|
+
@compress_on_rotate = config.fetch(:compress, true)
|
|
421
|
+
@file = nil
|
|
422
|
+
@mutex = Mutex.new
|
|
423
|
+
|
|
424
|
+
ensure_directory!
|
|
425
|
+
open_file!
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def write(event_data)
|
|
429
|
+
@mutex.synchronize do
|
|
430
|
+
rotate_if_needed!
|
|
431
|
+
|
|
432
|
+
line = format_event(event_data)
|
|
433
|
+
@file.puts(line)
|
|
434
|
+
@file.flush
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
true
|
|
438
|
+
rescue => e
|
|
439
|
+
warn "File adapter error: #{e.message}"
|
|
440
|
+
false
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def write_batch(events)
|
|
444
|
+
@mutex.synchronize do
|
|
445
|
+
rotate_if_needed!
|
|
446
|
+
|
|
447
|
+
events.each do |event_data|
|
|
448
|
+
line = format_event(event_data)
|
|
449
|
+
@file.puts(line)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
@file.flush
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
true
|
|
456
|
+
rescue => e
|
|
457
|
+
warn "File adapter batch error: #{e.message}"
|
|
458
|
+
false
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def close
|
|
462
|
+
@mutex.synchronize do
|
|
463
|
+
@file&.close
|
|
464
|
+
@file = nil
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def capabilities
|
|
469
|
+
{
|
|
470
|
+
batching: true,
|
|
471
|
+
compression: false, # Done on rotation
|
|
472
|
+
async: false,
|
|
473
|
+
streaming: true
|
|
474
|
+
}
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
private
|
|
478
|
+
|
|
479
|
+
def format_event(event_data)
|
|
480
|
+
event_data.to_json
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def ensure_directory!
|
|
484
|
+
dir = ::File.dirname(@path)
|
|
485
|
+
FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def open_file!
|
|
489
|
+
@file = ::File.open(current_path, 'a')
|
|
490
|
+
@file.sync = true # Auto-flush
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def current_path
|
|
494
|
+
case @rotation
|
|
495
|
+
when :daily
|
|
496
|
+
"#{@path}.#{Date.today.strftime('%Y%m%d')}"
|
|
497
|
+
when :hourly
|
|
498
|
+
"#{@path}.#{Time.now.strftime('%Y%m%d%H')}"
|
|
499
|
+
else
|
|
500
|
+
@path
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def rotate_if_needed!
|
|
505
|
+
need_rotation = case @rotation
|
|
506
|
+
when :daily
|
|
507
|
+
@file.path != current_path
|
|
508
|
+
when :hourly
|
|
509
|
+
@file.path != current_path
|
|
510
|
+
when :size
|
|
511
|
+
@file.size >= @max_size
|
|
512
|
+
else
|
|
513
|
+
false
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
if need_rotation
|
|
517
|
+
rotate!
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def rotate!
|
|
522
|
+
old_path = @file.path
|
|
523
|
+
|
|
524
|
+
@file.close
|
|
525
|
+
|
|
526
|
+
# Compress old file
|
|
527
|
+
if @compress_on_rotate
|
|
528
|
+
compress_file!(old_path)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Open new file
|
|
532
|
+
open_file!
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def compress_file!(path)
|
|
536
|
+
require 'zlib'
|
|
537
|
+
|
|
538
|
+
Zlib::GzipWriter.open("#{path}.gz") do |gz|
|
|
539
|
+
::File.open(path, 'r') do |file|
|
|
540
|
+
gz.write(file.read)
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
::File.delete(path)
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
**Configuration:**
|
|
552
|
+
|
|
553
|
+
```ruby
|
|
554
|
+
E11y.configure do |config|
|
|
555
|
+
config.adapters do
|
|
556
|
+
register :file, E11y::Adapters::File.new(
|
|
557
|
+
path: Rails.root.join('log', 'e11y.log'),
|
|
558
|
+
rotation: :daily,
|
|
559
|
+
max_size: 100.megabytes,
|
|
560
|
+
compress: true
|
|
561
|
+
)
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
---
|
|
567
|
+
|
|
568
|
+
### 4.3. Loki Adapter
|
|
569
|
+
|
|
570
|
+
**Purpose:** Ship logs to Grafana Loki for aggregation and querying.
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
module E11y
|
|
574
|
+
module Adapters
|
|
575
|
+
class Loki < Base
|
|
576
|
+
def initialize(config = {})
|
|
577
|
+
super
|
|
578
|
+
@url = config.fetch(:url)
|
|
579
|
+
@tenant_id = config[:tenant_id]
|
|
580
|
+
@labels = config.fetch(:labels, {})
|
|
581
|
+
@batch_size = config.fetch(:batch_size, 100)
|
|
582
|
+
@batch_timeout = config.fetch(:batch_timeout, 5.seconds)
|
|
583
|
+
@compress = config.fetch(:compress, true)
|
|
584
|
+
|
|
585
|
+
@connection = build_connection
|
|
586
|
+
@buffer = []
|
|
587
|
+
@buffer_mutex = Mutex.new
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def write(event_data)
|
|
591
|
+
@buffer_mutex.synchronize do
|
|
592
|
+
@buffer << event_data
|
|
593
|
+
|
|
594
|
+
if @buffer.size >= @batch_size
|
|
595
|
+
flush_buffer!
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
true
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def write_batch(events)
|
|
603
|
+
@buffer_mutex.synchronize do
|
|
604
|
+
@buffer.concat(events)
|
|
605
|
+
|
|
606
|
+
if @buffer.size >= @batch_size
|
|
607
|
+
flush_buffer!
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
true
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def close
|
|
615
|
+
@buffer_mutex.synchronize do
|
|
616
|
+
flush_buffer! if @buffer.any?
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def capabilities
|
|
621
|
+
{
|
|
622
|
+
batching: true,
|
|
623
|
+
compression: true,
|
|
624
|
+
async: true,
|
|
625
|
+
streaming: false
|
|
626
|
+
}
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
private
|
|
630
|
+
|
|
631
|
+
def build_connection
|
|
632
|
+
require 'net/http'
|
|
633
|
+
require 'uri'
|
|
634
|
+
|
|
635
|
+
uri = URI.parse(@url)
|
|
636
|
+
|
|
637
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
638
|
+
http.use_ssl = uri.scheme == 'https'
|
|
639
|
+
http.open_timeout = 5
|
|
640
|
+
http.read_timeout = 10
|
|
641
|
+
|
|
642
|
+
http
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def flush_buffer!
|
|
646
|
+
return if @buffer.empty?
|
|
647
|
+
|
|
648
|
+
events = @buffer.dup
|
|
649
|
+
@buffer.clear
|
|
650
|
+
|
|
651
|
+
# Release mutex before I/O
|
|
652
|
+
@buffer_mutex.unlock
|
|
653
|
+
|
|
654
|
+
begin
|
|
655
|
+
send_to_loki(events)
|
|
656
|
+
ensure
|
|
657
|
+
@buffer_mutex.lock
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def send_to_loki(events)
|
|
662
|
+
payload = build_loki_payload(events)
|
|
663
|
+
body = payload.to_json
|
|
664
|
+
|
|
665
|
+
# Compress if enabled
|
|
666
|
+
if @compress
|
|
667
|
+
require 'zlib'
|
|
668
|
+
body = Zlib.gzip(body)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# Build request
|
|
672
|
+
request = Net::HTTP::Post.new('/loki/api/v1/push')
|
|
673
|
+
request['Content-Type'] = 'application/json'
|
|
674
|
+
request['Content-Encoding'] = 'gzip' if @compress
|
|
675
|
+
request['X-Scope-OrgID'] = @tenant_id if @tenant_id
|
|
676
|
+
request.body = body
|
|
677
|
+
|
|
678
|
+
# Send
|
|
679
|
+
response = @connection.request(request)
|
|
680
|
+
|
|
681
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
682
|
+
raise "Loki error: #{response.code} #{response.message}"
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
true
|
|
686
|
+
rescue => e
|
|
687
|
+
warn "Loki adapter error: #{e.message}"
|
|
688
|
+
false
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def build_loki_payload(events)
|
|
692
|
+
# Group events by labels
|
|
693
|
+
streams = events.group_by { |event| extract_labels(event) }
|
|
694
|
+
|
|
695
|
+
{
|
|
696
|
+
streams: streams.map do |labels, stream_events|
|
|
697
|
+
{
|
|
698
|
+
stream: labels,
|
|
699
|
+
values: stream_events.map do |event|
|
|
700
|
+
[
|
|
701
|
+
(event[:timestamp].to_f * 1_000_000_000).to_i.to_s, # Nanoseconds
|
|
702
|
+
event.to_json
|
|
703
|
+
]
|
|
704
|
+
end
|
|
705
|
+
}
|
|
706
|
+
end
|
|
707
|
+
}
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def extract_labels(event_data)
|
|
711
|
+
base_labels = @labels.dup
|
|
712
|
+
|
|
713
|
+
# Add standard labels
|
|
714
|
+
base_labels[:severity] = event_data[:severity].to_s
|
|
715
|
+
base_labels[:event_name] = event_data[:event_name]
|
|
716
|
+
base_labels[:service] = event_data[:service] || 'unknown'
|
|
717
|
+
base_labels[:environment] = event_data[:environment] || 'unknown'
|
|
718
|
+
|
|
719
|
+
base_labels
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
**Configuration:**
|
|
727
|
+
|
|
728
|
+
```ruby
|
|
729
|
+
E11y.configure do |config|
|
|
730
|
+
config.adapters do
|
|
731
|
+
register :loki, E11y::Adapters::Loki.new(
|
|
732
|
+
url: ENV['LOKI_URL'] || 'http://localhost:3100',
|
|
733
|
+
tenant_id: ENV['LOKI_TENANT_ID'],
|
|
734
|
+
labels: {
|
|
735
|
+
app: 'my_app',
|
|
736
|
+
env: Rails.env
|
|
737
|
+
},
|
|
738
|
+
batch_size: 100,
|
|
739
|
+
batch_timeout: 5.seconds,
|
|
740
|
+
compress: true
|
|
741
|
+
)
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
---
|
|
747
|
+
|
|
748
|
+
### 4.4. Sentry Adapter
|
|
749
|
+
|
|
750
|
+
**Purpose:** Report errors and exceptions to Sentry.
|
|
751
|
+
|
|
752
|
+
```ruby
|
|
753
|
+
module E11y
|
|
754
|
+
module Adapters
|
|
755
|
+
class Sentry < Base
|
|
756
|
+
def initialize(config = {})
|
|
757
|
+
super
|
|
758
|
+
@dsn = config.fetch(:dsn)
|
|
759
|
+
@environment = config.fetch(:environment, Rails.env)
|
|
760
|
+
@send_breadcrumbs = config.fetch(:breadcrumbs, true)
|
|
761
|
+
@severity_threshold = config.fetch(:severity_threshold, :warn)
|
|
762
|
+
|
|
763
|
+
initialize_sentry!
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def write(event_data)
|
|
767
|
+
severity = event_data[:severity]
|
|
768
|
+
|
|
769
|
+
# Only send warn+ to Sentry (unless configured otherwise)
|
|
770
|
+
return true unless should_send_to_sentry?(severity)
|
|
771
|
+
|
|
772
|
+
if severity == :error || severity == :fatal
|
|
773
|
+
send_error_to_sentry(event_data)
|
|
774
|
+
else
|
|
775
|
+
send_breadcrumb_to_sentry(event_data)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
true
|
|
779
|
+
rescue => e
|
|
780
|
+
warn "Sentry adapter error: #{e.message}"
|
|
781
|
+
false
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def capabilities
|
|
785
|
+
{
|
|
786
|
+
batching: false, # Sentry SDK handles batching
|
|
787
|
+
compression: false, # Sentry SDK handles compression
|
|
788
|
+
async: true, # Sentry SDK is async
|
|
789
|
+
streaming: false
|
|
790
|
+
}
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
private
|
|
794
|
+
|
|
795
|
+
def initialize_sentry!
|
|
796
|
+
require 'sentry-ruby'
|
|
797
|
+
|
|
798
|
+
::Sentry.init do |config|
|
|
799
|
+
config.dsn = @dsn
|
|
800
|
+
config.environment = @environment
|
|
801
|
+
config.breadcrumbs_logger = [] # We manage breadcrumbs
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def should_send_to_sentry?(severity)
|
|
806
|
+
severity_levels = [:debug, :info, :success, :warn, :error, :fatal]
|
|
807
|
+
threshold_index = severity_levels.index(@severity_threshold)
|
|
808
|
+
current_index = severity_levels.index(severity)
|
|
809
|
+
|
|
810
|
+
current_index >= threshold_index
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def send_error_to_sentry(event_data)
|
|
814
|
+
::Sentry.with_scope do |scope|
|
|
815
|
+
# Set context
|
|
816
|
+
scope.set_tags(extract_tags(event_data))
|
|
817
|
+
scope.set_extras(event_data[:payload] || {})
|
|
818
|
+
scope.set_user(event_data[:user] || {})
|
|
819
|
+
|
|
820
|
+
# Set trace context
|
|
821
|
+
if event_data[:trace_id]
|
|
822
|
+
scope.set_context('trace', {
|
|
823
|
+
trace_id: event_data[:trace_id],
|
|
824
|
+
span_id: event_data[:span_id]
|
|
825
|
+
})
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Capture exception or message
|
|
829
|
+
if event_data[:exception]
|
|
830
|
+
::Sentry.capture_exception(event_data[:exception])
|
|
831
|
+
else
|
|
832
|
+
::Sentry.capture_message(
|
|
833
|
+
event_data[:message] || event_data[:event_name],
|
|
834
|
+
level: sentry_level(event_data[:severity])
|
|
835
|
+
)
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def send_breadcrumb_to_sentry(event_data)
|
|
841
|
+
return unless @send_breadcrumbs
|
|
842
|
+
|
|
843
|
+
::Sentry.add_breadcrumb(
|
|
844
|
+
::Sentry::Breadcrumb.new(
|
|
845
|
+
category: event_data[:event_name],
|
|
846
|
+
message: event_data[:message],
|
|
847
|
+
level: sentry_level(event_data[:severity]),
|
|
848
|
+
data: event_data[:payload] || {},
|
|
849
|
+
timestamp: event_data[:timestamp]
|
|
850
|
+
)
|
|
851
|
+
)
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
def extract_tags(event_data)
|
|
855
|
+
{
|
|
856
|
+
event_name: event_data[:event_name],
|
|
857
|
+
severity: event_data[:severity],
|
|
858
|
+
environment: event_data[:environment]
|
|
859
|
+
}
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def sentry_level(severity)
|
|
863
|
+
case severity
|
|
864
|
+
when :debug then :debug
|
|
865
|
+
when :info, :success then :info
|
|
866
|
+
when :warn then :warning
|
|
867
|
+
when :error then :error
|
|
868
|
+
when :fatal then :fatal
|
|
869
|
+
else :info
|
|
870
|
+
end
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
**Configuration:**
|
|
878
|
+
|
|
879
|
+
```ruby
|
|
880
|
+
E11y.configure do |config|
|
|
881
|
+
config.adapters do
|
|
882
|
+
register :sentry, E11y::Adapters::Sentry.new(
|
|
883
|
+
dsn: ENV['SENTRY_DSN'],
|
|
884
|
+
environment: Rails.env,
|
|
885
|
+
breadcrumbs: true,
|
|
886
|
+
severity_threshold: :warn # Only send warn+ to Sentry
|
|
887
|
+
)
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
---
|
|
893
|
+
|
|
894
|
+
### 4.5. Elasticsearch Adapter
|
|
895
|
+
|
|
896
|
+
**Purpose:** Index events for search and analytics.
|
|
897
|
+
|
|
898
|
+
```ruby
|
|
899
|
+
module E11y
|
|
900
|
+
module Adapters
|
|
901
|
+
class Elasticsearch < Base
|
|
902
|
+
def initialize(config = {})
|
|
903
|
+
super
|
|
904
|
+
@url = config.fetch(:url)
|
|
905
|
+
@index_prefix = config.fetch(:index_prefix, 'e11y-events')
|
|
906
|
+
@index_rotation = config.fetch(:index_rotation, :daily)
|
|
907
|
+
@batch_size = config.fetch(:batch_size, 100)
|
|
908
|
+
@batch_timeout = config.fetch(:batch_timeout, 5.seconds)
|
|
909
|
+
|
|
910
|
+
@client = build_client
|
|
911
|
+
@buffer = []
|
|
912
|
+
@buffer_mutex = Mutex.new
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
def write(event_data)
|
|
916
|
+
@buffer_mutex.synchronize do
|
|
917
|
+
@buffer << event_data
|
|
918
|
+
|
|
919
|
+
if @buffer.size >= @batch_size
|
|
920
|
+
flush_buffer!
|
|
921
|
+
end
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
true
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def write_batch(events)
|
|
928
|
+
@buffer_mutex.synchronize do
|
|
929
|
+
@buffer.concat(events)
|
|
930
|
+
|
|
931
|
+
if @buffer.size >= @batch_size
|
|
932
|
+
flush_buffer!
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
true
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def close
|
|
940
|
+
@buffer_mutex.synchronize do
|
|
941
|
+
flush_buffer! if @buffer.any?
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
def capabilities
|
|
946
|
+
{
|
|
947
|
+
batching: true,
|
|
948
|
+
compression: true,
|
|
949
|
+
async: true,
|
|
950
|
+
streaming: false
|
|
951
|
+
}
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
private
|
|
955
|
+
|
|
956
|
+
def build_client
|
|
957
|
+
require 'elasticsearch'
|
|
958
|
+
|
|
959
|
+
::Elasticsearch::Client.new(
|
|
960
|
+
url: @url,
|
|
961
|
+
transport_options: {
|
|
962
|
+
request: { timeout: 10 }
|
|
963
|
+
}
|
|
964
|
+
)
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
def flush_buffer!
|
|
968
|
+
return if @buffer.empty?
|
|
969
|
+
|
|
970
|
+
events = @buffer.dup
|
|
971
|
+
@buffer.clear
|
|
972
|
+
|
|
973
|
+
@buffer_mutex.unlock
|
|
974
|
+
|
|
975
|
+
begin
|
|
976
|
+
bulk_index(events)
|
|
977
|
+
ensure
|
|
978
|
+
@buffer_mutex.lock
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
def bulk_index(events)
|
|
983
|
+
# Build bulk payload
|
|
984
|
+
bulk_body = events.flat_map do |event_data|
|
|
985
|
+
[
|
|
986
|
+
{ index: { _index: index_name(event_data) } },
|
|
987
|
+
format_for_es(event_data)
|
|
988
|
+
]
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
# Send bulk request
|
|
992
|
+
response = @client.bulk(body: bulk_body)
|
|
993
|
+
|
|
994
|
+
# Check for errors
|
|
995
|
+
if response['errors']
|
|
996
|
+
handle_bulk_errors(response['items'])
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
true
|
|
1000
|
+
rescue => e
|
|
1001
|
+
warn "Elasticsearch adapter error: #{e.message}"
|
|
1002
|
+
false
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
def index_name(event_data)
|
|
1006
|
+
timestamp = event_data[:timestamp]
|
|
1007
|
+
|
|
1008
|
+
suffix = case @index_rotation
|
|
1009
|
+
when :daily
|
|
1010
|
+
timestamp.strftime('%Y.%m.%d')
|
|
1011
|
+
when :monthly
|
|
1012
|
+
timestamp.strftime('%Y.%m')
|
|
1013
|
+
when :yearly
|
|
1014
|
+
timestamp.strftime('%Y')
|
|
1015
|
+
else
|
|
1016
|
+
'default'
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
"#{@index_prefix}-#{suffix}"
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
def format_for_es(event_data)
|
|
1023
|
+
{
|
|
1024
|
+
'@timestamp': event_data[:timestamp].iso8601,
|
|
1025
|
+
event_name: event_data[:event_name],
|
|
1026
|
+
severity: event_data[:severity],
|
|
1027
|
+
message: event_data[:message],
|
|
1028
|
+
trace_id: event_data[:trace_id],
|
|
1029
|
+
span_id: event_data[:span_id],
|
|
1030
|
+
payload: event_data[:payload],
|
|
1031
|
+
metadata: event_data[:metadata],
|
|
1032
|
+
environment: event_data[:environment],
|
|
1033
|
+
service: event_data[:service]
|
|
1034
|
+
}
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
def handle_bulk_errors(items)
|
|
1038
|
+
items.each do |item|
|
|
1039
|
+
if item['index'] && item['index']['error']
|
|
1040
|
+
warn "ES index error: #{item['index']['error']}"
|
|
1041
|
+
end
|
|
1042
|
+
end
|
|
1043
|
+
end
|
|
1044
|
+
end
|
|
1045
|
+
end
|
|
1046
|
+
end
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
**Configuration:**
|
|
1050
|
+
|
|
1051
|
+
```ruby
|
|
1052
|
+
E11y.configure do |config|
|
|
1053
|
+
config.adapters do
|
|
1054
|
+
register :elasticsearch, E11y::Adapters::Elasticsearch.new(
|
|
1055
|
+
url: ENV['ELASTICSEARCH_URL'] || 'http://localhost:9200',
|
|
1056
|
+
index_prefix: 'e11y-events',
|
|
1057
|
+
index_rotation: :daily,
|
|
1058
|
+
batch_size: 100,
|
|
1059
|
+
batch_timeout: 5.seconds
|
|
1060
|
+
)
|
|
1061
|
+
end
|
|
1062
|
+
end
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
---
|
|
1066
|
+
|
|
1067
|
+
## 5. Adapter Registry
|
|
1068
|
+
|
|
1069
|
+
### 5.1. Registry Architecture
|
|
1070
|
+
|
|
1071
|
+
```mermaid
|
|
1072
|
+
graph TB
|
|
1073
|
+
subgraph "Configuration Phase (Boot Time)"
|
|
1074
|
+
Config[Configuration] --> Register[Registry.register]
|
|
1075
|
+
Register --> Instance1[Loki Instance]
|
|
1076
|
+
Register --> Instance2[Sentry Instance]
|
|
1077
|
+
Register --> Instance3[File Instance]
|
|
1078
|
+
|
|
1079
|
+
Instance1 --> Pool1[Connection Pool]
|
|
1080
|
+
Instance2 --> Pool2[Connection Pool]
|
|
1081
|
+
Instance3 --> Pool3[Connection Pool]
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
subgraph "Runtime Phase"
|
|
1085
|
+
Event[Event.track] --> Resolve[Registry.resolve]
|
|
1086
|
+
Resolve -->|:loki| Instance1
|
|
1087
|
+
Resolve -->|:sentry| Instance2
|
|
1088
|
+
Resolve -->|:file| Instance3
|
|
1089
|
+
end
|
|
1090
|
+
|
|
1091
|
+
style Register fill:#d1ecf1
|
|
1092
|
+
style Resolve fill:#fff3cd
|
|
1093
|
+
style Pool1 fill:#d4edda
|
|
1094
|
+
style Pool2 fill:#d4edda
|
|
1095
|
+
style Pool3 fill:#d4edda
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
### 5.2. Registry Implementation
|
|
1099
|
+
|
|
1100
|
+
```ruby
|
|
1101
|
+
module E11y
|
|
1102
|
+
module Adapters
|
|
1103
|
+
class Registry
|
|
1104
|
+
class << self
|
|
1105
|
+
def register(name, adapter_instance)
|
|
1106
|
+
validate_adapter!(adapter_instance)
|
|
1107
|
+
|
|
1108
|
+
adapters[name] = adapter_instance
|
|
1109
|
+
|
|
1110
|
+
# Register cleanup hook
|
|
1111
|
+
at_exit { adapter_instance.close }
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
def resolve(name)
|
|
1115
|
+
adapters.fetch(name) do
|
|
1116
|
+
raise AdapterNotFoundError, "Adapter not found: #{name}"
|
|
1117
|
+
end
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
def resolve_all(names)
|
|
1121
|
+
names.map { |name| resolve(name) }
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
def all
|
|
1125
|
+
adapters.values
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
def names
|
|
1129
|
+
adapters.keys
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
def clear!
|
|
1133
|
+
adapters.each_value(&:close)
|
|
1134
|
+
adapters.clear
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
private
|
|
1138
|
+
|
|
1139
|
+
def adapters
|
|
1140
|
+
@adapters ||= {}
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
def validate_adapter!(adapter)
|
|
1144
|
+
unless adapter.respond_to?(:write)
|
|
1145
|
+
raise ArgumentError, "Adapter must respond to #write"
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
unless adapter.respond_to?(:write_batch)
|
|
1149
|
+
raise ArgumentError, "Adapter must respond to #write_batch"
|
|
1150
|
+
end
|
|
1151
|
+
end
|
|
1152
|
+
end
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
class AdapterNotFoundError < StandardError; end
|
|
1156
|
+
end
|
|
1157
|
+
end
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
### 5.3. Usage in Events
|
|
1161
|
+
|
|
1162
|
+
```ruby
|
|
1163
|
+
module Events
|
|
1164
|
+
class OrderPaid < E11y::Event::Base
|
|
1165
|
+
# Override global adapters
|
|
1166
|
+
adapters [:loki, :file, :sentry]
|
|
1167
|
+
|
|
1168
|
+
schema do
|
|
1169
|
+
required(:order_id).filled(:string)
|
|
1170
|
+
required(:amount).filled(:float)
|
|
1171
|
+
end
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1174
|
+
class DebugEvent < E11y::Event::Base
|
|
1175
|
+
# Only log to file in development
|
|
1176
|
+
adapters [:file, :stdout]
|
|
1177
|
+
|
|
1178
|
+
schema do
|
|
1179
|
+
required(:message).filled(:string)
|
|
1180
|
+
end
|
|
1181
|
+
end
|
|
1182
|
+
end
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
---
|
|
1186
|
+
|
|
1187
|
+
## 6. Connection Management
|
|
1188
|
+
|
|
1189
|
+
### 6.1. Connection Pooling
|
|
1190
|
+
|
|
1191
|
+
```ruby
|
|
1192
|
+
module E11y
|
|
1193
|
+
module Adapters
|
|
1194
|
+
class ConnectionPool
|
|
1195
|
+
def initialize(adapter_class, config, pool_size: 5)
|
|
1196
|
+
@adapter_class = adapter_class
|
|
1197
|
+
@config = config
|
|
1198
|
+
@pool_size = pool_size
|
|
1199
|
+
@pool = []
|
|
1200
|
+
@mutex = Mutex.new
|
|
1201
|
+
|
|
1202
|
+
initialize_pool!
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
def with_connection
|
|
1206
|
+
connection = acquire
|
|
1207
|
+
|
|
1208
|
+
begin
|
|
1209
|
+
yield connection
|
|
1210
|
+
ensure
|
|
1211
|
+
release(connection)
|
|
1212
|
+
end
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
def close_all
|
|
1216
|
+
@mutex.synchronize do
|
|
1217
|
+
@pool.each(&:close)
|
|
1218
|
+
@pool.clear
|
|
1219
|
+
end
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
private
|
|
1223
|
+
|
|
1224
|
+
def initialize_pool!
|
|
1225
|
+
@pool_size.times do
|
|
1226
|
+
@pool << create_connection
|
|
1227
|
+
end
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
def create_connection
|
|
1231
|
+
@adapter_class.new(@config)
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
def acquire
|
|
1235
|
+
@mutex.synchronize do
|
|
1236
|
+
connection = @pool.pop
|
|
1237
|
+
|
|
1238
|
+
# Create new if pool exhausted
|
|
1239
|
+
connection ||= create_connection
|
|
1240
|
+
|
|
1241
|
+
connection
|
|
1242
|
+
end
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
def release(connection)
|
|
1246
|
+
@mutex.synchronize do
|
|
1247
|
+
if @pool.size < @pool_size
|
|
1248
|
+
@pool.push(connection)
|
|
1249
|
+
else
|
|
1250
|
+
connection.close
|
|
1251
|
+
end
|
|
1252
|
+
end
|
|
1253
|
+
end
|
|
1254
|
+
end
|
|
1255
|
+
end
|
|
1256
|
+
end
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
---
|
|
1260
|
+
|
|
1261
|
+
## 7. Error Handling & Retry
|
|
1262
|
+
|
|
1263
|
+
### 7.1. Retry Policy
|
|
1264
|
+
|
|
1265
|
+
```ruby
|
|
1266
|
+
module E11y
|
|
1267
|
+
module Adapters
|
|
1268
|
+
class RetryHandler
|
|
1269
|
+
def initialize(max_retries: 3, base_delay: 1.0, max_delay: 30.0)
|
|
1270
|
+
@max_retries = max_retries
|
|
1271
|
+
@base_delay = base_delay
|
|
1272
|
+
@max_delay = max_delay
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
def with_retry(adapter_name, &block)
|
|
1276
|
+
attempt = 0
|
|
1277
|
+
|
|
1278
|
+
begin
|
|
1279
|
+
attempt += 1
|
|
1280
|
+
block.call
|
|
1281
|
+
rescue => e
|
|
1282
|
+
if retriable?(e) && attempt <= @max_retries
|
|
1283
|
+
delay = calculate_delay(attempt)
|
|
1284
|
+
|
|
1285
|
+
warn "[E11y] Adapter #{adapter_name} failed (attempt #{attempt}/#{@max_retries}): #{e.message}"
|
|
1286
|
+
warn "[E11y] Retrying in #{delay}s..."
|
|
1287
|
+
|
|
1288
|
+
sleep(delay)
|
|
1289
|
+
retry
|
|
1290
|
+
else
|
|
1291
|
+
raise
|
|
1292
|
+
end
|
|
1293
|
+
end
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
private
|
|
1297
|
+
|
|
1298
|
+
def retriable?(error)
|
|
1299
|
+
# Network errors
|
|
1300
|
+
return true if error.is_a?(Timeout::Error)
|
|
1301
|
+
return true if error.is_a?(Errno::ECONNREFUSED)
|
|
1302
|
+
return true if error.is_a?(Errno::ETIMEDOUT)
|
|
1303
|
+
|
|
1304
|
+
# HTTP errors (if using HTTP client)
|
|
1305
|
+
if defined?(Net::HTTPRetriableError)
|
|
1306
|
+
return true if error.is_a?(Net::HTTPRetriableError)
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
false
|
|
1310
|
+
end
|
|
1311
|
+
|
|
1312
|
+
def calculate_delay(attempt)
|
|
1313
|
+
# Exponential backoff with jitter
|
|
1314
|
+
delay = [@base_delay * (2 ** (attempt - 1)), @max_delay].min
|
|
1315
|
+
jitter = rand * delay * 0.1
|
|
1316
|
+
|
|
1317
|
+
delay + jitter
|
|
1318
|
+
end
|
|
1319
|
+
end
|
|
1320
|
+
end
|
|
1321
|
+
end
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
### 7.2. Circuit Breaker
|
|
1325
|
+
|
|
1326
|
+
```ruby
|
|
1327
|
+
module E11y
|
|
1328
|
+
module Adapters
|
|
1329
|
+
class CircuitBreaker
|
|
1330
|
+
CLOSED = :closed
|
|
1331
|
+
OPEN = :open
|
|
1332
|
+
HALF_OPEN = :half_open
|
|
1333
|
+
|
|
1334
|
+
def initialize(failure_threshold: 5, timeout: 60, success_threshold: 2)
|
|
1335
|
+
@failure_threshold = failure_threshold
|
|
1336
|
+
@timeout = timeout
|
|
1337
|
+
@success_threshold = success_threshold
|
|
1338
|
+
|
|
1339
|
+
@state = CLOSED
|
|
1340
|
+
@failure_count = 0
|
|
1341
|
+
@success_count = 0
|
|
1342
|
+
@last_failure_time = nil
|
|
1343
|
+
@mutex = Mutex.new
|
|
1344
|
+
end
|
|
1345
|
+
|
|
1346
|
+
def call
|
|
1347
|
+
@mutex.synchronize do
|
|
1348
|
+
if open?
|
|
1349
|
+
if timeout_expired?
|
|
1350
|
+
transition_to_half_open
|
|
1351
|
+
else
|
|
1352
|
+
raise CircuitOpenError, "Circuit breaker is open"
|
|
1353
|
+
end
|
|
1354
|
+
end
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
begin
|
|
1358
|
+
result = yield
|
|
1359
|
+
on_success
|
|
1360
|
+
result
|
|
1361
|
+
rescue => e
|
|
1362
|
+
on_failure
|
|
1363
|
+
raise
|
|
1364
|
+
end
|
|
1365
|
+
end
|
|
1366
|
+
|
|
1367
|
+
def open?
|
|
1368
|
+
@state == OPEN
|
|
1369
|
+
end
|
|
1370
|
+
|
|
1371
|
+
def closed?
|
|
1372
|
+
@state == CLOSED
|
|
1373
|
+
end
|
|
1374
|
+
|
|
1375
|
+
def half_open?
|
|
1376
|
+
@state == HALF_OPEN
|
|
1377
|
+
end
|
|
1378
|
+
|
|
1379
|
+
private
|
|
1380
|
+
|
|
1381
|
+
def on_success
|
|
1382
|
+
@mutex.synchronize do
|
|
1383
|
+
@failure_count = 0
|
|
1384
|
+
|
|
1385
|
+
if half_open?
|
|
1386
|
+
@success_count += 1
|
|
1387
|
+
|
|
1388
|
+
if @success_count >= @success_threshold
|
|
1389
|
+
transition_to_closed
|
|
1390
|
+
end
|
|
1391
|
+
end
|
|
1392
|
+
end
|
|
1393
|
+
end
|
|
1394
|
+
|
|
1395
|
+
def on_failure
|
|
1396
|
+
@mutex.synchronize do
|
|
1397
|
+
@failure_count += 1
|
|
1398
|
+
@success_count = 0
|
|
1399
|
+
@last_failure_time = Time.now
|
|
1400
|
+
|
|
1401
|
+
if @failure_count >= @failure_threshold
|
|
1402
|
+
transition_to_open
|
|
1403
|
+
end
|
|
1404
|
+
end
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1407
|
+
def timeout_expired?
|
|
1408
|
+
@last_failure_time && (Time.now - @last_failure_time) >= @timeout
|
|
1409
|
+
end
|
|
1410
|
+
|
|
1411
|
+
def transition_to_open
|
|
1412
|
+
@state = OPEN
|
|
1413
|
+
warn "[E11y] Circuit breaker opened (#{@failure_count} failures)"
|
|
1414
|
+
end
|
|
1415
|
+
|
|
1416
|
+
def transition_to_half_open
|
|
1417
|
+
@state = HALF_OPEN
|
|
1418
|
+
@success_count = 0
|
|
1419
|
+
warn "[E11y] Circuit breaker half-open (testing)"
|
|
1420
|
+
end
|
|
1421
|
+
|
|
1422
|
+
def transition_to_closed
|
|
1423
|
+
@state = CLOSED
|
|
1424
|
+
warn "[E11y] Circuit breaker closed (recovered)"
|
|
1425
|
+
end
|
|
1426
|
+
end
|
|
1427
|
+
|
|
1428
|
+
class CircuitOpenError < StandardError; end
|
|
1429
|
+
end
|
|
1430
|
+
end
|
|
1431
|
+
```
|
|
1432
|
+
|
|
1433
|
+
---
|
|
1434
|
+
|
|
1435
|
+
## 8. Performance & Batching
|
|
1436
|
+
|
|
1437
|
+
### 8.1. Adaptive Batching
|
|
1438
|
+
|
|
1439
|
+
```ruby
|
|
1440
|
+
module E11y
|
|
1441
|
+
module Adapters
|
|
1442
|
+
class AdaptiveBatcher
|
|
1443
|
+
def initialize(adapter, min_size: 10, max_size: 1000, timeout: 5.seconds)
|
|
1444
|
+
@adapter = adapter
|
|
1445
|
+
@min_size = min_size
|
|
1446
|
+
@max_size = max_size
|
|
1447
|
+
@timeout = timeout
|
|
1448
|
+
|
|
1449
|
+
@buffer = []
|
|
1450
|
+
@mutex = Mutex.new
|
|
1451
|
+
@last_flush = Time.now
|
|
1452
|
+
|
|
1453
|
+
start_flush_timer!
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
def write(event_data)
|
|
1457
|
+
@mutex.synchronize do
|
|
1458
|
+
@buffer << event_data
|
|
1459
|
+
|
|
1460
|
+
if should_flush?
|
|
1461
|
+
flush!
|
|
1462
|
+
end
|
|
1463
|
+
end
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1466
|
+
def flush!
|
|
1467
|
+
return if @buffer.empty?
|
|
1468
|
+
|
|
1469
|
+
events = @buffer.dup
|
|
1470
|
+
@buffer.clear
|
|
1471
|
+
@last_flush = Time.now
|
|
1472
|
+
|
|
1473
|
+
# Release lock before I/O
|
|
1474
|
+
@mutex.unlock
|
|
1475
|
+
|
|
1476
|
+
begin
|
|
1477
|
+
@adapter.write_batch(events)
|
|
1478
|
+
ensure
|
|
1479
|
+
@mutex.lock
|
|
1480
|
+
end
|
|
1481
|
+
end
|
|
1482
|
+
|
|
1483
|
+
private
|
|
1484
|
+
|
|
1485
|
+
def should_flush?
|
|
1486
|
+
@buffer.size >= @max_size ||
|
|
1487
|
+
(@buffer.size >= @min_size && timeout_expired?)
|
|
1488
|
+
end
|
|
1489
|
+
|
|
1490
|
+
def timeout_expired?
|
|
1491
|
+
(Time.now - @last_flush) >= @timeout
|
|
1492
|
+
end
|
|
1493
|
+
|
|
1494
|
+
def start_flush_timer!
|
|
1495
|
+
Thread.new do
|
|
1496
|
+
loop do
|
|
1497
|
+
sleep(@timeout)
|
|
1498
|
+
|
|
1499
|
+
@mutex.synchronize do
|
|
1500
|
+
flush! if timeout_expired? && @buffer.any?
|
|
1501
|
+
end
|
|
1502
|
+
end
|
|
1503
|
+
end
|
|
1504
|
+
end
|
|
1505
|
+
end
|
|
1506
|
+
end
|
|
1507
|
+
end
|
|
1508
|
+
```
|
|
1509
|
+
|
|
1510
|
+
---
|
|
1511
|
+
|
|
1512
|
+
## 9. Testing Strategy
|
|
1513
|
+
|
|
1514
|
+
### 9.1. In-Memory Test Adapter
|
|
1515
|
+
|
|
1516
|
+
```ruby
|
|
1517
|
+
module E11y
|
|
1518
|
+
module Adapters
|
|
1519
|
+
class InMemory < Base
|
|
1520
|
+
attr_reader :events, :batches
|
|
1521
|
+
|
|
1522
|
+
def initialize(config = {})
|
|
1523
|
+
super
|
|
1524
|
+
@events = []
|
|
1525
|
+
@batches = []
|
|
1526
|
+
@mutex = Mutex.new
|
|
1527
|
+
end
|
|
1528
|
+
|
|
1529
|
+
def write(event_data)
|
|
1530
|
+
@mutex.synchronize do
|
|
1531
|
+
@events << event_data
|
|
1532
|
+
end
|
|
1533
|
+
true
|
|
1534
|
+
end
|
|
1535
|
+
|
|
1536
|
+
def write_batch(events)
|
|
1537
|
+
@mutex.synchronize do
|
|
1538
|
+
@events.concat(events)
|
|
1539
|
+
@batches << events
|
|
1540
|
+
end
|
|
1541
|
+
true
|
|
1542
|
+
end
|
|
1543
|
+
|
|
1544
|
+
def clear!
|
|
1545
|
+
@mutex.synchronize do
|
|
1546
|
+
@events.clear
|
|
1547
|
+
@batches.clear
|
|
1548
|
+
end
|
|
1549
|
+
end
|
|
1550
|
+
|
|
1551
|
+
def find_events(pattern)
|
|
1552
|
+
@events.select { |event| event[:event_name].match?(pattern) }
|
|
1553
|
+
end
|
|
1554
|
+
|
|
1555
|
+
def event_count(event_name: nil)
|
|
1556
|
+
if event_name
|
|
1557
|
+
@events.count { |event| event[:event_name] == event_name }
|
|
1558
|
+
else
|
|
1559
|
+
@events.size
|
|
1560
|
+
end
|
|
1561
|
+
end
|
|
1562
|
+
end
|
|
1563
|
+
end
|
|
1564
|
+
end
|
|
1565
|
+
```
|
|
1566
|
+
|
|
1567
|
+
### 9.2. RSpec Helpers
|
|
1568
|
+
|
|
1569
|
+
```ruby
|
|
1570
|
+
RSpec.describe "Adapter Testing" do
|
|
1571
|
+
let(:test_adapter) { E11y::Adapters::InMemory.new }
|
|
1572
|
+
|
|
1573
|
+
before do
|
|
1574
|
+
E11y.configure do |config|
|
|
1575
|
+
config.adapters do
|
|
1576
|
+
register :test, test_adapter
|
|
1577
|
+
end
|
|
1578
|
+
end
|
|
1579
|
+
end
|
|
1580
|
+
|
|
1581
|
+
after do
|
|
1582
|
+
test_adapter.clear!
|
|
1583
|
+
end
|
|
1584
|
+
|
|
1585
|
+
it "tracks events to test adapter" do
|
|
1586
|
+
Events::OrderPaid.track(order_id: '123', amount: 99.99)
|
|
1587
|
+
|
|
1588
|
+
expect(test_adapter.events.size).to eq(1)
|
|
1589
|
+
expect(test_adapter.events.first[:event_name]).to eq('order.paid')
|
|
1590
|
+
end
|
|
1591
|
+
|
|
1592
|
+
it "supports batch writes" do
|
|
1593
|
+
10.times { |i| Events::OrderPaid.track(order_id: i, amount: 10.0) }
|
|
1594
|
+
|
|
1595
|
+
expect(test_adapter.event_count).to eq(10)
|
|
1596
|
+
expect(test_adapter.batches.size).to be > 0
|
|
1597
|
+
end
|
|
1598
|
+
end
|
|
1599
|
+
```
|
|
1600
|
+
|
|
1601
|
+
---
|
|
1602
|
+
|
|
1603
|
+
## 10. Custom Adapters
|
|
1604
|
+
|
|
1605
|
+
### 10.1. Creating a Custom Adapter
|
|
1606
|
+
|
|
1607
|
+
```ruby
|
|
1608
|
+
# lib/adapters/slack_adapter.rb
|
|
1609
|
+
module Adapters
|
|
1610
|
+
class Slack < E11y::Adapters::Base
|
|
1611
|
+
def initialize(config = {})
|
|
1612
|
+
super
|
|
1613
|
+
@webhook_url = config.fetch(:webhook_url)
|
|
1614
|
+
@channel = config.fetch(:channel, '#alerts')
|
|
1615
|
+
@username = config.fetch(:username, 'E11y Bot')
|
|
1616
|
+
end
|
|
1617
|
+
|
|
1618
|
+
def write(event_data)
|
|
1619
|
+
# Only send error+ to Slack
|
|
1620
|
+
return true unless [:error, :fatal].include?(event_data[:severity])
|
|
1621
|
+
|
|
1622
|
+
send_to_slack(event_data)
|
|
1623
|
+
true
|
|
1624
|
+
rescue => e
|
|
1625
|
+
warn "Slack adapter error: #{e.message}"
|
|
1626
|
+
false
|
|
1627
|
+
end
|
|
1628
|
+
|
|
1629
|
+
def capabilities
|
|
1630
|
+
{
|
|
1631
|
+
batching: false,
|
|
1632
|
+
compression: false,
|
|
1633
|
+
async: true,
|
|
1634
|
+
streaming: false
|
|
1635
|
+
}
|
|
1636
|
+
end
|
|
1637
|
+
|
|
1638
|
+
private
|
|
1639
|
+
|
|
1640
|
+
def send_to_slack(event_data)
|
|
1641
|
+
require 'net/http'
|
|
1642
|
+
require 'uri'
|
|
1643
|
+
|
|
1644
|
+
uri = URI.parse(@webhook_url)
|
|
1645
|
+
|
|
1646
|
+
payload = {
|
|
1647
|
+
channel: @channel,
|
|
1648
|
+
username: @username,
|
|
1649
|
+
text: format_message(event_data),
|
|
1650
|
+
attachments: [
|
|
1651
|
+
{
|
|
1652
|
+
color: severity_color(event_data[:severity]),
|
|
1653
|
+
fields: [
|
|
1654
|
+
{ title: 'Event', value: event_data[:event_name], short: true },
|
|
1655
|
+
{ title: 'Severity', value: event_data[:severity], short: true },
|
|
1656
|
+
{ title: 'Trace ID', value: event_data[:trace_id], short: false }
|
|
1657
|
+
],
|
|
1658
|
+
ts: event_data[:timestamp].to_i
|
|
1659
|
+
}
|
|
1660
|
+
]
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
1664
|
+
http.use_ssl = true
|
|
1665
|
+
|
|
1666
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
1667
|
+
request['Content-Type'] = 'application/json'
|
|
1668
|
+
request.body = payload.to_json
|
|
1669
|
+
|
|
1670
|
+
http.request(request)
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
def format_message(event_data)
|
|
1674
|
+
"🚨 *#{event_data[:event_name]}*: #{event_data[:message]}"
|
|
1675
|
+
end
|
|
1676
|
+
|
|
1677
|
+
def severity_color(severity)
|
|
1678
|
+
case severity
|
|
1679
|
+
when :error then 'danger'
|
|
1680
|
+
when :fatal then 'danger'
|
|
1681
|
+
when :warn then 'warning'
|
|
1682
|
+
else 'good'
|
|
1683
|
+
end
|
|
1684
|
+
end
|
|
1685
|
+
end
|
|
1686
|
+
end
|
|
1687
|
+
|
|
1688
|
+
# Register in config
|
|
1689
|
+
E11y.configure do |config|
|
|
1690
|
+
config.adapters do
|
|
1691
|
+
register :slack, Adapters::Slack.new(
|
|
1692
|
+
webhook_url: ENV['SLACK_WEBHOOK_URL'],
|
|
1693
|
+
channel: '#production-alerts'
|
|
1694
|
+
)
|
|
1695
|
+
end
|
|
1696
|
+
end
|
|
1697
|
+
```
|
|
1698
|
+
|
|
1699
|
+
---
|
|
1700
|
+
|
|
1701
|
+
## 10a. Event-Level Adapter Configuration (NEW - v1.1)
|
|
1702
|
+
|
|
1703
|
+
> **🎯 CONTRADICTION_01 Resolution:** Move adapter configuration from global initializer to event classes.
|
|
1704
|
+
>
|
|
1705
|
+
> **Goal:** Reduce global config from 1400+ lines to <300 lines by distributing adapter routing across events.
|
|
1706
|
+
|
|
1707
|
+
### 10a.1. Problem: Global Adapter Routing Complexity
|
|
1708
|
+
|
|
1709
|
+
**Before (v1.0): All routing in global config**
|
|
1710
|
+
|
|
1711
|
+
```ruby
|
|
1712
|
+
# config/initializers/e11y.rb (1400+ lines!)
|
|
1713
|
+
E11y.configure do |config|
|
|
1714
|
+
# Register adapters (infrastructure)
|
|
1715
|
+
config.register_adapter :loki, Loki.new(url: ENV['LOKI_URL'])
|
|
1716
|
+
config.register_adapter :sentry, Sentry.new(dsn: ENV['SENTRY_DSN'])
|
|
1717
|
+
config.register_adapter :s3, S3Adapter.new(bucket: 'events-archive')
|
|
1718
|
+
config.register_adapter :audit_encrypted, AuditAdapter.new(...)
|
|
1719
|
+
|
|
1720
|
+
# ❌ PROBLEM: Routing for EVERY event in global config
|
|
1721
|
+
config.events do
|
|
1722
|
+
# Payment events → multiple adapters
|
|
1723
|
+
event 'Events::PaymentSucceeded' do
|
|
1724
|
+
adapters [:loki, :sentry, :s3]
|
|
1725
|
+
end
|
|
1726
|
+
event 'Events::PaymentFailed' do
|
|
1727
|
+
adapters [:loki, :sentry, :s3]
|
|
1728
|
+
end
|
|
1729
|
+
event 'Events::PaymentRefunded' do
|
|
1730
|
+
adapters [:loki, :sentry, :s3]
|
|
1731
|
+
end
|
|
1732
|
+
|
|
1733
|
+
# Audit events → encrypted adapter
|
|
1734
|
+
event 'Events::UserDeleted' do
|
|
1735
|
+
adapters [:audit_encrypted]
|
|
1736
|
+
end
|
|
1737
|
+
event 'Events::PermissionChanged' do
|
|
1738
|
+
adapters [:audit_encrypted]
|
|
1739
|
+
end
|
|
1740
|
+
|
|
1741
|
+
# Debug events → file only
|
|
1742
|
+
event 'Events::DebugQuery' do
|
|
1743
|
+
adapters [:file]
|
|
1744
|
+
end
|
|
1745
|
+
|
|
1746
|
+
# ... 100+ more events ...
|
|
1747
|
+
end
|
|
1748
|
+
end
|
|
1749
|
+
```
|
|
1750
|
+
|
|
1751
|
+
**Problems:**
|
|
1752
|
+
- ❌ 1400+ lines of global config
|
|
1753
|
+
- ❌ Routing far from event definition (locality violation)
|
|
1754
|
+
- ❌ Hard to find which events use which adapters
|
|
1755
|
+
- ❌ Duplication (many events → same adapters)
|
|
1756
|
+
|
|
1757
|
+
### 10a.2. Solution: Event-Level Adapter Declaration
|
|
1758
|
+
|
|
1759
|
+
**After (v1.1): Adapters in event classes**
|
|
1760
|
+
|
|
1761
|
+
```ruby
|
|
1762
|
+
# config/initializers/e11y.rb (<300 lines!)
|
|
1763
|
+
E11y.configure do |config|
|
|
1764
|
+
# ONLY infrastructure (adapter registration)
|
|
1765
|
+
config.register_adapter :loki, Loki.new(url: ENV['LOKI_URL'])
|
|
1766
|
+
config.register_adapter :sentry, Sentry.new(dsn: ENV['SENTRY_DSN'])
|
|
1767
|
+
config.register_adapter :s3, S3Adapter.new(bucket: 'events-archive')
|
|
1768
|
+
config.register_adapter :audit_encrypted, AuditAdapter.new(...)
|
|
1769
|
+
|
|
1770
|
+
# Optional: default adapters (convention)
|
|
1771
|
+
config.default_adapters = [:loki] # Most events → Loki
|
|
1772
|
+
end
|
|
1773
|
+
|
|
1774
|
+
# app/events/payment_succeeded.rb
|
|
1775
|
+
module Events
|
|
1776
|
+
class PaymentSucceeded < E11y::Event::Base
|
|
1777
|
+
schema do; required(:transaction_id).filled(:string); end
|
|
1778
|
+
|
|
1779
|
+
# ✅ Adapters right next to schema!
|
|
1780
|
+
adapters [:loki, :sentry, :s3]
|
|
1781
|
+
end
|
|
1782
|
+
end
|
|
1783
|
+
|
|
1784
|
+
# app/events/user_deleted.rb
|
|
1785
|
+
module Events
|
|
1786
|
+
class UserDeleted < E11y::Event::Base
|
|
1787
|
+
schema do; required(:user_id).filled(:string); end
|
|
1788
|
+
|
|
1789
|
+
# ✅ Adapters right next to schema!
|
|
1790
|
+
adapters [:audit_encrypted]
|
|
1791
|
+
end
|
|
1792
|
+
end
|
|
1793
|
+
|
|
1794
|
+
# app/events/debug_query.rb
|
|
1795
|
+
module Events
|
|
1796
|
+
class DebugQuery < E11y::Event::Base
|
|
1797
|
+
schema do; required(:query).filled(:string); end
|
|
1798
|
+
|
|
1799
|
+
# ✅ Adapters right next to schema!
|
|
1800
|
+
adapters [:file]
|
|
1801
|
+
end
|
|
1802
|
+
end
|
|
1803
|
+
```
|
|
1804
|
+
|
|
1805
|
+
**Benefits:**
|
|
1806
|
+
- ✅ Global config <300 lines (only infrastructure)
|
|
1807
|
+
- ✅ Locality of behavior (adapters next to schema)
|
|
1808
|
+
- ✅ Easy to find (grep event file)
|
|
1809
|
+
- ✅ DRY via inheritance (see below)
|
|
1810
|
+
|
|
1811
|
+
### 10a.3. Inheritance for Adapter Routing
|
|
1812
|
+
|
|
1813
|
+
**Base class with common adapters:**
|
|
1814
|
+
|
|
1815
|
+
```ruby
|
|
1816
|
+
# app/events/base_payment_event.rb
|
|
1817
|
+
module Events
|
|
1818
|
+
class BasePaymentEvent < E11y::Event::Base
|
|
1819
|
+
# Common adapters for ALL payment events
|
|
1820
|
+
adapters [:loki, :sentry, :s3]
|
|
1821
|
+
|
|
1822
|
+
# Common config
|
|
1823
|
+
severity :success
|
|
1824
|
+
sample_rate 1.0
|
|
1825
|
+
retention 7.years
|
|
1826
|
+
end
|
|
1827
|
+
end
|
|
1828
|
+
|
|
1829
|
+
# Inherit from base (1-2 lines per event!)
|
|
1830
|
+
class Events::PaymentSucceeded < Events::BasePaymentEvent
|
|
1831
|
+
schema do; required(:transaction_id).filled(:string); end
|
|
1832
|
+
# ← Inherits: adapters [:loki, :sentry, :s3]
|
|
1833
|
+
end
|
|
1834
|
+
|
|
1835
|
+
class Events::PaymentFailed < Events::BasePaymentEvent
|
|
1836
|
+
severity :error # ← Override severity
|
|
1837
|
+
schema do; required(:error_code).filled(:string); end
|
|
1838
|
+
# ← Inherits: adapters [:loki, :sentry, :s3]
|
|
1839
|
+
end
|
|
1840
|
+
|
|
1841
|
+
class Events::PaymentRefunded < Events::BasePaymentEvent
|
|
1842
|
+
schema do; required(:refund_id).filled(:string); end
|
|
1843
|
+
# ← Inherits: adapters [:loki, :sentry, :s3]
|
|
1844
|
+
end
|
|
1845
|
+
```
|
|
1846
|
+
|
|
1847
|
+
**Base class for audit events:**
|
|
1848
|
+
|
|
1849
|
+
```ruby
|
|
1850
|
+
# app/events/base_audit_event.rb
|
|
1851
|
+
module Events
|
|
1852
|
+
class BaseAuditEvent < E11y::Event::Base
|
|
1853
|
+
# Common adapters for ALL audit events
|
|
1854
|
+
adapters [:audit_encrypted]
|
|
1855
|
+
|
|
1856
|
+
# Common config
|
|
1857
|
+
audit_event true
|
|
1858
|
+
# ← Auto-set: retention = E11y.config.audit_retention (configurable!)
|
|
1859
|
+
# rate_limiting = false (LOCKED!)
|
|
1860
|
+
# sampling = false (LOCKED!)
|
|
1861
|
+
end
|
|
1862
|
+
end
|
|
1863
|
+
|
|
1864
|
+
# Inherit from base
|
|
1865
|
+
class Events::UserDeleted < Events::BaseAuditEvent
|
|
1866
|
+
schema do; required(:user_id).filled(:string); end
|
|
1867
|
+
# ← Inherits: adapters [:audit_encrypted]
|
|
1868
|
+
end
|
|
1869
|
+
|
|
1870
|
+
class Events::PermissionChanged < Events::BaseAuditEvent
|
|
1871
|
+
schema do; required(:user_id).filled(:string); end
|
|
1872
|
+
# ← Inherits: adapters [:audit_encrypted]
|
|
1873
|
+
end
|
|
1874
|
+
```
|
|
1875
|
+
|
|
1876
|
+
### 10a.4. Preset Modules for Adapters
|
|
1877
|
+
|
|
1878
|
+
```ruby
|
|
1879
|
+
# lib/e11y/presets/high_value_event.rb
|
|
1880
|
+
module E11y
|
|
1881
|
+
module Presets
|
|
1882
|
+
module HighValueEvent
|
|
1883
|
+
extend ActiveSupport::Concern
|
|
1884
|
+
included do
|
|
1885
|
+
adapters [:loki, :sentry, :s3_archive]
|
|
1886
|
+
sample_rate 1.0
|
|
1887
|
+
retention 7.years
|
|
1888
|
+
end
|
|
1889
|
+
end
|
|
1890
|
+
|
|
1891
|
+
module DebugEvent
|
|
1892
|
+
extend ActiveSupport::Concern
|
|
1893
|
+
included do
|
|
1894
|
+
adapters [:file] # Local only
|
|
1895
|
+
severity :debug
|
|
1896
|
+
sample_rate 0.01
|
|
1897
|
+
retention 7.days
|
|
1898
|
+
end
|
|
1899
|
+
end
|
|
1900
|
+
end
|
|
1901
|
+
end
|
|
1902
|
+
|
|
1903
|
+
# Usage:
|
|
1904
|
+
class Events::PaymentProcessed < E11y::Event::Base
|
|
1905
|
+
include E11y::Presets::HighValueEvent # ← Adapters inherited!
|
|
1906
|
+
schema do; required(:transaction_id).filled(:string); end
|
|
1907
|
+
end
|
|
1908
|
+
|
|
1909
|
+
class Events::DebugQuery < E11y::Event::Base
|
|
1910
|
+
include E11y::Presets::DebugEvent # ← Adapters inherited!
|
|
1911
|
+
schema do; required(:query).filled(:string); end
|
|
1912
|
+
end
|
|
1913
|
+
```
|
|
1914
|
+
|
|
1915
|
+
### 10a.5. Conventions for Adapters (Sensible Defaults)
|
|
1916
|
+
|
|
1917
|
+
```ruby
|
|
1918
|
+
# Convention: Severity → Adapters
|
|
1919
|
+
# :error/:fatal → [:sentry]
|
|
1920
|
+
# :success/:info/:warn → [:loki]
|
|
1921
|
+
# :debug → [:file] (dev), [:loki] (prod with sampling)
|
|
1922
|
+
|
|
1923
|
+
# Zero-config event (uses conventions):
|
|
1924
|
+
class Events::OrderCreated < E11y::Event::Base
|
|
1925
|
+
severity :success
|
|
1926
|
+
schema do; required(:order_id).filled(:string); end
|
|
1927
|
+
# ← Auto: adapters = [:loki] (from severity!)
|
|
1928
|
+
end
|
|
1929
|
+
|
|
1930
|
+
class Events::CriticalError < E11y::Event::Base
|
|
1931
|
+
severity :fatal
|
|
1932
|
+
schema do; required(:error_code).filled(:string); end
|
|
1933
|
+
# ← Auto: adapters = [:sentry] (from severity!)
|
|
1934
|
+
end
|
|
1935
|
+
|
|
1936
|
+
# Override convention:
|
|
1937
|
+
class Events::OrderCreated < E11y::Event::Base
|
|
1938
|
+
severity :success
|
|
1939
|
+
adapters [:loki, :elasticsearch, :s3] # ← Override
|
|
1940
|
+
schema do; required(:order_id).filled(:string); end
|
|
1941
|
+
end
|
|
1942
|
+
```
|
|
1943
|
+
|
|
1944
|
+
### 10a.6. Adapter Strategy: Replace vs Append
|
|
1945
|
+
|
|
1946
|
+
```ruby
|
|
1947
|
+
# Replace strategy (default): Override parent/convention
|
|
1948
|
+
class Events::PaymentSucceeded < Events::BasePaymentEvent
|
|
1949
|
+
adapters [:loki, :sentry] # ← Replaces base [:loki, :sentry, :s3]
|
|
1950
|
+
end
|
|
1951
|
+
|
|
1952
|
+
# Append strategy: Add to parent/convention
|
|
1953
|
+
class Events::PaymentSucceeded < Events::BasePaymentEvent
|
|
1954
|
+
adapters_strategy :append
|
|
1955
|
+
adapters [:slack_business] # ← Adds to base (result: [:loki, :sentry, :s3, :slack_business])
|
|
1956
|
+
end
|
|
1957
|
+
```
|
|
1958
|
+
|
|
1959
|
+
### 10a.7. Precedence Rules
|
|
1960
|
+
|
|
1961
|
+
```ruby
|
|
1962
|
+
# Precedence (highest to lowest):
|
|
1963
|
+
# 1. Event-level explicit: adapters [:loki]
|
|
1964
|
+
# 2. Preset module: include E11y::Presets::HighValueEvent
|
|
1965
|
+
# 3. Base class inheritance: class < BasePaymentEvent
|
|
1966
|
+
# 4. Convention: severity :success → [:loki]
|
|
1967
|
+
# 5. Global default: config.default_adapters = [:loki]
|
|
1968
|
+
|
|
1969
|
+
# Example:
|
|
1970
|
+
E11y.configure do |config|
|
|
1971
|
+
config.default_adapters = [:loki] # 5. Global default
|
|
1972
|
+
end
|
|
1973
|
+
|
|
1974
|
+
class Events::BasePaymentEvent < E11y::Event::Base
|
|
1975
|
+
adapters [:loki, :sentry] # 3. Base class
|
|
1976
|
+
end
|
|
1977
|
+
|
|
1978
|
+
class Events::PaymentSucceeded < Events::BasePaymentEvent
|
|
1979
|
+
include E11y::Presets::HighValueEvent # 2. Preset (adds :s3)
|
|
1980
|
+
adapters [:loki, :sentry, :pagerduty] # 1. Event-level (WINS!)
|
|
1981
|
+
end
|
|
1982
|
+
|
|
1983
|
+
# Result: [:loki, :sentry, :pagerduty]
|
|
1984
|
+
```
|
|
1985
|
+
|
|
1986
|
+
### 10a.8. Migration Path
|
|
1987
|
+
|
|
1988
|
+
**Step 1: Keep global config (backward compatible)**
|
|
1989
|
+
|
|
1990
|
+
```ruby
|
|
1991
|
+
# v1.1 supports BOTH global and event-level config
|
|
1992
|
+
E11y.configure do |config|
|
|
1993
|
+
# Old style (still works):
|
|
1994
|
+
config.events do
|
|
1995
|
+
event 'Events::OrderCreated' do
|
|
1996
|
+
adapters [:loki]
|
|
1997
|
+
end
|
|
1998
|
+
end
|
|
1999
|
+
end
|
|
2000
|
+
|
|
2001
|
+
# New style (preferred):
|
|
2002
|
+
class Events::OrderCreated < E11y::Event::Base
|
|
2003
|
+
adapters [:loki] # ← Event-level (overrides global)
|
|
2004
|
+
end
|
|
2005
|
+
```
|
|
2006
|
+
|
|
2007
|
+
**Step 2: Migrate incrementally**
|
|
2008
|
+
|
|
2009
|
+
```ruby
|
|
2010
|
+
# Migrate high-value events first:
|
|
2011
|
+
class Events::PaymentSucceeded < E11y::Event::Base
|
|
2012
|
+
adapters [:loki, :sentry, :s3] # ← Migrated
|
|
2013
|
+
end
|
|
2014
|
+
|
|
2015
|
+
# Keep others in global config (temporary):
|
|
2016
|
+
E11y.configure do |config|
|
|
2017
|
+
config.events do
|
|
2018
|
+
event 'Events::DebugQuery' do
|
|
2019
|
+
adapters [:file] # ← Not migrated yet
|
|
2020
|
+
end
|
|
2021
|
+
end
|
|
2022
|
+
end
|
|
2023
|
+
```
|
|
2024
|
+
|
|
2025
|
+
**Step 3: Remove global routing**
|
|
2026
|
+
|
|
2027
|
+
```ruby
|
|
2028
|
+
# After all events migrated:
|
|
2029
|
+
E11y.configure do |config|
|
|
2030
|
+
# Only infrastructure (adapters registration)
|
|
2031
|
+
config.register_adapter :loki, Loki.new(...)
|
|
2032
|
+
config.register_adapter :sentry, Sentry.new(...)
|
|
2033
|
+
|
|
2034
|
+
# Optional: default adapters (convention)
|
|
2035
|
+
config.default_adapters = [:loki]
|
|
2036
|
+
|
|
2037
|
+
# ✅ No more config.events { ... } block!
|
|
2038
|
+
end
|
|
2039
|
+
```
|
|
2040
|
+
|
|
2041
|
+
### 10a.9. Benefits Summary
|
|
2042
|
+
|
|
2043
|
+
| Aspect | Before (v1.0) | After (v1.1) | Improvement |
|
|
2044
|
+
|--------|---------------|--------------|-------------|
|
|
2045
|
+
| **Global config lines** | 1400+ | <300 | 78% reduction |
|
|
2046
|
+
| **Locality** | ❌ Far from event | ✅ Next to schema | Better |
|
|
2047
|
+
| **DRY** | ❌ Duplication | ✅ Inheritance | Better |
|
|
2048
|
+
| **Discoverability** | ❌ Hard to find | ✅ Grep event file | Better |
|
|
2049
|
+
| **Conventions** | ❌ None | ✅ Severity → adapters | Better |
|
|
2050
|
+
| **Backward compat** | N/A | ✅ Both styles work | Safe |
|
|
2051
|
+
|
|
2052
|
+
---
|
|
2053
|
+
|
|
2054
|
+
## 11. Trade-offs
|
|
2055
|
+
|
|
2056
|
+
### 11.1. Key Decisions
|
|
2057
|
+
|
|
2058
|
+
| Decision | Pro | Con | Rationale |
|
|
2059
|
+
|----------|-----|-----|-----------|
|
|
2060
|
+
| **Global registry** | Configure once, reuse | Less flexibility | DRY principle |
|
|
2061
|
+
| **Batching by default** | Better performance | Slight latency | 99% use cases benefit |
|
|
2062
|
+
| **Connection pooling** | Resource efficient | Memory overhead | Critical for scale |
|
|
2063
|
+
| **Circuit breaker** | Prevents cascades | Complexity | Reliability critical |
|
|
2064
|
+
| **Sync interface** | Simple to reason | Blocks thread | Buffering mitigates |
|
|
2065
|
+
|
|
2066
|
+
### 11.2. Alternatives Considered
|
|
2067
|
+
|
|
2068
|
+
**A) No adapter abstraction**
|
|
2069
|
+
- ❌ Rejected: Every event hardcodes destinations
|
|
2070
|
+
|
|
2071
|
+
**B) Adapter instances in events**
|
|
2072
|
+
- ❌ Rejected: Duplication, no connection reuse
|
|
2073
|
+
|
|
2074
|
+
**C) Async by default**
|
|
2075
|
+
- ❌ Rejected: Complexity, hard to test
|
|
2076
|
+
|
|
2077
|
+
---
|
|
2078
|
+
|
|
2079
|
+
## 12. Validations & Environment-Specific Configuration (NEW - v1.1)
|
|
2080
|
+
|
|
2081
|
+
### 12.1. Adapter Registration Validation
|
|
2082
|
+
|
|
2083
|
+
**Problem:** Typos in adapter names → events lost.
|
|
2084
|
+
|
|
2085
|
+
**Solution:** Validate adapters against registered list:
|
|
2086
|
+
|
|
2087
|
+
```ruby
|
|
2088
|
+
# Gem implementation (automatic):
|
|
2089
|
+
def self.adapters(list)
|
|
2090
|
+
list.each do |adapter|
|
|
2091
|
+
unless E11y.registered_adapters.include?(adapter)
|
|
2092
|
+
raise ArgumentError, "Unknown adapter: #{adapter}. Registered: #{E11y.registered_adapters.keys.join(', ')}"
|
|
2093
|
+
end
|
|
2094
|
+
end
|
|
2095
|
+
self._adapters = list
|
|
2096
|
+
end
|
|
2097
|
+
|
|
2098
|
+
# Result:
|
|
2099
|
+
class Events::OrderPaid < E11y::Event::Base
|
|
2100
|
+
adapters [:loki, :sentri] # ← ERROR: "Unknown adapter: :sentri. Registered: loki, sentry, file"
|
|
2101
|
+
end
|
|
2102
|
+
```
|
|
2103
|
+
|
|
2104
|
+
### 12.2. Environment-Specific Adapters
|
|
2105
|
+
|
|
2106
|
+
**Problem:** Different adapters per environment (dev/staging/prod).
|
|
2107
|
+
|
|
2108
|
+
**Solution:** Use Ruby conditionals:
|
|
2109
|
+
|
|
2110
|
+
```ruby
|
|
2111
|
+
class Events::DebugQuery < E11y::Event::Base
|
|
2112
|
+
schema do
|
|
2113
|
+
required(:query).filled(:string)
|
|
2114
|
+
end
|
|
2115
|
+
|
|
2116
|
+
# Environment-specific adapters
|
|
2117
|
+
adapters Rails.env.production? ? [:loki] : [:file]
|
|
2118
|
+
end
|
|
2119
|
+
|
|
2120
|
+
class Events::PaymentFailed < E11y::Event::Base
|
|
2121
|
+
schema do
|
|
2122
|
+
required(:order_id).filled(:string)
|
|
2123
|
+
end
|
|
2124
|
+
|
|
2125
|
+
# Multi-environment routing
|
|
2126
|
+
adapters case Rails.env
|
|
2127
|
+
when 'production' then [:loki, :sentry, :s3_archive]
|
|
2128
|
+
when 'staging' then [:loki, :sentry]
|
|
2129
|
+
else [:file]
|
|
2130
|
+
end
|
|
2131
|
+
end
|
|
2132
|
+
```
|
|
2133
|
+
|
|
2134
|
+
### 12.3. Precedence Rules for Adapters
|
|
2135
|
+
|
|
2136
|
+
**Precedence Order (Highest to Lowest):**
|
|
2137
|
+
|
|
2138
|
+
```
|
|
2139
|
+
1. Event-level explicit config (highest)
|
|
2140
|
+
↓
|
|
2141
|
+
2. Preset module config
|
|
2142
|
+
↓
|
|
2143
|
+
3. Base class config (inheritance)
|
|
2144
|
+
↓
|
|
2145
|
+
4. Convention-based defaults (severity → adapters)
|
|
2146
|
+
↓
|
|
2147
|
+
5. Global config (lowest)
|
|
2148
|
+
```
|
|
2149
|
+
|
|
2150
|
+
**Example:**
|
|
2151
|
+
|
|
2152
|
+
```ruby
|
|
2153
|
+
# Global config (lowest)
|
|
2154
|
+
E11y.configure do |config|
|
|
2155
|
+
config.adapters = [:file] # Default
|
|
2156
|
+
end
|
|
2157
|
+
|
|
2158
|
+
# Base class (medium)
|
|
2159
|
+
class Events::BasePaymentEvent < E11y::Event::Base
|
|
2160
|
+
adapters [:loki, :sentry] # Override global
|
|
2161
|
+
end
|
|
2162
|
+
|
|
2163
|
+
# Preset (higher)
|
|
2164
|
+
module E11y::Presets::HighValueEvent
|
|
2165
|
+
extend ActiveSupport::Concern
|
|
2166
|
+
included do
|
|
2167
|
+
adapters [:loki, :sentry, :s3_archive] # Add S3
|
|
2168
|
+
end
|
|
2169
|
+
end
|
|
2170
|
+
|
|
2171
|
+
# Event (highest)
|
|
2172
|
+
class Events::CriticalPayment < Events::BasePaymentEvent
|
|
2173
|
+
include E11y::Presets::HighValueEvent
|
|
2174
|
+
|
|
2175
|
+
adapters [:loki, :sentry, :s3_archive, :datadog] # Add Datadog
|
|
2176
|
+
|
|
2177
|
+
# Final: [:loki, :sentry, :s3_archive, :datadog] (event-level wins)
|
|
2178
|
+
end
|
|
2179
|
+
```
|
|
2180
|
+
|
|
2181
|
+
---
|
|
2182
|
+
|
|
2183
|
+
## 13. Implementation Examples (2026-01-19)
|
|
2184
|
+
|
|
2185
|
+
### 13.1. Production Setup with All Adapters
|
|
2186
|
+
|
|
2187
|
+
```ruby
|
|
2188
|
+
# config/initializers/e11y.rb
|
|
2189
|
+
require 'e11y'
|
|
2190
|
+
|
|
2191
|
+
E11y.configure do |config|
|
|
2192
|
+
# Register Loki for centralized logging
|
|
2193
|
+
E11y::Adapters::Registry.register(
|
|
2194
|
+
:loki,
|
|
2195
|
+
E11y::Adapters::Loki.new(
|
|
2196
|
+
url: ENV['LOKI_URL'] || 'http://loki:3100',
|
|
2197
|
+
labels: {
|
|
2198
|
+
app: 'my_app',
|
|
2199
|
+
env: Rails.env,
|
|
2200
|
+
host: Socket.gethostname
|
|
2201
|
+
},
|
|
2202
|
+
batch_size: 100,
|
|
2203
|
+
batch_timeout: 5,
|
|
2204
|
+
compress: true,
|
|
2205
|
+
tenant_id: ENV['LOKI_TENANT_ID']
|
|
2206
|
+
)
|
|
2207
|
+
)
|
|
2208
|
+
|
|
2209
|
+
# Register Sentry for error tracking
|
|
2210
|
+
E11y::Adapters::Registry.register(
|
|
2211
|
+
:sentry,
|
|
2212
|
+
E11y::Adapters::Sentry.new(
|
|
2213
|
+
dsn: ENV['SENTRY_DSN'],
|
|
2214
|
+
environment: Rails.env,
|
|
2215
|
+
severity_threshold: :warn,
|
|
2216
|
+
breadcrumbs: true
|
|
2217
|
+
)
|
|
2218
|
+
)
|
|
2219
|
+
|
|
2220
|
+
# Register File adapter for local development
|
|
2221
|
+
E11y::Adapters::Registry.register(
|
|
2222
|
+
:file,
|
|
2223
|
+
E11y::Adapters::File.new(
|
|
2224
|
+
path: Rails.root.join('log', 'e11y.log'),
|
|
2225
|
+
rotation: :daily,
|
|
2226
|
+
max_size: 100 * 1024 * 1024, # 100MB
|
|
2227
|
+
compress: true
|
|
2228
|
+
)
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
# Register Stdout for console output
|
|
2232
|
+
E11y::Adapters::Registry.register(
|
|
2233
|
+
:stdout,
|
|
2234
|
+
E11y::Adapters::Stdout.new(
|
|
2235
|
+
pretty: Rails.env.development?,
|
|
2236
|
+
colorize: true
|
|
2237
|
+
)
|
|
2238
|
+
)
|
|
2239
|
+
|
|
2240
|
+
# Register InMemory for testing
|
|
2241
|
+
if Rails.env.test?
|
|
2242
|
+
E11y::Adapters::Registry.register(
|
|
2243
|
+
:test,
|
|
2244
|
+
E11y::Adapters::InMemory.new(
|
|
2245
|
+
max_events: 1000,
|
|
2246
|
+
max_batches: 100
|
|
2247
|
+
)
|
|
2248
|
+
)
|
|
2249
|
+
end
|
|
2250
|
+
end
|
|
2251
|
+
```
|
|
2252
|
+
|
|
2253
|
+
### 13.2. Event Examples with Adapters
|
|
2254
|
+
|
|
2255
|
+
```ruby
|
|
2256
|
+
# Business event - goes to Loki
|
|
2257
|
+
class Events::OrderPlaced < E11y::Event::Base
|
|
2258
|
+
schema do
|
|
2259
|
+
required(:order_id).filled(:string)
|
|
2260
|
+
required(:amount).filled(:integer)
|
|
2261
|
+
required(:currency).filled(:string)
|
|
2262
|
+
end
|
|
2263
|
+
|
|
2264
|
+
severity :info
|
|
2265
|
+
adapters [:loki]
|
|
2266
|
+
end
|
|
2267
|
+
|
|
2268
|
+
# Error event - goes to Sentry and Loki
|
|
2269
|
+
class Events::PaymentFailed < E11y::Event::Base
|
|
2270
|
+
schema do
|
|
2271
|
+
required(:order_id).filled(:string)
|
|
2272
|
+
required(:error_message).filled(:string)
|
|
2273
|
+
optional(:exception).filled
|
|
2274
|
+
end
|
|
2275
|
+
|
|
2276
|
+
severity :error
|
|
2277
|
+
adapters [:sentry, :loki]
|
|
2278
|
+
end
|
|
2279
|
+
|
|
2280
|
+
# Debug event - only in development
|
|
2281
|
+
class Events::DebugQuery < E11y::Event::Base
|
|
2282
|
+
schema do
|
|
2283
|
+
required(:query).filled(:string)
|
|
2284
|
+
required(:duration_ms).filled(:integer)
|
|
2285
|
+
end
|
|
2286
|
+
|
|
2287
|
+
severity :debug
|
|
2288
|
+
adapters Rails.env.development? ? [:stdout] : []
|
|
2289
|
+
end
|
|
2290
|
+
|
|
2291
|
+
# Usage
|
|
2292
|
+
Events::OrderPlaced.track(
|
|
2293
|
+
order_id: 'ORD-123',
|
|
2294
|
+
amount: 10000,
|
|
2295
|
+
currency: 'USD'
|
|
2296
|
+
)
|
|
2297
|
+
|
|
2298
|
+
Events::PaymentFailed.track(
|
|
2299
|
+
order_id: 'ORD-456',
|
|
2300
|
+
error_message: 'Card declined',
|
|
2301
|
+
exception: StandardError.new('Payment gateway error')
|
|
2302
|
+
)
|
|
2303
|
+
```
|
|
2304
|
+
|
|
2305
|
+
### 13.3. Testing with InMemory Adapter
|
|
2306
|
+
|
|
2307
|
+
```ruby
|
|
2308
|
+
# spec/events/order_placed_spec.rb
|
|
2309
|
+
RSpec.describe Events::OrderPlaced do
|
|
2310
|
+
let(:adapter) { E11y::Adapters::Registry.resolve(:test) }
|
|
2311
|
+
|
|
2312
|
+
before do
|
|
2313
|
+
adapter.clear!
|
|
2314
|
+
end
|
|
2315
|
+
|
|
2316
|
+
it 'tracks order placement' do
|
|
2317
|
+
described_class.track(
|
|
2318
|
+
order_id: 'ORD-123',
|
|
2319
|
+
amount: 10000,
|
|
2320
|
+
currency: 'USD'
|
|
2321
|
+
)
|
|
2322
|
+
|
|
2323
|
+
expect(adapter.event_count).to eq(1)
|
|
2324
|
+
|
|
2325
|
+
event = adapter.events.first
|
|
2326
|
+
expect(event[:event_name]).to eq('order.placed')
|
|
2327
|
+
expect(event[:order_id]).to eq('ORD-123')
|
|
2328
|
+
expect(event[:amount]).to eq(10000)
|
|
2329
|
+
end
|
|
2330
|
+
|
|
2331
|
+
it 'finds events by pattern' do
|
|
2332
|
+
Events::OrderPlaced.track(order_id: 'ORD-1', amount: 100, currency: 'USD')
|
|
2333
|
+
Events::OrderPlaced.track(order_id: 'ORD-2', amount: 200, currency: 'USD')
|
|
2334
|
+
|
|
2335
|
+
order_events = adapter.find_events(/order\.placed/)
|
|
2336
|
+
expect(order_events.size).to eq(2)
|
|
2337
|
+
end
|
|
2338
|
+
end
|
|
2339
|
+
```
|
|
2340
|
+
|
|
2341
|
+
### 13.4. Adapter Capabilities Check
|
|
2342
|
+
|
|
2343
|
+
```ruby
|
|
2344
|
+
# Check adapter capabilities before using
|
|
2345
|
+
loki = E11y::Adapters::Registry.resolve(:loki)
|
|
2346
|
+
puts loki.capabilities
|
|
2347
|
+
# => {
|
|
2348
|
+
# batching: true,
|
|
2349
|
+
# compression: true,
|
|
2350
|
+
# async: true,
|
|
2351
|
+
# streaming: false
|
|
2352
|
+
# }
|
|
2353
|
+
|
|
2354
|
+
# Use batching if supported
|
|
2355
|
+
if loki.capabilities[:batching]
|
|
2356
|
+
events = [event1, event2, event3]
|
|
2357
|
+
loki.write_batch(events)
|
|
2358
|
+
else
|
|
2359
|
+
events.each { |e| loki.write(e) }
|
|
2360
|
+
end
|
|
2361
|
+
```
|
|
2362
|
+
|
|
2363
|
+
### 13.5. Health Checks
|
|
2364
|
+
|
|
2365
|
+
```ruby
|
|
2366
|
+
# config/initializers/health_check.rb
|
|
2367
|
+
HealthCheck.setup do |config|
|
|
2368
|
+
config.add_custom_check('e11y_adapters') do
|
|
2369
|
+
adapters = E11y::Adapters::Registry.all
|
|
2370
|
+
unhealthy = adapters.reject { |name, adapter| adapter.healthy? }
|
|
2371
|
+
|
|
2372
|
+
if unhealthy.any?
|
|
2373
|
+
"Unhealthy adapters: #{unhealthy.keys.join(', ')}"
|
|
2374
|
+
else
|
|
2375
|
+
''
|
|
2376
|
+
end
|
|
2377
|
+
end
|
|
2378
|
+
end
|
|
2379
|
+
```
|
|
2380
|
+
|
|
2381
|
+
---
|
|
2382
|
+
|
|
2383
|
+
**Status:** ✅ Draft Complete (Updated to Unified DSL v1.1.0)
|
|
2384
|
+
**Next:** ADR-006 (Security & Compliance) or ADR-008 (Rails Integration)
|
|
2385
|
+
**Estimated Implementation:** 2 weeks
|