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,1836 @@
|
|
|
1
|
+
# ADR-011: Testing Strategy
|
|
2
|
+
|
|
3
|
+
**Status:** Draft
|
|
4
|
+
**Date:** January 12, 2026
|
|
5
|
+
**Covers:** UC-018 (Testing Events)
|
|
6
|
+
**Depends On:** ADR-001 (Core), ADR-008 (Rails Integration)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 📋 Table of Contents
|
|
11
|
+
|
|
12
|
+
1. [Context & Problem](#1-context--problem)
|
|
13
|
+
2. [Architecture Overview](#2-architecture-overview)
|
|
14
|
+
3. [RSpec Matchers](#3-rspec-matchers)
|
|
15
|
+
4. [Test Adapters](#4-test-adapters)
|
|
16
|
+
5. [Factory Integration](#5-factory-integration)
|
|
17
|
+
6. [Snapshot Testing](#6-snapshot-testing)
|
|
18
|
+
7. [Performance Testing](#7-performance-testing)
|
|
19
|
+
8. [Integration Testing](#8-integration-testing)
|
|
20
|
+
9. [Contract Testing](#9-contract-testing)
|
|
21
|
+
10. [Security & Compliance Testing](#10-security--compliance-testing)
|
|
22
|
+
11. [Trade-offs](#11-trade-offs)
|
|
23
|
+
12. [Complete Example](#12-complete-example)
|
|
24
|
+
13. [Summary & Key Takeaways](#13-summary--key-takeaways)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 1. Context & Problem
|
|
29
|
+
|
|
30
|
+
### 1.1. Problem Statement
|
|
31
|
+
|
|
32
|
+
**Current Pain Points:**
|
|
33
|
+
|
|
34
|
+
1. **No Test Helpers:**
|
|
35
|
+
```ruby
|
|
36
|
+
# ❌ Manual verification, brittle
|
|
37
|
+
it 'tracks order created event' do
|
|
38
|
+
order = create(:order)
|
|
39
|
+
|
|
40
|
+
# How to verify event was tracked?
|
|
41
|
+
# Check logs? Mock adapters? 🤷
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
2. **Adapter Pollution:**
|
|
46
|
+
```ruby
|
|
47
|
+
# ❌ Test events go to real adapters
|
|
48
|
+
RSpec.describe OrdersController do
|
|
49
|
+
it 'creates order' do
|
|
50
|
+
post :create
|
|
51
|
+
# Event sent to Loki, Sentry in test! 😱
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
3. **No Event Assertions:**
|
|
57
|
+
```ruby
|
|
58
|
+
# ❌ Can't verify event payload
|
|
59
|
+
it 'tracks with correct data' do
|
|
60
|
+
Events::OrderCreated.track(order_id: 123)
|
|
61
|
+
|
|
62
|
+
# How to assert payload[:order_id] == 123?
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
4. **Hard to Test Edge Cases:**
|
|
67
|
+
```ruby
|
|
68
|
+
# ❌ How to test retry logic? Circuit breaker?
|
|
69
|
+
it 'retries on failure' do
|
|
70
|
+
# Simulate adapter failure?
|
|
71
|
+
# Verify retry attempts?
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 1.2. Goals
|
|
76
|
+
|
|
77
|
+
**Primary Goals:**
|
|
78
|
+
- ✅ **RSpec matchers** for event assertions
|
|
79
|
+
- ✅ **InMemory test adapter** (zero I/O)
|
|
80
|
+
- ✅ **Factory integration** (FactoryBot)
|
|
81
|
+
- ✅ **Snapshot testing** for payloads
|
|
82
|
+
- ✅ **Performance benchmarks**
|
|
83
|
+
- ✅ **Contract testing** for adapters
|
|
84
|
+
|
|
85
|
+
**Non-Goals:**
|
|
86
|
+
- ❌ Support other test frameworks (Minitest, etc.)
|
|
87
|
+
- ❌ Full mocking of E11y internals
|
|
88
|
+
- ❌ Production-like test environment
|
|
89
|
+
|
|
90
|
+
### 1.3. Success Metrics
|
|
91
|
+
|
|
92
|
+
| Metric | Target | Critical? |
|
|
93
|
+
|--------|--------|-----------|
|
|
94
|
+
| **Test execution speed** | <1ms per event assertion | ✅ Yes |
|
|
95
|
+
| **Setup complexity** | <5 lines in spec_helper | ✅ Yes |
|
|
96
|
+
| **Matcher coverage** | 100% of common patterns | ✅ Yes |
|
|
97
|
+
| **False positive rate** | <1% | ✅ Yes |
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 2. Architecture Overview
|
|
102
|
+
|
|
103
|
+
### 2.1. System Context
|
|
104
|
+
|
|
105
|
+
```mermaid
|
|
106
|
+
C4Context
|
|
107
|
+
title Testing Strategy Context
|
|
108
|
+
|
|
109
|
+
Person(dev, "Developer", "Writes tests")
|
|
110
|
+
|
|
111
|
+
System(rspec, "RSpec Suite", "Test suite")
|
|
112
|
+
|
|
113
|
+
System(e11y, "E11y Gem", "Event tracking")
|
|
114
|
+
|
|
115
|
+
System_Ext(test_adapter, "InMemory Adapter", "Test-only adapter")
|
|
116
|
+
System_Ext(factories, "FactoryBot", "Test data")
|
|
117
|
+
|
|
118
|
+
Rel(dev, rspec, "Runs tests", "bundle exec rspec")
|
|
119
|
+
Rel(rspec, e11y, "Uses matchers", "expect().to have_tracked_event")
|
|
120
|
+
Rel(e11y, test_adapter, "Stores events", "In-memory array")
|
|
121
|
+
Rel(rspec, factories, "Creates test data", "create(:order)")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 2.2. Component Architecture
|
|
125
|
+
|
|
126
|
+
```mermaid
|
|
127
|
+
graph TB
|
|
128
|
+
subgraph "RSpec Test Suite"
|
|
129
|
+
TestCase[Test Case] --> Matcher[E11y RSpec Matcher]
|
|
130
|
+
TestCase --> Factory[FactoryBot]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
subgraph "E11y Testing Module"
|
|
134
|
+
Matcher --> TestAdapter[InMemory Test Adapter]
|
|
135
|
+
Matcher --> Assertions[Assertion Helpers]
|
|
136
|
+
|
|
137
|
+
TestAdapter --> EventStore[Event Store<br/>In-Memory Array]
|
|
138
|
+
|
|
139
|
+
Assertions --> PayloadMatcher[Payload Matcher]
|
|
140
|
+
Assertions --> SeverityMatcher[Severity Matcher]
|
|
141
|
+
Assertions --> CountMatcher[Count Matcher]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
subgraph "E11y Core"
|
|
145
|
+
EventStore --> Pipeline[E11y Pipeline]
|
|
146
|
+
Pipeline --> EventClass[Event Classes]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
subgraph "Test Helpers"
|
|
150
|
+
Factory --> EventFactory[Event Factories]
|
|
151
|
+
Factory --> TraceFactory[Trace Context Factory]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
style TestAdapter fill:#d1ecf1
|
|
155
|
+
style Matcher fill:#fff3cd
|
|
156
|
+
style EventStore fill:#d4edda
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### 2.3. Test Flow
|
|
160
|
+
|
|
161
|
+
```mermaid
|
|
162
|
+
sequenceDiagram
|
|
163
|
+
participant Test as RSpec Test
|
|
164
|
+
participant Matcher as E11y Matcher
|
|
165
|
+
participant Adapter as InMemory Adapter
|
|
166
|
+
participant App as Application Code
|
|
167
|
+
|
|
168
|
+
Note over Test: Setup phase
|
|
169
|
+
Test->>Test: before(:each)
|
|
170
|
+
Test->>Adapter: Clear event store
|
|
171
|
+
|
|
172
|
+
Note over Test: Exercise phase
|
|
173
|
+
Test->>App: Trigger action
|
|
174
|
+
App->>E11y: Track event
|
|
175
|
+
E11y->>Adapter: Store event (in-memory)
|
|
176
|
+
|
|
177
|
+
Note over Test: Verify phase
|
|
178
|
+
Test->>Matcher: expect().to have_tracked_event
|
|
179
|
+
Matcher->>Adapter: Query event store
|
|
180
|
+
Adapter-->>Matcher: Return matching events
|
|
181
|
+
Matcher->>Matcher: Assert payload/severity/count
|
|
182
|
+
Matcher-->>Test: Pass/Fail
|
|
183
|
+
|
|
184
|
+
Note over Test: Teardown phase
|
|
185
|
+
Test->>Adapter: Clear event store
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 3. RSpec Matchers
|
|
191
|
+
|
|
192
|
+
### 3.1. Core Matchers
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
# lib/e11y/testing/rspec_matchers.rb
|
|
196
|
+
module E11y
|
|
197
|
+
module Testing
|
|
198
|
+
module RSpecMatchers
|
|
199
|
+
# Main matcher: have_tracked_event
|
|
200
|
+
def have_tracked_event(event_class_or_pattern)
|
|
201
|
+
HaveTrackedEventMatcher.new(event_class_or_pattern)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Alias for readability
|
|
205
|
+
alias_method :track_event, :have_tracked_event
|
|
206
|
+
|
|
207
|
+
class HaveTrackedEventMatcher
|
|
208
|
+
def initialize(event_class_or_pattern)
|
|
209
|
+
@event_class_or_pattern = event_class_or_pattern
|
|
210
|
+
@payload_matchers = {}
|
|
211
|
+
@severity_matcher = nil
|
|
212
|
+
@count_matcher = nil
|
|
213
|
+
@trace_id_matcher = nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Chain: with payload
|
|
217
|
+
def with(payload_hash)
|
|
218
|
+
@payload_matchers = payload_hash
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Chain: with severity
|
|
223
|
+
def with_severity(severity)
|
|
224
|
+
@severity_matcher = severity
|
|
225
|
+
self
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Chain: exactly N times
|
|
229
|
+
def exactly(count)
|
|
230
|
+
@count_matcher = count
|
|
231
|
+
self
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Chain: at_least N times
|
|
235
|
+
def at_least(count)
|
|
236
|
+
@count_matcher = [:at_least, count]
|
|
237
|
+
self
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Chain: at_most N times
|
|
241
|
+
def at_most(count)
|
|
242
|
+
@count_matcher = [:at_most, count]
|
|
243
|
+
self
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Chain: once
|
|
247
|
+
def once
|
|
248
|
+
exactly(1)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Chain: twice
|
|
252
|
+
def twice
|
|
253
|
+
exactly(2)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Chain: with trace_id
|
|
257
|
+
def with_trace_id(trace_id)
|
|
258
|
+
@trace_id_matcher = trace_id
|
|
259
|
+
self
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Matcher implementation
|
|
263
|
+
def matches?(actual = nil)
|
|
264
|
+
@events = find_matching_events
|
|
265
|
+
|
|
266
|
+
return false if @events.empty?
|
|
267
|
+
return false unless count_matches?
|
|
268
|
+
return false unless payload_matches?
|
|
269
|
+
return false unless severity_matches?
|
|
270
|
+
return false unless trace_id_matches?
|
|
271
|
+
|
|
272
|
+
true
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def failure_message
|
|
276
|
+
if @events.empty?
|
|
277
|
+
no_events_message
|
|
278
|
+
elsif !count_matches?
|
|
279
|
+
count_mismatch_message
|
|
280
|
+
elsif !payload_matches?
|
|
281
|
+
payload_mismatch_message
|
|
282
|
+
elsif !severity_matches?
|
|
283
|
+
severity_mismatch_message
|
|
284
|
+
else
|
|
285
|
+
trace_id_mismatch_message
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def failure_message_when_negated
|
|
290
|
+
"expected not to have tracked #{event_name}, but it was tracked"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def supports_block_expectations?
|
|
294
|
+
true
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
private
|
|
298
|
+
|
|
299
|
+
def find_matching_events
|
|
300
|
+
E11y.test_adapter.find_events(event_pattern)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def event_pattern
|
|
304
|
+
case @event_class_or_pattern
|
|
305
|
+
when Class
|
|
306
|
+
@event_class_or_pattern.event_name
|
|
307
|
+
when String, Regexp
|
|
308
|
+
@event_class_or_pattern
|
|
309
|
+
else
|
|
310
|
+
raise ArgumentError, "Invalid event pattern: #{@event_class_or_pattern}"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def event_name
|
|
315
|
+
case @event_class_or_pattern
|
|
316
|
+
when Class
|
|
317
|
+
@event_class_or_pattern.name
|
|
318
|
+
else
|
|
319
|
+
@event_class_or_pattern.to_s
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def count_matches?
|
|
324
|
+
return true unless @count_matcher
|
|
325
|
+
|
|
326
|
+
case @count_matcher
|
|
327
|
+
when Integer
|
|
328
|
+
@events.size == @count_matcher
|
|
329
|
+
when Array
|
|
330
|
+
operator, expected = @count_matcher
|
|
331
|
+
case operator
|
|
332
|
+
when :at_least
|
|
333
|
+
@events.size >= expected
|
|
334
|
+
when :at_most
|
|
335
|
+
@events.size <= expected
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def payload_matches?
|
|
341
|
+
return true if @payload_matchers.empty?
|
|
342
|
+
|
|
343
|
+
@events.any? do |event|
|
|
344
|
+
@payload_matchers.all? do |key, expected_value|
|
|
345
|
+
actual_value = event[:payload][key]
|
|
346
|
+
|
|
347
|
+
case expected_value
|
|
348
|
+
when RSpec::Matchers::BuiltIn::BaseMatcher
|
|
349
|
+
expected_value.matches?(actual_value)
|
|
350
|
+
when Proc
|
|
351
|
+
expected_value.call(actual_value)
|
|
352
|
+
else
|
|
353
|
+
actual_value == expected_value
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def severity_matches?
|
|
360
|
+
return true unless @severity_matcher
|
|
361
|
+
|
|
362
|
+
@events.any? { |event| event[:severity] == @severity_matcher }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def trace_id_matches?
|
|
366
|
+
return true unless @trace_id_matcher
|
|
367
|
+
|
|
368
|
+
@events.any? { |event| event[:trace_id] == @trace_id_matcher }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def no_events_message
|
|
372
|
+
all_events = E11y.test_adapter.all_events
|
|
373
|
+
|
|
374
|
+
if all_events.empty?
|
|
375
|
+
"expected to have tracked #{event_name}, but no events were tracked at all"
|
|
376
|
+
else
|
|
377
|
+
tracked_names = all_events.map { |e| e[:event_name] }.uniq.join(', ')
|
|
378
|
+
"expected to have tracked #{event_name}, but only tracked: #{tracked_names}"
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def count_mismatch_message
|
|
383
|
+
expected_count = case @count_matcher
|
|
384
|
+
when Integer
|
|
385
|
+
"exactly #{@count_matcher}"
|
|
386
|
+
when Array
|
|
387
|
+
operator, count = @count_matcher
|
|
388
|
+
"#{operator.to_s.gsub('_', ' ')} #{count}"
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
"expected to track #{event_name} #{expected_count} times, but tracked #{@events.size} times"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def payload_mismatch_message
|
|
395
|
+
"expected #{event_name} with payload #{@payload_matchers.inspect}, " \
|
|
396
|
+
"but got:\n#{format_events(@events)}"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def severity_mismatch_message
|
|
400
|
+
severities = @events.map { |e| e[:severity] }.uniq.join(', ')
|
|
401
|
+
"expected #{event_name} with severity :#{@severity_matcher}, " \
|
|
402
|
+
"but got severities: #{severities}"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def trace_id_mismatch_message
|
|
406
|
+
trace_ids = @events.map { |e| e[:trace_id] }.uniq.join(', ')
|
|
407
|
+
"expected #{event_name} with trace_id #{@trace_id_matcher}, " \
|
|
408
|
+
"but got trace_ids: #{trace_ids}"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def format_events(events)
|
|
412
|
+
events.map.with_index do |event, index|
|
|
413
|
+
" [#{index + 1}] #{event[:payload].inspect}"
|
|
414
|
+
end.join("\n")
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### 3.2. Usage Examples
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
RSpec.describe OrdersController, type: :controller do
|
|
426
|
+
describe 'POST #create' do
|
|
427
|
+
it 'tracks order created event' do
|
|
428
|
+
expect {
|
|
429
|
+
post :create, params: { order: { item: 'Book', price: 29.99 } }
|
|
430
|
+
}.to have_tracked_event(Events::OrderCreated)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
it 'tracks with correct payload' do
|
|
434
|
+
post :create, params: { order: { item: 'Book', price: 29.99 } }
|
|
435
|
+
|
|
436
|
+
expect(Events::OrderCreated).to have_been_tracked
|
|
437
|
+
.with(item: 'Book', price: 29.99)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
it 'tracks with correct severity' do
|
|
441
|
+
post :create
|
|
442
|
+
|
|
443
|
+
expect(Events::OrderCreated).to have_been_tracked
|
|
444
|
+
.with_severity(:info)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
it 'tracks exactly once' do
|
|
448
|
+
post :create
|
|
449
|
+
|
|
450
|
+
expect(Events::OrderCreated).to have_been_tracked.once
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
it 'tracks multiple times' do
|
|
454
|
+
3.times { post :create }
|
|
455
|
+
|
|
456
|
+
expect(Events::OrderCreated).to have_been_tracked
|
|
457
|
+
.exactly(3)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
it 'uses RSpec matchers in payload' do
|
|
461
|
+
post :create, params: { order: { price: 29.99 } }
|
|
462
|
+
|
|
463
|
+
expect(Events::OrderCreated).to have_been_tracked
|
|
464
|
+
.with(
|
|
465
|
+
order_id: be_a(Integer),
|
|
466
|
+
price: be_within(0.01).of(29.99),
|
|
467
|
+
created_at: be_a(Time)
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
it 'uses custom matchers' do
|
|
472
|
+
post :create
|
|
473
|
+
|
|
474
|
+
expect(Events::OrderCreated).to have_been_tracked
|
|
475
|
+
.with(
|
|
476
|
+
amount: ->(val) { val > 0 }
|
|
477
|
+
)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
it 'verifies trace context' do
|
|
481
|
+
post :create
|
|
482
|
+
|
|
483
|
+
expect(Events::OrderCreated).to have_been_tracked
|
|
484
|
+
.with_trace_id(E11y::Current.trace_id)
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## 4. Test Adapters
|
|
493
|
+
|
|
494
|
+
### 4.1. InMemory Adapter
|
|
495
|
+
|
|
496
|
+
```ruby
|
|
497
|
+
# lib/e11y/adapters/in_memory.rb
|
|
498
|
+
module E11y
|
|
499
|
+
module Adapters
|
|
500
|
+
class InMemory < Base
|
|
501
|
+
def initialize
|
|
502
|
+
super(name: :test)
|
|
503
|
+
@events = []
|
|
504
|
+
@mutex = Mutex.new
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def send_batch(events)
|
|
508
|
+
@mutex.synchronize do
|
|
509
|
+
events.each do |event|
|
|
510
|
+
@events << event.dup
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
{ success: true, sent: events.size }
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def health_check
|
|
518
|
+
true
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Test helper methods
|
|
522
|
+
def all_events
|
|
523
|
+
@mutex.synchronize { @events.dup }
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def find_events(pattern)
|
|
527
|
+
@mutex.synchronize do
|
|
528
|
+
@events.select do |event|
|
|
529
|
+
case pattern
|
|
530
|
+
when String
|
|
531
|
+
event[:event_name] == pattern
|
|
532
|
+
when Regexp
|
|
533
|
+
event[:event_name] =~ pattern
|
|
534
|
+
when Class
|
|
535
|
+
event[:event_name] == pattern.event_name
|
|
536
|
+
else
|
|
537
|
+
false
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def find_event(pattern)
|
|
544
|
+
find_events(pattern).first
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def event_count(pattern = nil)
|
|
548
|
+
if pattern
|
|
549
|
+
find_events(pattern).size
|
|
550
|
+
else
|
|
551
|
+
@mutex.synchronize { @events.size }
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def clear!
|
|
556
|
+
@mutex.synchronize { @events.clear }
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def tracked?(event_class_or_pattern)
|
|
560
|
+
find_events(event_class_or_pattern).any?
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Pretty print for debugging
|
|
564
|
+
def inspect
|
|
565
|
+
"#<E11y::Adapters::InMemory events=#{@events.size}>"
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def to_s
|
|
569
|
+
inspect
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### 4.2. Mock Adapter (For Failure Testing)
|
|
577
|
+
|
|
578
|
+
```ruby
|
|
579
|
+
# lib/e11y/adapters/mock.rb
|
|
580
|
+
module E11y
|
|
581
|
+
module Adapters
|
|
582
|
+
class Mock < Base
|
|
583
|
+
attr_accessor :should_fail, :failure_error, :delay_ms
|
|
584
|
+
|
|
585
|
+
def initialize(config = {})
|
|
586
|
+
super(name: :mock)
|
|
587
|
+
@should_fail = config[:should_fail] || false
|
|
588
|
+
@failure_error = config[:failure_error] || StandardError.new('Mock failure')
|
|
589
|
+
@delay_ms = config[:delay_ms] || 0
|
|
590
|
+
@call_count = 0
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def send_batch(events)
|
|
594
|
+
@call_count += 1
|
|
595
|
+
|
|
596
|
+
# Simulate delay
|
|
597
|
+
sleep(@delay_ms / 1000.0) if @delay_ms > 0
|
|
598
|
+
|
|
599
|
+
# Simulate failure
|
|
600
|
+
raise @failure_error if @should_fail
|
|
601
|
+
|
|
602
|
+
{ success: true, sent: events.size }
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def health_check
|
|
606
|
+
!@should_fail
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def call_count
|
|
610
|
+
@call_count
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def reset!
|
|
614
|
+
@call_count = 0
|
|
615
|
+
@should_fail = false
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
### 4.3. RSpec Configuration
|
|
623
|
+
|
|
624
|
+
```ruby
|
|
625
|
+
# spec/support/e11y.rb
|
|
626
|
+
RSpec.configure do |config|
|
|
627
|
+
# Use InMemory adapter for all tests
|
|
628
|
+
config.before(:suite) do
|
|
629
|
+
E11y.configure do |e11y_config|
|
|
630
|
+
# Clear all adapters
|
|
631
|
+
e11y_config.adapters.clear
|
|
632
|
+
|
|
633
|
+
# Register only test adapter
|
|
634
|
+
e11y_config.adapters.register :test, E11y::Adapters::InMemory.new
|
|
635
|
+
|
|
636
|
+
# Disable rate limiting in tests
|
|
637
|
+
e11y_config.rate_limiting.enabled = false
|
|
638
|
+
|
|
639
|
+
# Disable sampling (track everything)
|
|
640
|
+
e11y_config.sampling.default_sample_rate = 1.0
|
|
641
|
+
|
|
642
|
+
# Fast buffer flush (no delays)
|
|
643
|
+
e11y_config.buffer.flush_interval = 0.milliseconds
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Clear events between tests
|
|
648
|
+
config.before(:each) do
|
|
649
|
+
E11y.test_adapter.clear!
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Reset trace context
|
|
653
|
+
config.after(:each) do
|
|
654
|
+
E11y::Current.reset
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Include matchers
|
|
658
|
+
config.include E11y::Testing::RSpecMatchers
|
|
659
|
+
|
|
660
|
+
# Alias for convenience
|
|
661
|
+
config.alias_example_group_to :describe_event, type: :event
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Convenience method to access test adapter
|
|
665
|
+
module E11y
|
|
666
|
+
def self.test_adapter
|
|
667
|
+
Adapters::Registry.get(:test)
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## 5. Factory Integration
|
|
675
|
+
|
|
676
|
+
### 5.1. Event Factories
|
|
677
|
+
|
|
678
|
+
```ruby
|
|
679
|
+
# spec/factories/events.rb
|
|
680
|
+
FactoryBot.define do
|
|
681
|
+
# Base event factory
|
|
682
|
+
factory :event, class: Hash do
|
|
683
|
+
transient do
|
|
684
|
+
event_class { Events::Generic }
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
event_name { event_class.event_name }
|
|
688
|
+
severity { :info }
|
|
689
|
+
timestamp { Time.now }
|
|
690
|
+
trace_id { E11y::TraceContext.generate_id }
|
|
691
|
+
payload { {} }
|
|
692
|
+
|
|
693
|
+
initialize_with { attributes }
|
|
694
|
+
|
|
695
|
+
# Specific event factories
|
|
696
|
+
factory :order_created_event do
|
|
697
|
+
event_class { Events::OrderCreated }
|
|
698
|
+
payload do
|
|
699
|
+
{
|
|
700
|
+
order_id: Faker::Number.number(digits: 10),
|
|
701
|
+
user_id: Faker::Number.number(digits: 5),
|
|
702
|
+
amount: Faker::Commerce.price,
|
|
703
|
+
currency: 'USD'
|
|
704
|
+
}
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
factory :payment_processed_event do
|
|
709
|
+
event_class { Events::PaymentProcessed }
|
|
710
|
+
severity { :success }
|
|
711
|
+
payload do
|
|
712
|
+
{
|
|
713
|
+
payment_id: Faker::Alphanumeric.alphanumeric(number: 20),
|
|
714
|
+
order_id: Faker::Number.number(digits: 10),
|
|
715
|
+
amount: Faker::Commerce.price,
|
|
716
|
+
status: 'completed'
|
|
717
|
+
}
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
factory :error_event do
|
|
722
|
+
event_class { Events::ErrorOccurred }
|
|
723
|
+
severity { :error }
|
|
724
|
+
payload do
|
|
725
|
+
{
|
|
726
|
+
error_class: 'StandardError',
|
|
727
|
+
error_message: Faker::Lorem.sentence,
|
|
728
|
+
backtrace: Faker::Lorem.paragraphs(number: 3)
|
|
729
|
+
}
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# Trace context factory
|
|
735
|
+
factory :trace_context, class: Hash do
|
|
736
|
+
trace_id { E11y::TraceContext.generate_id }
|
|
737
|
+
span_id { E11y::TraceContext.generate_span_id }
|
|
738
|
+
sampled { true }
|
|
739
|
+
user_id { Faker::Number.number(digits: 5) }
|
|
740
|
+
|
|
741
|
+
initialize_with { attributes }
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
### 5.2. Usage with Factories
|
|
747
|
+
|
|
748
|
+
```ruby
|
|
749
|
+
RSpec.describe 'Event tracking with factories' do
|
|
750
|
+
it 'builds event payload' do
|
|
751
|
+
payload = build(:order_created_event)[:payload]
|
|
752
|
+
|
|
753
|
+
Events::OrderCreated.track(payload)
|
|
754
|
+
|
|
755
|
+
expect(Events::OrderCreated).to have_been_tracked
|
|
756
|
+
.with(order_id: payload[:order_id])
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
it 'sets up trace context' do
|
|
760
|
+
context = build(:trace_context)
|
|
761
|
+
|
|
762
|
+
E11y::Current.set(context)
|
|
763
|
+
|
|
764
|
+
Events::OrderCreated.track(order_id: 123)
|
|
765
|
+
|
|
766
|
+
expect(Events::OrderCreated).to have_been_tracked
|
|
767
|
+
.with_trace_id(context[:trace_id])
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
---
|
|
773
|
+
|
|
774
|
+
## 6. Snapshot Testing
|
|
775
|
+
|
|
776
|
+
### 6.1. Snapshot Matcher
|
|
777
|
+
|
|
778
|
+
```ruby
|
|
779
|
+
# lib/e11y/testing/snapshot_matcher.rb
|
|
780
|
+
module E11y
|
|
781
|
+
module Testing
|
|
782
|
+
module SnapshotMatcher
|
|
783
|
+
def match_snapshot(snapshot_name)
|
|
784
|
+
SnapshotMatcher.new(snapshot_name)
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
class SnapshotMatcher
|
|
788
|
+
def initialize(snapshot_name)
|
|
789
|
+
@snapshot_name = snapshot_name
|
|
790
|
+
@snapshot_dir = Rails.root.join('spec', 'snapshots', 'events')
|
|
791
|
+
@snapshot_file = @snapshot_dir.join("#{@snapshot_name}.yml")
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def matches?(actual)
|
|
795
|
+
@actual = normalize_event(actual)
|
|
796
|
+
|
|
797
|
+
if File.exist?(@snapshot_file)
|
|
798
|
+
@expected = YAML.load_file(@snapshot_file)
|
|
799
|
+
@actual == @expected
|
|
800
|
+
else
|
|
801
|
+
# First run: save snapshot
|
|
802
|
+
save_snapshot(@actual)
|
|
803
|
+
true
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def failure_message
|
|
808
|
+
"Expected event to match snapshot #{@snapshot_name}\n\n" \
|
|
809
|
+
"Expected:\n#{@expected.to_yaml}\n\n" \
|
|
810
|
+
"Actual:\n#{@actual.to_yaml}\n\n" \
|
|
811
|
+
"Run UPDATE_SNAPSHOTS=1 rspec to update snapshot"
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
private
|
|
815
|
+
|
|
816
|
+
def normalize_event(event)
|
|
817
|
+
event = event.deep_symbolize_keys if event.respond_to?(:deep_symbolize_keys)
|
|
818
|
+
|
|
819
|
+
# Remove volatile fields
|
|
820
|
+
event.except(:timestamp, :trace_id, :span_id)
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
def save_snapshot(data)
|
|
824
|
+
FileUtils.mkdir_p(@snapshot_dir)
|
|
825
|
+
File.write(@snapshot_file, data.to_yaml)
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
### 6.2. Snapshot Usage
|
|
834
|
+
|
|
835
|
+
```ruby
|
|
836
|
+
RSpec.describe Events::OrderCreated do
|
|
837
|
+
it 'matches expected structure' do
|
|
838
|
+
Events::OrderCreated.track(
|
|
839
|
+
order_id: 123,
|
|
840
|
+
user_id: 456,
|
|
841
|
+
amount: 99.99,
|
|
842
|
+
currency: 'USD'
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
event = E11y.test_adapter.find_event(Events::OrderCreated)
|
|
846
|
+
|
|
847
|
+
expect(event).to match_snapshot('order_created_event')
|
|
848
|
+
end
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# First run: creates spec/snapshots/events/order_created_event.yml
|
|
852
|
+
# Subsequent runs: compares against saved snapshot
|
|
853
|
+
# Update snapshots: UPDATE_SNAPSHOTS=1 bundle exec rspec
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
---
|
|
857
|
+
|
|
858
|
+
## 7. Performance Testing
|
|
859
|
+
|
|
860
|
+
### 7.1. Performance Benchmarks
|
|
861
|
+
|
|
862
|
+
```ruby
|
|
863
|
+
# spec/performance/event_tracking_spec.rb
|
|
864
|
+
RSpec.describe 'Event tracking performance', type: :performance do
|
|
865
|
+
it 'tracks 1000 events in < 100ms' do
|
|
866
|
+
elapsed = Benchmark.realtime do
|
|
867
|
+
1000.times do |i|
|
|
868
|
+
Events::OrderCreated.track(order_id: i)
|
|
869
|
+
end
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
expect(elapsed).to be < 0.1 # 100ms
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
it 'handles concurrent tracking' do
|
|
876
|
+
threads = 10.times.map do |thread_id|
|
|
877
|
+
Thread.new do
|
|
878
|
+
100.times do |i|
|
|
879
|
+
Events::OrderCreated.track(
|
|
880
|
+
order_id: "#{thread_id}-#{i}"
|
|
881
|
+
)
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
threads.each(&:join)
|
|
887
|
+
|
|
888
|
+
expect(E11y.test_adapter.event_count).to eq(1000)
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
it 'has low memory overhead' do
|
|
892
|
+
before_memory = memory_usage
|
|
893
|
+
|
|
894
|
+
1000.times { Events::OrderCreated.track(order_id: 1) }
|
|
895
|
+
|
|
896
|
+
after_memory = memory_usage
|
|
897
|
+
memory_increase = after_memory - before_memory
|
|
898
|
+
|
|
899
|
+
# Should use < 1MB for 1000 events
|
|
900
|
+
expect(memory_increase).to be < 1.megabyte
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
private
|
|
904
|
+
|
|
905
|
+
def memory_usage
|
|
906
|
+
`ps -o rss= -p #{Process.pid}`.to_i * 1024 # bytes
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
### 7.2. Benchmark Helpers
|
|
912
|
+
|
|
913
|
+
```ruby
|
|
914
|
+
# spec/support/benchmark_helpers.rb
|
|
915
|
+
module BenchmarkHelpers
|
|
916
|
+
def benchmark(label = nil, &block)
|
|
917
|
+
label ||= caller_locations(1, 1).first.label
|
|
918
|
+
|
|
919
|
+
result = nil
|
|
920
|
+
elapsed = Benchmark.realtime { result = block.call }
|
|
921
|
+
|
|
922
|
+
puts "#{label}: #{(elapsed * 1000).round(2)}ms"
|
|
923
|
+
|
|
924
|
+
result
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def profile_memory(&block)
|
|
928
|
+
before = memory_usage
|
|
929
|
+
result = block.call
|
|
930
|
+
after = memory_usage
|
|
931
|
+
|
|
932
|
+
increase = ((after - before) / 1024.0).round(2)
|
|
933
|
+
puts "Memory increase: #{increase} KB"
|
|
934
|
+
|
|
935
|
+
result
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
RSpec.configure do |config|
|
|
940
|
+
config.include BenchmarkHelpers, type: :performance
|
|
941
|
+
end
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
---
|
|
945
|
+
|
|
946
|
+
## 8. Integration Testing
|
|
947
|
+
|
|
948
|
+
### 8.1. Full Stack Integration Tests
|
|
949
|
+
|
|
950
|
+
```ruby
|
|
951
|
+
# spec/integration/order_flow_spec.rb
|
|
952
|
+
RSpec.describe 'Order flow integration', type: :integration do
|
|
953
|
+
it 'tracks all events in order creation flow' do
|
|
954
|
+
user = create(:user)
|
|
955
|
+
|
|
956
|
+
# Simulate full order flow
|
|
957
|
+
order = nil
|
|
958
|
+
|
|
959
|
+
expect {
|
|
960
|
+
order = create_order_as(user, items: [{ name: 'Book', price: 29.99 }])
|
|
961
|
+
}.to have_tracked_event(Events::OrderCreated)
|
|
962
|
+
.and have_tracked_event(Events::PaymentProcessed)
|
|
963
|
+
.and have_tracked_event(Events::EmailQueued)
|
|
964
|
+
|
|
965
|
+
# Verify event sequence
|
|
966
|
+
events = E11y.test_adapter.all_events
|
|
967
|
+
event_names = events.map { |e| e[:event_name] }
|
|
968
|
+
|
|
969
|
+
expect(event_names).to eq([
|
|
970
|
+
'Events::OrderCreated',
|
|
971
|
+
'Events::PaymentProcessed',
|
|
972
|
+
'Events::EmailQueued'
|
|
973
|
+
])
|
|
974
|
+
|
|
975
|
+
# Verify trace context propagation
|
|
976
|
+
trace_ids = events.map { |e| e[:trace_id] }.uniq
|
|
977
|
+
expect(trace_ids.size).to eq(1) # All events share same trace_id
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
it 'propagates trace to background jobs' do
|
|
981
|
+
order = create(:order)
|
|
982
|
+
|
|
983
|
+
expect {
|
|
984
|
+
SendOrderEmailJob.perform_later(order.id)
|
|
985
|
+
}.to have_tracked_event(Events::Rails::Job::Enqueued)
|
|
986
|
+
|
|
987
|
+
# Execute job
|
|
988
|
+
perform_enqueued_jobs
|
|
989
|
+
|
|
990
|
+
# Verify job tracked event with same trace_id
|
|
991
|
+
enqueued_event = E11y.test_adapter.find_event(Events::Rails::Job::Enqueued)
|
|
992
|
+
completed_event = E11y.test_adapter.find_event(Events::Rails::Job::Completed)
|
|
993
|
+
|
|
994
|
+
expect(completed_event[:trace_id]).to eq(enqueued_event[:trace_id])
|
|
995
|
+
end
|
|
996
|
+
end
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
### 8.2. Adapter Integration Tests
|
|
1000
|
+
|
|
1001
|
+
```ruby
|
|
1002
|
+
# spec/integration/adapters_spec.rb
|
|
1003
|
+
RSpec.describe 'Adapter integration', type: :integration do
|
|
1004
|
+
it 'sends events to multiple adapters' do
|
|
1005
|
+
# Reconfigure with multiple test adapters
|
|
1006
|
+
adapter1 = E11y::Adapters::InMemory.new
|
|
1007
|
+
adapter2 = E11y::Adapters::InMemory.new
|
|
1008
|
+
|
|
1009
|
+
E11y.configure do |config|
|
|
1010
|
+
config.adapters.clear
|
|
1011
|
+
config.adapters.register :adapter1, adapter1
|
|
1012
|
+
config.adapters.register :adapter2, adapter2
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
Events::OrderCreated.track(order_id: 123)
|
|
1016
|
+
|
|
1017
|
+
# Verify both adapters received event
|
|
1018
|
+
expect(adapter1.event_count).to eq(1)
|
|
1019
|
+
expect(adapter2.event_count).to eq(1)
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
it 'handles adapter failures gracefully' do
|
|
1023
|
+
failing_adapter = E11y::Adapters::Mock.new(should_fail: true)
|
|
1024
|
+
working_adapter = E11y::Adapters::InMemory.new
|
|
1025
|
+
|
|
1026
|
+
E11y.configure do |config|
|
|
1027
|
+
config.adapters.clear
|
|
1028
|
+
config.adapters.register :failing, failing_adapter
|
|
1029
|
+
config.adapters.register :working, working_adapter
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
# Should not raise, working adapter should receive event
|
|
1033
|
+
expect {
|
|
1034
|
+
Events::OrderCreated.track(order_id: 123)
|
|
1035
|
+
}.not_to raise_error
|
|
1036
|
+
|
|
1037
|
+
expect(working_adapter.event_count).to eq(1)
|
|
1038
|
+
end
|
|
1039
|
+
end
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
---
|
|
1043
|
+
|
|
1044
|
+
## 9. Contract Testing
|
|
1045
|
+
|
|
1046
|
+
### 9.1. Adapter Contract Tests
|
|
1047
|
+
|
|
1048
|
+
```ruby
|
|
1049
|
+
# spec/contracts/adapter_contract_spec.rb
|
|
1050
|
+
RSpec.shared_examples 'an E11y adapter' do
|
|
1051
|
+
let(:adapter) { described_class.new }
|
|
1052
|
+
|
|
1053
|
+
describe '#send_batch' do
|
|
1054
|
+
it 'accepts array of events' do
|
|
1055
|
+
events = [build(:event)]
|
|
1056
|
+
|
|
1057
|
+
expect { adapter.send_batch(events) }.not_to raise_error
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
it 'returns success hash' do
|
|
1061
|
+
events = [build(:event)]
|
|
1062
|
+
result = adapter.send_batch(events)
|
|
1063
|
+
|
|
1064
|
+
expect(result).to be_a(Hash)
|
|
1065
|
+
expect(result).to have_key(:success)
|
|
1066
|
+
expect(result[:success]).to be_in([true, false])
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
it 'handles empty batch' do
|
|
1070
|
+
expect { adapter.send_batch([]) }.not_to raise_error
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
it 'raises on invalid input' do
|
|
1074
|
+
expect { adapter.send_batch(nil) }.to raise_error(ArgumentError)
|
|
1075
|
+
end
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
describe '#health_check' do
|
|
1079
|
+
it 'returns boolean' do
|
|
1080
|
+
result = adapter.health_check
|
|
1081
|
+
expect(result).to be_in([true, false])
|
|
1082
|
+
end
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
describe '#capabilities' do
|
|
1086
|
+
it 'returns array of symbols' do
|
|
1087
|
+
capabilities = adapter.capabilities
|
|
1088
|
+
|
|
1089
|
+
expect(capabilities).to be_an(Array)
|
|
1090
|
+
expect(capabilities).to all(be_a(Symbol))
|
|
1091
|
+
end
|
|
1092
|
+
end
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
# Use in adapter specs
|
|
1096
|
+
RSpec.describe E11y::Adapters::Loki do
|
|
1097
|
+
it_behaves_like 'an E11y adapter'
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
RSpec.describe E11y::Adapters::Sentry do
|
|
1101
|
+
it_behaves_like 'an E11y adapter'
|
|
1102
|
+
end
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
### 9.2. Event Contract Tests
|
|
1106
|
+
|
|
1107
|
+
```ruby
|
|
1108
|
+
# spec/contracts/event_contract_spec.rb
|
|
1109
|
+
RSpec.shared_examples 'an E11y event' do
|
|
1110
|
+
describe '.track' do
|
|
1111
|
+
it 'accepts payload hash' do
|
|
1112
|
+
expect { described_class.track({}) }.not_to raise_error
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
it 'validates schema' do
|
|
1116
|
+
invalid_payload = { invalid_key: 'value' }
|
|
1117
|
+
|
|
1118
|
+
expect {
|
|
1119
|
+
described_class.track(invalid_payload)
|
|
1120
|
+
}.to raise_error(E11y::SchemaValidationError)
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
it 'returns nil' do
|
|
1124
|
+
result = described_class.track(valid_payload)
|
|
1125
|
+
expect(result).to be_nil
|
|
1126
|
+
end
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1129
|
+
describe '.event_name' do
|
|
1130
|
+
it 'returns string' do
|
|
1131
|
+
expect(described_class.event_name).to be_a(String)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
it 'matches class name pattern' do
|
|
1135
|
+
expect(described_class.event_name).to match(/^Events::/)
|
|
1136
|
+
end
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
describe '.schema' do
|
|
1140
|
+
it 'is defined' do
|
|
1141
|
+
expect(described_class.schema).to be_present
|
|
1142
|
+
end
|
|
1143
|
+
end
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
# Use in event specs
|
|
1147
|
+
RSpec.describe Events::OrderCreated do
|
|
1148
|
+
let(:valid_payload) do
|
|
1149
|
+
{
|
|
1150
|
+
order_id: 123,
|
|
1151
|
+
user_id: 456,
|
|
1152
|
+
amount: 99.99
|
|
1153
|
+
}
|
|
1154
|
+
end
|
|
1155
|
+
|
|
1156
|
+
it_behaves_like 'an E11y event'
|
|
1157
|
+
end
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
---
|
|
1161
|
+
|
|
1162
|
+
## 10. Security & Compliance Testing
|
|
1163
|
+
|
|
1164
|
+
### 10.1. Problem: Critical Features Need User Verification
|
|
1165
|
+
|
|
1166
|
+
**Why This Matters:**
|
|
1167
|
+
|
|
1168
|
+
```ruby
|
|
1169
|
+
# ❌ GDPR violation if PII filtering broken:
|
|
1170
|
+
# Company: $50M fine for data breach
|
|
1171
|
+
# Reputation: Destroyed
|
|
1172
|
+
|
|
1173
|
+
# ❌ Cost overrun if routing broken:
|
|
1174
|
+
# Expected: $100/month (debug → file)
|
|
1175
|
+
# Actual: $10,000/month (debug → Sentry)
|
|
1176
|
+
|
|
1177
|
+
# Users MUST be able to verify these features work!
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
### 10.2. Architecture: Multiple InMemory Adapters
|
|
1181
|
+
|
|
1182
|
+
**Design Decision:** Use separate `InMemory` adapters per destination to verify:
|
|
1183
|
+
1. ✅ PII filtering per-adapter
|
|
1184
|
+
2. ✅ Event routing to correct adapters
|
|
1185
|
+
3. ✅ Rate limiting behavior
|
|
1186
|
+
4. ✅ Sampling behavior
|
|
1187
|
+
|
|
1188
|
+
```mermaid
|
|
1189
|
+
graph TB
|
|
1190
|
+
subgraph "Test Setup"
|
|
1191
|
+
TestCase[RSpec Test] --> Config[E11y Config]
|
|
1192
|
+
Config --> Register1[Register :sentry adapter]
|
|
1193
|
+
Config --> Register2[Register :file_audit adapter]
|
|
1194
|
+
Config --> Register3[Register :file adapter]
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
subgraph "Event Tracking"
|
|
1198
|
+
TestCase --> TrackEvent[Track Event]
|
|
1199
|
+
TrackEvent --> Pipeline[E11y Pipeline]
|
|
1200
|
+
Pipeline --> PiiFilter[PII Filtering]
|
|
1201
|
+
PiiFilter --> Router[Adapter Router]
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1204
|
+
subgraph "Separate InMemory Adapters"
|
|
1205
|
+
Router --> SentryAdapter[InMemory :sentry<br/>Email: HASHED<br/>Password: MASKED]
|
|
1206
|
+
Router --> AuditAdapter[InMemory :file_audit<br/>Email: ORIGINAL<br/>Password: MASKED]
|
|
1207
|
+
Router --> FileAdapter[InMemory :file<br/>No event]
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
subgraph "Verification Phase"
|
|
1211
|
+
TestCase --> VerifySentry[Assert Sentry got hashed email]
|
|
1212
|
+
TestCase --> VerifyAudit[Assert Audit got original email]
|
|
1213
|
+
TestCase --> VerifyFile[Assert File got nothing]
|
|
1214
|
+
|
|
1215
|
+
VerifySentry --> SentryAdapter
|
|
1216
|
+
VerifyAudit --> AuditAdapter
|
|
1217
|
+
VerifyFile --> FileAdapter
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
style PiiFilter fill:#f8d7da
|
|
1221
|
+
style Router fill:#fff3cd
|
|
1222
|
+
style SentryAdapter fill:#d1ecf1
|
|
1223
|
+
style AuditAdapter fill:#d1ecf1
|
|
1224
|
+
style FileAdapter fill:#d1ecf1
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
### 10.3. PII Filtering Test Helpers
|
|
1228
|
+
|
|
1229
|
+
```ruby
|
|
1230
|
+
# lib/e11y/testing/pii_helpers.rb
|
|
1231
|
+
module E11y
|
|
1232
|
+
module Testing
|
|
1233
|
+
module PiiHelpers
|
|
1234
|
+
# Verify field was filtered with specific strategy
|
|
1235
|
+
def have_field_filtered(field, strategy, options = {})
|
|
1236
|
+
PiiFilterMatcher.new(field, strategy, options)
|
|
1237
|
+
end
|
|
1238
|
+
|
|
1239
|
+
class PiiFilterMatcher
|
|
1240
|
+
def initialize(field, strategy, options)
|
|
1241
|
+
@field = field
|
|
1242
|
+
@strategy = strategy
|
|
1243
|
+
@adapter = options[:for_adapter]
|
|
1244
|
+
@algorithm = options[:algorithm]
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
def matches?(event)
|
|
1248
|
+
@actual_value = event.dig(:payload, @field)
|
|
1249
|
+
|
|
1250
|
+
case @strategy
|
|
1251
|
+
when :mask
|
|
1252
|
+
@actual_value == '[FILTERED]'
|
|
1253
|
+
when :hash
|
|
1254
|
+
verify_hash
|
|
1255
|
+
when :skip
|
|
1256
|
+
!event[:payload].key?(@field)
|
|
1257
|
+
when :truncate
|
|
1258
|
+
@actual_value.length <= (event[:metadata][:pii_truncate_length] || 100)
|
|
1259
|
+
when :redact
|
|
1260
|
+
@actual_value == '[REDACTED]'
|
|
1261
|
+
else
|
|
1262
|
+
false
|
|
1263
|
+
end
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
def failure_message
|
|
1267
|
+
"expected field '#{@field}' to be filtered with strategy :#{@strategy}, " \
|
|
1268
|
+
"but got: #{@actual_value.inspect}"
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
def failure_message_when_negated
|
|
1272
|
+
"expected field '#{@field}' NOT to be filtered with :#{@strategy}, " \
|
|
1273
|
+
"but it was"
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
private
|
|
1277
|
+
|
|
1278
|
+
def verify_hash
|
|
1279
|
+
return false unless @actual_value.is_a?(String)
|
|
1280
|
+
|
|
1281
|
+
case @algorithm || :sha256
|
|
1282
|
+
when :sha256
|
|
1283
|
+
@actual_value =~ /^[a-f0-9]{64}$/
|
|
1284
|
+
when :md5
|
|
1285
|
+
@actual_value =~ /^[a-f0-9]{32}$/
|
|
1286
|
+
else
|
|
1287
|
+
false
|
|
1288
|
+
end
|
|
1289
|
+
end
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
# Verify event was sent to specific adapters only
|
|
1293
|
+
def be_routed_to(*adapter_names)
|
|
1294
|
+
AdapterRoutingMatcher.new(adapter_names)
|
|
1295
|
+
end
|
|
1296
|
+
|
|
1297
|
+
class AdapterRoutingMatcher
|
|
1298
|
+
def initialize(expected_adapters)
|
|
1299
|
+
@expected_adapters = expected_adapters.map(&:to_sym).sort
|
|
1300
|
+
@only = false
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
def only
|
|
1304
|
+
@only = true
|
|
1305
|
+
self
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
def matches?(event_class)
|
|
1309
|
+
@actual_adapters = event_class.adapters.sort
|
|
1310
|
+
|
|
1311
|
+
if @only
|
|
1312
|
+
@actual_adapters == @expected_adapters
|
|
1313
|
+
else
|
|
1314
|
+
(@expected_adapters - @actual_adapters).empty?
|
|
1315
|
+
end
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
def failure_message
|
|
1319
|
+
if @only
|
|
1320
|
+
"expected event to be routed to ONLY #{@expected_adapters.inspect}, " \
|
|
1321
|
+
"but configured for: #{@actual_adapters.inspect}"
|
|
1322
|
+
else
|
|
1323
|
+
"expected event to be routed to #{@expected_adapters.inspect}, " \
|
|
1324
|
+
"but only configured for: #{@actual_adapters.inspect}"
|
|
1325
|
+
end
|
|
1326
|
+
end
|
|
1327
|
+
end
|
|
1328
|
+
|
|
1329
|
+
# Helper to setup multiple test adapters
|
|
1330
|
+
def setup_test_adapters(*adapter_names)
|
|
1331
|
+
adapters = {}
|
|
1332
|
+
|
|
1333
|
+
adapter_names.each do |name|
|
|
1334
|
+
adapters[name] = E11y::Adapters::InMemory.new
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
E11y.configure do |config|
|
|
1338
|
+
config.adapters.clear
|
|
1339
|
+
adapters.each do |name, adapter|
|
|
1340
|
+
config.adapters.register name, adapter
|
|
1341
|
+
end
|
|
1342
|
+
end
|
|
1343
|
+
|
|
1344
|
+
adapters
|
|
1345
|
+
end
|
|
1346
|
+
end
|
|
1347
|
+
end
|
|
1348
|
+
end
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
### 10.4. PII Filtering Test Examples
|
|
1352
|
+
|
|
1353
|
+
```ruby
|
|
1354
|
+
# spec/events/user_login_spec.rb
|
|
1355
|
+
RSpec.describe Events::UserLogin, type: :event do
|
|
1356
|
+
# Setup separate adapters to verify per-adapter PII filtering
|
|
1357
|
+
let(:adapters) { setup_test_adapters(:sentry, :file_audit, :file) }
|
|
1358
|
+
|
|
1359
|
+
describe 'PII filtering' do
|
|
1360
|
+
context 'password field' do
|
|
1361
|
+
it 'masks password for ALL adapters' do
|
|
1362
|
+
Events::UserLogin.track(
|
|
1363
|
+
email: 'user@example.com',
|
|
1364
|
+
password: 'secret123',
|
|
1365
|
+
ip_address: '192.168.1.1'
|
|
1366
|
+
)
|
|
1367
|
+
|
|
1368
|
+
# ✅ Sentry: password masked
|
|
1369
|
+
sentry_event = adapters[:sentry].find_event(Events::UserLogin)
|
|
1370
|
+
expect(sentry_event).to have_field_filtered(:password, :mask)
|
|
1371
|
+
|
|
1372
|
+
# ✅ Audit: password masked (sensitive data)
|
|
1373
|
+
audit_event = adapters[:file_audit].find_event(Events::UserLogin)
|
|
1374
|
+
expect(audit_event).to have_field_filtered(:password, :mask)
|
|
1375
|
+
|
|
1376
|
+
# ✅ File: password masked
|
|
1377
|
+
file_event = adapters[:file].find_event(Events::UserLogin)
|
|
1378
|
+
expect(file_event).to have_field_filtered(:password, :mask)
|
|
1379
|
+
end
|
|
1380
|
+
end
|
|
1381
|
+
|
|
1382
|
+
context 'email field (per-adapter rules)' do
|
|
1383
|
+
it 'hashes email for Sentry, keeps original for audit' do
|
|
1384
|
+
Events::UserLogin.track(
|
|
1385
|
+
email: 'user@example.com',
|
|
1386
|
+
password: 'secret123'
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
# ✅ Sentry: email hashed (compliance + privacy)
|
|
1390
|
+
sentry_event = adapters[:sentry].find_event(Events::UserLogin)
|
|
1391
|
+
expect(sentry_event).to have_field_filtered(:email, :hash, algorithm: :sha256)
|
|
1392
|
+
expect(sentry_event[:payload][:email]).not_to eq('user@example.com')
|
|
1393
|
+
|
|
1394
|
+
# ✅ Audit: email ORIGINAL (for investigation)
|
|
1395
|
+
audit_event = adapters[:file_audit].find_event(Events::UserLogin)
|
|
1396
|
+
expect(audit_event[:payload][:email]).to eq('user@example.com')
|
|
1397
|
+
expect(audit_event).not_to have_field_filtered(:email, :hash)
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1400
|
+
it 'uses consistent hash for same email' do
|
|
1401
|
+
# Track twice with same email
|
|
1402
|
+
2.times do
|
|
1403
|
+
Events::UserLogin.track(
|
|
1404
|
+
email: 'user@example.com',
|
|
1405
|
+
password: 'secret123'
|
|
1406
|
+
)
|
|
1407
|
+
end
|
|
1408
|
+
|
|
1409
|
+
events = adapters[:sentry].find_events(Events::UserLogin)
|
|
1410
|
+
hashes = events.map { |e| e[:payload][:email] }
|
|
1411
|
+
|
|
1412
|
+
# ✅ Same email → same hash (for correlation)
|
|
1413
|
+
expect(hashes.uniq.size).to eq(1)
|
|
1414
|
+
end
|
|
1415
|
+
end
|
|
1416
|
+
|
|
1417
|
+
context 'ip_address field (conditional filtering)' do
|
|
1418
|
+
it 'masks IP for non-admin users, skips for audit' do
|
|
1419
|
+
Events::UserLogin.track(
|
|
1420
|
+
email: 'user@example.com',
|
|
1421
|
+
password: 'secret123',
|
|
1422
|
+
ip_address: '192.168.1.1',
|
|
1423
|
+
is_admin: false
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
# ✅ Sentry: IP masked (PII for regular users)
|
|
1427
|
+
sentry_event = adapters[:sentry].find_event(Events::UserLogin)
|
|
1428
|
+
expect(sentry_event).to have_field_filtered(:ip_address, :mask)
|
|
1429
|
+
|
|
1430
|
+
# ✅ Audit: IP kept (security investigation)
|
|
1431
|
+
audit_event = adapters[:file_audit].find_event(Events::UserLogin)
|
|
1432
|
+
expect(audit_event[:payload][:ip_address]).to eq('192.168.1.1')
|
|
1433
|
+
end
|
|
1434
|
+
end
|
|
1435
|
+
end
|
|
1436
|
+
|
|
1437
|
+
describe 'GDPR compliance' do
|
|
1438
|
+
it 'never leaks PII to non-audit adapters' do
|
|
1439
|
+
Events::UserLogin.track(
|
|
1440
|
+
email: 'user@example.com',
|
|
1441
|
+
password: 'secret123',
|
|
1442
|
+
session_id: 'sess_abc123'
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
sentry_event = adapters[:sentry].find_event(Events::UserLogin)
|
|
1446
|
+
|
|
1447
|
+
# ✅ No plain-text PII in Sentry
|
|
1448
|
+
expect(sentry_event[:payload].values).not_to include('user@example.com')
|
|
1449
|
+
expect(sentry_event[:payload].values).not_to include('secret123')
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1452
|
+
it 'logs PII filtering activity (for audit trail)' do
|
|
1453
|
+
Events::UserLogin.track(
|
|
1454
|
+
email: 'user@example.com',
|
|
1455
|
+
password: 'secret123'
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
# ✅ Metadata tracks PII filtering
|
|
1459
|
+
sentry_event = adapters[:sentry].find_event(Events::UserLogin)
|
|
1460
|
+
expect(sentry_event[:metadata][:pii_filtered]).to be true
|
|
1461
|
+
expect(sentry_event[:metadata][:pii_fields]).to include(:email, :password)
|
|
1462
|
+
end
|
|
1463
|
+
end
|
|
1464
|
+
end
|
|
1465
|
+
```
|
|
1466
|
+
|
|
1467
|
+
### 10.5. Adapter Routing Test Examples
|
|
1468
|
+
|
|
1469
|
+
```ruby
|
|
1470
|
+
# spec/events/routing_spec.rb
|
|
1471
|
+
RSpec.describe 'Event routing', type: :integration do
|
|
1472
|
+
let(:adapters) { setup_test_adapters(:sentry, :loki, :file, :pagerduty) }
|
|
1473
|
+
|
|
1474
|
+
describe Events::DebugQuery do
|
|
1475
|
+
it 'routes ONLY to file adapter (cost optimization)' do
|
|
1476
|
+
Events::DebugQuery.track(sql: 'SELECT * FROM users')
|
|
1477
|
+
|
|
1478
|
+
# ✅ File got event (cheap)
|
|
1479
|
+
expect(adapters[:file].event_count).to eq(1)
|
|
1480
|
+
|
|
1481
|
+
# ✅ Sentry did NOT get event (expensive!)
|
|
1482
|
+
expect(adapters[:sentry].event_count).to eq(0)
|
|
1483
|
+
expect(adapters[:loki].event_count).to eq(0)
|
|
1484
|
+
expect(adapters[:pagerduty].event_count).to eq(0)
|
|
1485
|
+
end
|
|
1486
|
+
|
|
1487
|
+
it 'uses routing matcher for cleaner assertions' do
|
|
1488
|
+
expect(Events::DebugQuery).to be_routed_to(:file).only
|
|
1489
|
+
end
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
describe Events::PaymentFraudDetected do
|
|
1493
|
+
it 'routes to critical adapters (Sentry, PagerDuty, Audit)' do
|
|
1494
|
+
Events::PaymentFraudDetected.track(
|
|
1495
|
+
transaction_id: 'tx_123',
|
|
1496
|
+
fraud_score: 0.95
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
# ✅ Critical adapters got event
|
|
1500
|
+
expect(adapters[:sentry].event_count).to eq(1)
|
|
1501
|
+
expect(adapters[:pagerduty].event_count).to eq(1)
|
|
1502
|
+
|
|
1503
|
+
# ✅ Low-priority adapters did NOT get event
|
|
1504
|
+
expect(adapters[:file].event_count).to eq(0)
|
|
1505
|
+
end
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
describe Events::HealthCheckPassed do
|
|
1509
|
+
it 'routes NOWHERE (excluded from all adapters)' do
|
|
1510
|
+
Events::HealthCheckPassed.track
|
|
1511
|
+
|
|
1512
|
+
# ✅ No adapters got event (noise reduction)
|
|
1513
|
+
expect(adapters.values.sum(&:event_count)).to eq(0)
|
|
1514
|
+
end
|
|
1515
|
+
end
|
|
1516
|
+
|
|
1517
|
+
context 'cost verification' do
|
|
1518
|
+
it 'prevents expensive routing mistakes' do
|
|
1519
|
+
# Track 1000 debug events
|
|
1520
|
+
1000.times { Events::DebugQuery.track(sql: 'SELECT 1') }
|
|
1521
|
+
|
|
1522
|
+
# ✅ If routed to Sentry: $10/1000 events = $10 cost
|
|
1523
|
+
# ✅ If routed to File: $0 cost
|
|
1524
|
+
expect(adapters[:sentry].event_count).to eq(0),
|
|
1525
|
+
'Debug events leaked to Sentry! Cost impact: ~$10'
|
|
1526
|
+
end
|
|
1527
|
+
end
|
|
1528
|
+
end
|
|
1529
|
+
```
|
|
1530
|
+
|
|
1531
|
+
### 10.6. Rate Limiting Test Examples
|
|
1532
|
+
|
|
1533
|
+
```ruby
|
|
1534
|
+
# spec/features/rate_limiting_spec.rb
|
|
1535
|
+
RSpec.describe 'Rate limiting', type: :integration do
|
|
1536
|
+
let(:adapters) { setup_test_adapters(:file) }
|
|
1537
|
+
|
|
1538
|
+
before do
|
|
1539
|
+
E11y.configure do |config|
|
|
1540
|
+
config.rate_limiting.enabled = true
|
|
1541
|
+
config.rate_limiting.global_limit = 10 # 10 events/sec
|
|
1542
|
+
end
|
|
1543
|
+
end
|
|
1544
|
+
|
|
1545
|
+
it 'drops events exceeding global rate limit' do
|
|
1546
|
+
# Track 15 events (exceeds limit of 10)
|
|
1547
|
+
15.times { Events::DebugQuery.track(sql: 'SELECT 1') }
|
|
1548
|
+
|
|
1549
|
+
# ✅ Only 10 events tracked
|
|
1550
|
+
expect(adapters[:file].event_count).to eq(10)
|
|
1551
|
+
|
|
1552
|
+
# ✅ 5 events dropped
|
|
1553
|
+
expect(E11y::Metrics.get('e11y.rate_limit.dropped_total')).to eq(5)
|
|
1554
|
+
end
|
|
1555
|
+
|
|
1556
|
+
it 'never drops critical events (bypass rate limit)' do
|
|
1557
|
+
E11y.config.rate_limiting.global_limit = 1 # Extremely low
|
|
1558
|
+
|
|
1559
|
+
# Track 10 critical events
|
|
1560
|
+
10.times { Events::PaymentFailed.track(payment_id: 'p123') }
|
|
1561
|
+
|
|
1562
|
+
# ✅ ALL critical events tracked (bypass)
|
|
1563
|
+
expect(adapters[:file].event_count).to eq(10)
|
|
1564
|
+
end
|
|
1565
|
+
|
|
1566
|
+
it 'applies per-event rate limits' do
|
|
1567
|
+
E11y.config.rate_limiting.per_event_limits = {
|
|
1568
|
+
'Events::DebugQuery' => 5 # Max 5 per second
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
10.times { Events::DebugQuery.track(sql: 'SELECT 1') }
|
|
1572
|
+
|
|
1573
|
+
# ✅ Only 5 DebugQuery events tracked
|
|
1574
|
+
debug_events = adapters[:file].find_events(Events::DebugQuery)
|
|
1575
|
+
expect(debug_events.size).to eq(5)
|
|
1576
|
+
end
|
|
1577
|
+
end
|
|
1578
|
+
```
|
|
1579
|
+
|
|
1580
|
+
### 10.7. Sampling Test Examples
|
|
1581
|
+
|
|
1582
|
+
```ruby
|
|
1583
|
+
# spec/features/sampling_spec.rb
|
|
1584
|
+
RSpec.describe 'Adaptive sampling', type: :integration do
|
|
1585
|
+
let(:adapters) { setup_test_adapters(:file) }
|
|
1586
|
+
|
|
1587
|
+
before do
|
|
1588
|
+
E11y.configure do |config|
|
|
1589
|
+
config.sampling.enabled = true
|
|
1590
|
+
config.sampling.default_sample_rate = 0.1 # 10%
|
|
1591
|
+
end
|
|
1592
|
+
end
|
|
1593
|
+
|
|
1594
|
+
it 'samples debug events at configured rate' do
|
|
1595
|
+
# Track 1000 debug events
|
|
1596
|
+
1000.times { Events::DebugQuery.track(sql: 'SELECT 1') }
|
|
1597
|
+
|
|
1598
|
+
# ✅ ~100 events tracked (10% ± margin of error)
|
|
1599
|
+
expect(adapters[:file].event_count).to be_within(30).of(100)
|
|
1600
|
+
end
|
|
1601
|
+
|
|
1602
|
+
it 'NEVER samples error/fatal events (always 100%)' do
|
|
1603
|
+
E11y.config.sampling.default_sample_rate = 0.01 # 1%
|
|
1604
|
+
|
|
1605
|
+
# Track 100 error events
|
|
1606
|
+
100.times { Events::ErrorOccurred.track(error: 'Boom!') }
|
|
1607
|
+
|
|
1608
|
+
# ✅ ALL 100 error events tracked (no sampling)
|
|
1609
|
+
expect(adapters[:file].event_count).to eq(100)
|
|
1610
|
+
end
|
|
1611
|
+
|
|
1612
|
+
it 'NEVER samples audit events (compliance requirement)' do
|
|
1613
|
+
E11y.config.sampling.default_sample_rate = 0.01 # 1%
|
|
1614
|
+
|
|
1615
|
+
# Track 50 audit events
|
|
1616
|
+
50.times { Events::GdprDeletion.audit(user_id: 123) }
|
|
1617
|
+
|
|
1618
|
+
# ✅ ALL 50 audit events tracked (never sampled)
|
|
1619
|
+
expect(adapters[:file].event_count).to eq(50)
|
|
1620
|
+
end
|
|
1621
|
+
|
|
1622
|
+
it 'uses trace-consistent sampling (same trace_id)' do
|
|
1623
|
+
E11y::Current.set(trace_id: 'trace-123')
|
|
1624
|
+
|
|
1625
|
+
# Track 100 events with same trace_id
|
|
1626
|
+
sampled = 0
|
|
1627
|
+
100.times do
|
|
1628
|
+
Events::DebugQuery.track(sql: 'SELECT 1')
|
|
1629
|
+
sampled += 1 if adapters[:file].event_count > 0
|
|
1630
|
+
adapters[:file].clear! # Reset for next iteration
|
|
1631
|
+
end
|
|
1632
|
+
|
|
1633
|
+
# ✅ Either ALL sampled or NONE sampled (consistent)
|
|
1634
|
+
expect([0, 100]).to include(sampled)
|
|
1635
|
+
end
|
|
1636
|
+
end
|
|
1637
|
+
```
|
|
1638
|
+
|
|
1639
|
+
### 10.8. Configuration for Security Tests
|
|
1640
|
+
|
|
1641
|
+
```ruby
|
|
1642
|
+
# spec/support/e11y_security.rb
|
|
1643
|
+
RSpec.configure do |config|
|
|
1644
|
+
# Include PII helpers
|
|
1645
|
+
config.include E11y::Testing::PiiHelpers, type: :event
|
|
1646
|
+
config.include E11y::Testing::PiiHelpers, type: :integration
|
|
1647
|
+
|
|
1648
|
+
# Global before hook for security tests
|
|
1649
|
+
config.before(:each, type: :event) do
|
|
1650
|
+
# Enable strict PII validation in tests
|
|
1651
|
+
E11y.configure do |e11y_config|
|
|
1652
|
+
e11y_config.pii.strict_mode = true # Fail on unfiltered PII
|
|
1653
|
+
e11y_config.pii.validate_on_track = true # Validate immediately
|
|
1654
|
+
end
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1657
|
+
# Verify no PII leaks in ANY test (global check)
|
|
1658
|
+
config.after(:each) do
|
|
1659
|
+
next unless E11y.config.pii.strict_mode
|
|
1660
|
+
|
|
1661
|
+
# Get all events from all adapters
|
|
1662
|
+
all_events = E11y.config.adapters.all.flat_map do |adapter|
|
|
1663
|
+
adapter.respond_to?(:all_events) ? adapter.all_events : []
|
|
1664
|
+
end
|
|
1665
|
+
|
|
1666
|
+
# Verify no plain-text PII (if event declares contains_pii)
|
|
1667
|
+
all_events.each do |event|
|
|
1668
|
+
event_class = E11y::Registry.get(event[:event_name])
|
|
1669
|
+
|
|
1670
|
+
if event_class&.contains_pii?
|
|
1671
|
+
# Check for common PII patterns
|
|
1672
|
+
payload_str = event[:payload].values.join(' ')
|
|
1673
|
+
|
|
1674
|
+
# ❌ Email pattern
|
|
1675
|
+
if payload_str =~ /@.+\./
|
|
1676
|
+
raise "SECURITY: Plain-text email detected in #{event[:event_name]}"
|
|
1677
|
+
end
|
|
1678
|
+
|
|
1679
|
+
# ❌ Credit card pattern
|
|
1680
|
+
if payload_str =~ /\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}/
|
|
1681
|
+
raise "SECURITY: Credit card detected in #{event[:event_name]}"
|
|
1682
|
+
end
|
|
1683
|
+
end
|
|
1684
|
+
end
|
|
1685
|
+
end
|
|
1686
|
+
end
|
|
1687
|
+
```
|
|
1688
|
+
|
|
1689
|
+
### 10.9. Trade-offs: Security Testing
|
|
1690
|
+
|
|
1691
|
+
| Decision | Pro | Con | Rationale |
|
|
1692
|
+
|----------|-----|-----|-----------|
|
|
1693
|
+
| **Multiple InMemory adapters** | Real per-adapter verification | More test setup | Critical for PII/routing |
|
|
1694
|
+
| **Strict mode in tests** | Catches PII leaks early | Slower tests | Security > speed |
|
|
1695
|
+
| **Per-adapter assertions** | Precise verification | Verbose tests | Compliance requirement |
|
|
1696
|
+
| **Global PII leak detector** | Safety net | False positives | Better safe than sorry |
|
|
1697
|
+
| **Routing matchers** | Prevents cost overruns | Extra DSL | Clear intent |
|
|
1698
|
+
|
|
1699
|
+
---
|
|
1700
|
+
|
|
1701
|
+
## 11. Trade-offs
|
|
1702
|
+
|
|
1703
|
+
### 11.1. Key Decisions
|
|
1704
|
+
|
|
1705
|
+
| Decision | Pro | Con | Rationale |
|
|
1706
|
+
|----------|-----|-----|-----------|
|
|
1707
|
+
| **InMemory adapter** | Fast, zero I/O | Not production-like | Test speed > realism |
|
|
1708
|
+
| **RSpec-only** | Deep integration | No Minitest support | Focus on one framework |
|
|
1709
|
+
| **Snapshot testing** | Easy updates | Brittle on schema changes | Opt-in feature |
|
|
1710
|
+
| **No auto-mocking** | Explicit tests | More setup | Clarity > magic |
|
|
1711
|
+
| **Event factories** | Reusable | Extra maintenance | DRY test data |
|
|
1712
|
+
| **Contract tests** | Enforces API | Boilerplate | Adapter quality |
|
|
1713
|
+
|
|
1714
|
+
### 11.2. Alternatives Considered
|
|
1715
|
+
|
|
1716
|
+
**A) VCR for adapter recording**
|
|
1717
|
+
- ❌ Rejected: Too complex for unit tests
|
|
1718
|
+
|
|
1719
|
+
**B) Minitest support**
|
|
1720
|
+
- ❌ Rejected: Limited by time, RSpec is majority
|
|
1721
|
+
|
|
1722
|
+
**C) Automatic mocking**
|
|
1723
|
+
- ❌ Rejected: Hides test intent
|
|
1724
|
+
|
|
1725
|
+
**D) Database-backed test adapter**
|
|
1726
|
+
- ❌ Rejected: Too slow
|
|
1727
|
+
|
|
1728
|
+
**E) Spy pattern (track all calls)**
|
|
1729
|
+
- ✅ Partially adopted: InMemory adapter tracks calls
|
|
1730
|
+
|
|
1731
|
+
---
|
|
1732
|
+
|
|
1733
|
+
## 12. Complete Example
|
|
1734
|
+
|
|
1735
|
+
```ruby
|
|
1736
|
+
# spec/models/order_spec.rb
|
|
1737
|
+
RSpec.describe Order, type: :model do
|
|
1738
|
+
describe '#complete!' do
|
|
1739
|
+
let(:order) { create(:order, status: 'pending') }
|
|
1740
|
+
|
|
1741
|
+
it 'tracks order completed event' do
|
|
1742
|
+
expect {
|
|
1743
|
+
order.complete!
|
|
1744
|
+
}.to have_tracked_event(Events::OrderCompleted)
|
|
1745
|
+
.with(
|
|
1746
|
+
order_id: order.id,
|
|
1747
|
+
total_amount: order.total_amount,
|
|
1748
|
+
items_count: order.items.count
|
|
1749
|
+
)
|
|
1750
|
+
.with_severity(:success)
|
|
1751
|
+
.once
|
|
1752
|
+
end
|
|
1753
|
+
|
|
1754
|
+
it 'includes trace context' do
|
|
1755
|
+
E11y::Current.set(trace_id: 'test-trace-123')
|
|
1756
|
+
|
|
1757
|
+
order.complete!
|
|
1758
|
+
|
|
1759
|
+
expect(Events::OrderCompleted).to have_been_tracked
|
|
1760
|
+
.with_trace_id('test-trace-123')
|
|
1761
|
+
end
|
|
1762
|
+
|
|
1763
|
+
it 'matches expected payload structure' do
|
|
1764
|
+
order.complete!
|
|
1765
|
+
|
|
1766
|
+
event = E11y.test_adapter.find_event(Events::OrderCompleted)
|
|
1767
|
+
|
|
1768
|
+
expect(event[:payload]).to match_snapshot('order_completed')
|
|
1769
|
+
end
|
|
1770
|
+
end
|
|
1771
|
+
end
|
|
1772
|
+
```
|
|
1773
|
+
|
|
1774
|
+
---
|
|
1775
|
+
|
|
1776
|
+
## 13. Summary & Key Takeaways
|
|
1777
|
+
|
|
1778
|
+
### 13.1. What We Achieved
|
|
1779
|
+
|
|
1780
|
+
✅ **Comprehensive testing strategy**: RSpec matchers, test adapters, factories, snapshots
|
|
1781
|
+
✅ **Performance testing**: Benchmarks for <1ms p99 latency
|
|
1782
|
+
✅ **Integration testing**: Full Rails stack with RSpec & factories
|
|
1783
|
+
✅ **Contract testing**: Shared examples for custom adapters
|
|
1784
|
+
✅ **Security & compliance testing**: PII filtering, adapter routing, rate limiting verification
|
|
1785
|
+
|
|
1786
|
+
### 13.2. Critical Testing Priorities
|
|
1787
|
+
|
|
1788
|
+
**Tier 1 (Must Test - Security/Cost Impact):**
|
|
1789
|
+
1. ✅ PII filtering per-adapter (GDPR compliance)
|
|
1790
|
+
2. ✅ Adapter routing (cost optimization)
|
|
1791
|
+
3. ✅ Rate limiting (system protection)
|
|
1792
|
+
4. ✅ Sampling (critical events never dropped)
|
|
1793
|
+
|
|
1794
|
+
**Tier 2 (Should Test - Functionality):**
|
|
1795
|
+
5. ✅ Event tracking & payload validation
|
|
1796
|
+
6. ✅ Trace context propagation
|
|
1797
|
+
7. ✅ Schema validation
|
|
1798
|
+
|
|
1799
|
+
**Tier 3 (Nice to Test - Performance):**
|
|
1800
|
+
8. ✅ Performance benchmarks (<1ms p99)
|
|
1801
|
+
9. ✅ Memory allocation (zero-allocation pattern)
|
|
1802
|
+
|
|
1803
|
+
### 13.3. Testing Architecture Decision
|
|
1804
|
+
|
|
1805
|
+
**✅ Multiple InMemory Adapters** - Winner for security/compliance testing:
|
|
1806
|
+
- ✅ Real per-adapter verification
|
|
1807
|
+
- ✅ Separate event stores per destination
|
|
1808
|
+
- ✅ Verifies PII filtering independently
|
|
1809
|
+
- ✅ Verifies routing correctness
|
|
1810
|
+
- ✅ Simple to setup & use
|
|
1811
|
+
|
|
1812
|
+
### 13.4. Key Testing Helpers
|
|
1813
|
+
|
|
1814
|
+
```ruby
|
|
1815
|
+
# PII filtering verification
|
|
1816
|
+
expect(event).to have_field_filtered(:password, :mask)
|
|
1817
|
+
expect(event).to have_field_filtered(:email, :hash, for_adapter: :sentry)
|
|
1818
|
+
|
|
1819
|
+
# Adapter routing verification
|
|
1820
|
+
expect(Events::DebugQuery).to be_routed_to(:file).only
|
|
1821
|
+
expect(Events::PaymentFailed).to be_routed_to(:sentry, :pagerduty)
|
|
1822
|
+
|
|
1823
|
+
# Setup test adapters
|
|
1824
|
+
adapters = setup_test_adapters(:sentry, :file_audit, :file)
|
|
1825
|
+
```
|
|
1826
|
+
|
|
1827
|
+
---
|
|
1828
|
+
|
|
1829
|
+
**Status:** ✅ Complete (with Security & Compliance Testing)
|
|
1830
|
+
**Next:** Implementation + Integration with CI/CD
|
|
1831
|
+
**Estimated Implementation:** 2 weeks
|
|
1832
|
+
**Impact:**
|
|
1833
|
+
- GDPR compliance verification (critical!)
|
|
1834
|
+
- Cost overrun prevention (routing tests)
|
|
1835
|
+
- Developer confidence (comprehensive testing)
|
|
1836
|
+
- Security audit trail (PII filtering proof)
|