e11y 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +130 -10
  3. data/CHANGELOG.md +56 -1
  4. data/CLAUDE.md +168 -0
  5. data/CONTRIBUTING.md +640 -0
  6. data/README.md +134 -702
  7. data/RELEASE.md +18 -3
  8. data/Rakefile +108 -29
  9. data/config/README.md +1 -1
  10. data/config/loki-local-config.yaml +12 -0
  11. data/config/otel-collector-config.yaml +44 -0
  12. data/cucumber.yml +1 -0
  13. data/docker-compose.yml +18 -2
  14. data/docs/ADAPTERS.md +76 -0
  15. data/docs/ADAPTIVE_SAMPLING.md +59 -0
  16. data/docs/COMPARISON.md +104 -0
  17. data/docs/CONFIGURATION.md +52 -0
  18. data/docs/DISTRIBUTED_TRACING.md +44 -0
  19. data/docs/LIMITATIONS.md +13 -0
  20. data/docs/METRICS_DSL.md +84 -0
  21. data/docs/PERFORMANCE.md +60 -0
  22. data/docs/PII_FILTERING.md +40 -0
  23. data/docs/PRESETS.md +65 -0
  24. data/docs/QUICK-START.md +546 -587
  25. data/docs/RAILS_INTEGRATION.md +29 -0
  26. data/docs/SCHEMA_VALIDATION.md +63 -0
  27. data/docs/SLO-PROMQL-ALERTS.md +161 -0
  28. data/docs/TESTING.md +69 -0
  29. data/docs/{ADR-001-architecture.md → architecture/ADR-001-architecture.md} +35 -64
  30. data/docs/{ADR-002-metrics-yabeda.md → architecture/ADR-002-metrics-yabeda.md} +62 -236
  31. data/docs/{ADR-003-slo-observability.md → architecture/ADR-003-slo-observability.md} +27 -466
  32. data/docs/{ADR-004-adapter-architecture.md → architecture/ADR-004-adapter-architecture.md} +163 -146
  33. data/docs/{ADR-005-tracing-context.md → architecture/ADR-005-tracing-context.md} +10 -9
  34. data/docs/{ADR-006-security-compliance.md → architecture/ADR-006-security-compliance.md} +184 -191
  35. data/docs/{ADR-007-opentelemetry-integration.md → architecture/ADR-007-opentelemetry-integration.md} +3 -21
  36. data/docs/{ADR-008-rails-integration.md → architecture/ADR-008-rails-integration.md} +209 -339
  37. data/docs/{ADR-009-cost-optimization.md → architecture/ADR-009-cost-optimization.md} +45 -54
  38. data/docs/architecture/ADR-010-developer-experience.md +522 -0
  39. data/docs/{ADR-011-testing-strategy.md → architecture/ADR-011-testing-strategy.md} +41 -83
  40. data/docs/{ADR-013-reliability-error-handling.md → architecture/ADR-013-reliability-error-handling.md} +37 -12
  41. data/docs/{ADR-014-event-driven-slo.md → architecture/ADR-014-event-driven-slo.md} +12 -24
  42. data/docs/{ADR-015-middleware-order.md → architecture/ADR-015-middleware-order.md} +23 -41
  43. data/docs/{ADR-016-self-monitoring-slo.md → architecture/ADR-016-self-monitoring-slo.md} +52 -349
  44. data/docs/{ADR-017-multi-rails-compatibility.md → architecture/ADR-017-multi-rails-compatibility.md} +4 -11
  45. data/docs/architecture/ADR-018-memory-optimization.md +366 -0
  46. data/docs/{ADR-INDEX.md → architecture/ADR-INDEX.md} +11 -6
  47. data/docs/{00-ICP-AND-TIMELINE.md → prd/00-ICP-AND-TIMELINE.md} +6 -6
  48. data/docs/{01-SCALE-REQUIREMENTS.md → prd/01-SCALE-REQUIREMENTS.md} +6 -6
  49. data/docs/prd/01-overview-vision.md +19 -14
  50. data/docs/use_cases/README.md +22 -23
  51. data/docs/use_cases/UC-001-request-scoped-debug-buffering.md +50 -44
  52. data/docs/use_cases/UC-002-business-event-tracking.md +26 -95
  53. data/docs/use_cases/UC-003-event-metrics.md +66 -0
  54. data/docs/use_cases/UC-004-zero-config-slo-tracking.md +42 -101
  55. data/docs/use_cases/UC-005-sentry-integration.md +13 -15
  56. data/docs/use_cases/UC-006-trace-context-management.md +30 -28
  57. data/docs/use_cases/UC-007-pii-filtering.md +35 -87
  58. data/docs/use_cases/UC-008-opentelemetry-integration.md +51 -89
  59. data/docs/use_cases/UC-009-multi-service-tracing.md +4 -4
  60. data/docs/use_cases/UC-010-background-job-tracking.md +5 -5
  61. data/docs/use_cases/UC-011-rate-limiting.md +95 -168
  62. data/docs/use_cases/UC-012-audit-trail.md +21 -46
  63. data/docs/use_cases/UC-013-high-cardinality-protection.md +29 -167
  64. data/docs/use_cases/UC-014-adaptive-sampling.md +2 -2
  65. data/docs/use_cases/UC-015-cost-optimization.md +46 -99
  66. data/docs/use_cases/UC-016-rails-logger-migration.md +39 -213
  67. data/docs/use_cases/UC-017-local-development.md +203 -777
  68. data/docs/use_cases/UC-018-testing-events.md +3 -3
  69. data/docs/use_cases/UC-019-retention-based-routing.md +53 -106
  70. data/docs/use_cases/UC-020-event-versioning.md +8 -9
  71. data/docs/use_cases/UC-021-error-handling-retry-dlq.md +18 -22
  72. data/docs/use_cases/UC-022-event-registry.md +15 -21
  73. data/docs/use_cases/backlog.md +119 -87
  74. data/e11y.gemspec +2 -2
  75. data/gems/e11y-devtools/README.md +136 -0
  76. data/gems/e11y-devtools/config/routes.rb +8 -0
  77. data/gems/e11y-devtools/e11y-devtools.gemspec +25 -0
  78. data/gems/e11y-devtools/exe/e11y +34 -0
  79. data/gems/e11y-devtools/lib/e11y/devtools/mcp/server.rb +96 -0
  80. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tool_base.rb +25 -0
  81. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/clear.rb +31 -0
  82. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/errors.rb +35 -0
  83. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/event_detail.rb +33 -0
  84. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/events_by_trace.rb +33 -0
  85. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/interactions.rb +40 -0
  86. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/recent_events.rb +34 -0
  87. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/search.rb +34 -0
  88. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/stats.rb +30 -0
  89. data/gems/e11y-devtools/lib/e11y/devtools/overlay/assets/overlay.js +115 -0
  90. data/gems/e11y-devtools/lib/e11y/devtools/overlay/controller.rb +54 -0
  91. data/gems/e11y-devtools/lib/e11y/devtools/overlay/engine.rb +26 -0
  92. data/gems/e11y-devtools/lib/e11y/devtools/overlay/middleware.rb +80 -0
  93. data/gems/e11y-devtools/lib/e11y/devtools/overlay/rails_controller.rb +42 -0
  94. data/gems/e11y-devtools/lib/e11y/devtools/tui/app.rb +262 -0
  95. data/gems/e11y-devtools/lib/e11y/devtools/tui/grouping.rb +66 -0
  96. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/event_detail.rb +62 -0
  97. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/event_list.rb +70 -0
  98. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/interaction_list.rb +47 -0
  99. data/gems/e11y-devtools/lib/e11y/devtools/version.rb +8 -0
  100. data/gems/e11y-devtools/lib/e11y/devtools.rb +13 -0
  101. data/gems/e11y-devtools/spec/e11y/devtools/mcp/tools_spec.rb +107 -0
  102. data/gems/e11y-devtools/spec/e11y/devtools/overlay/controller_spec.rb +58 -0
  103. data/gems/e11y-devtools/spec/e11y/devtools/overlay/middleware_spec.rb +46 -0
  104. data/gems/e11y-devtools/spec/e11y/devtools/tui/app_spec.rb +85 -0
  105. data/gems/e11y-devtools/spec/e11y/devtools/tui/grouping_spec.rb +64 -0
  106. data/gems/e11y-devtools/spec/spec_helper.rb +5 -0
  107. data/gems/e11y-devtools/spec/tui/widgets/event_list_spec.rb +44 -0
  108. data/gems/e11y-devtools/spec/tui/widgets/interaction_list_spec.rb +62 -0
  109. data/lib/e11y/adapters/audit_encrypted.rb +53 -11
  110. data/lib/e11y/adapters/base.rb +33 -34
  111. data/lib/e11y/adapters/dev_log/file_store.rb +143 -0
  112. data/lib/e11y/adapters/dev_log/query.rb +219 -0
  113. data/lib/e11y/adapters/dev_log.rb +118 -0
  114. data/lib/e11y/adapters/file.rb +3 -6
  115. data/lib/e11y/adapters/in_memory.rb +52 -5
  116. data/lib/e11y/adapters/in_memory_test.rb +29 -0
  117. data/lib/e11y/adapters/loki.rb +58 -23
  118. data/lib/e11y/adapters/null.rb +82 -0
  119. data/lib/e11y/adapters/opentelemetry_collector.rb +183 -0
  120. data/lib/e11y/adapters/otel_logs.rb +136 -23
  121. data/lib/e11y/adapters/sentry.rb +4 -7
  122. data/lib/e11y/adapters/stdout.rb +73 -7
  123. data/lib/e11y/adapters/yabeda.rb +153 -29
  124. data/lib/e11y/buffers/adaptive_buffer.rb +3 -17
  125. data/lib/e11y/buffers/{request_scoped_buffer.rb → ephemeral_buffer.rb} +72 -58
  126. data/lib/e11y/buffers/ring_buffer.rb +3 -16
  127. data/lib/e11y/configuration.rb +272 -0
  128. data/lib/e11y/console.rb +10 -17
  129. data/lib/e11y/current.rb +53 -1
  130. data/lib/e11y/debug/pipeline_inspector.rb +96 -0
  131. data/lib/e11y/documentation/generator.rb +48 -0
  132. data/lib/e11y/event/base.rb +176 -82
  133. data/lib/e11y/event/value_sampling_config.rb +1 -5
  134. data/lib/e11y/events/rails/database/query.rb +1 -4
  135. data/lib/e11y/events/rails/job/failed.rb +2 -0
  136. data/lib/e11y/instruments/active_job.rb +46 -12
  137. data/lib/e11y/instruments/rails_instrumentation.rb +49 -24
  138. data/lib/e11y/instruments/sidekiq.rb +137 -31
  139. data/lib/e11y/linters/base.rb +11 -0
  140. data/lib/e11y/linters/pii/pii_declaration_linter.rb +120 -0
  141. data/lib/e11y/linters/slo/config_consistency_linter.rb +76 -0
  142. data/lib/e11y/linters/slo/explicit_declaration_linter.rb +36 -0
  143. data/lib/e11y/linters/slo/slo_status_from_linter.rb +41 -0
  144. data/lib/e11y/logger/bridge.rb +26 -7
  145. data/lib/e11y/metrics/cardinality_protection.rb +10 -15
  146. data/lib/e11y/metrics/cardinality_tracker.rb +16 -6
  147. data/lib/e11y/metrics/registry.rb +3 -5
  148. data/lib/e11y/metrics/test_backend.rb +62 -0
  149. data/lib/e11y/metrics.rb +56 -10
  150. data/lib/e11y/middleware/adapter_resolver.rb +40 -0
  151. data/lib/e11y/middleware/audit_signing.rb +43 -6
  152. data/lib/e11y/middleware/baggage_protection.rb +75 -0
  153. data/lib/e11y/middleware/dev_log_source.rb +24 -0
  154. data/lib/e11y/middleware/event_slo.rb +23 -9
  155. data/lib/e11y/middleware/otel_span.rb +23 -0
  156. data/lib/e11y/middleware/pii_filter.rb +104 -75
  157. data/lib/e11y/middleware/rate_limiting.rb +54 -27
  158. data/lib/e11y/middleware/request.rb +70 -23
  159. data/lib/e11y/middleware/routing.rb +78 -21
  160. data/lib/e11y/middleware/sampling.rb +66 -17
  161. data/lib/e11y/middleware/self_monitoring_emit.rb +39 -0
  162. data/lib/e11y/middleware/trace_context.rb +45 -10
  163. data/lib/e11y/middleware/track_latency.rb +34 -0
  164. data/lib/e11y/middleware/validation.rb +7 -16
  165. data/lib/e11y/middleware/versioning.rb +26 -22
  166. data/lib/e11y/opentelemetry/semantic_conventions.rb +109 -0
  167. data/lib/e11y/opentelemetry/span_creator.rb +142 -0
  168. data/lib/e11y/pii/patterns.rb +12 -1
  169. data/lib/e11y/pipeline/builder.rb +1 -1
  170. data/lib/e11y/presets/audit_event.rb +13 -2
  171. data/lib/e11y/railtie.rb +52 -15
  172. data/lib/e11y/registry.rb +306 -0
  173. data/lib/e11y/reliability/circuit_breaker.rb +19 -21
  174. data/lib/e11y/reliability/dlq/base.rb +71 -0
  175. data/lib/e11y/reliability/dlq/file_adapter.rb +301 -0
  176. data/lib/e11y/reliability/dlq/file_storage.rb +63 -34
  177. data/lib/e11y/reliability/dlq/filter.rb +37 -54
  178. data/lib/e11y/reliability/retry_handler.rb +26 -29
  179. data/lib/e11y/reliability/retry_rate_limiter.rb +3 -11
  180. data/lib/e11y/sampling/error_spike_detector.rb +0 -2
  181. data/lib/e11y/sampling/load_monitor.rb +5 -9
  182. data/lib/e11y/sampling/stratified_tracker.rb +18 -0
  183. data/lib/e11y/self_monitoring/buffer_monitor.rb +2 -0
  184. data/lib/e11y/self_monitoring/performance_monitor.rb +19 -61
  185. data/lib/e11y/self_monitoring/reliability_monitor.rb +4 -74
  186. data/lib/e11y/slo/config_loader.rb +40 -0
  187. data/lib/e11y/slo/config_validator.rb +58 -0
  188. data/lib/e11y/slo/dashboard_generator.rb +122 -0
  189. data/lib/e11y/slo/event_driven.rb +8 -0
  190. data/lib/e11y/slo/tracker.rb +31 -4
  191. data/lib/e11y/testing/have_tracked_event_matcher.rb +190 -0
  192. data/lib/e11y/testing/rspec_matchers.rb +21 -0
  193. data/lib/e11y/testing/snapshot_matcher.rb +86 -0
  194. data/lib/e11y/trace_context/sampler.rb +35 -0
  195. data/lib/e11y/tracing/faraday_middleware.rb +31 -0
  196. data/lib/e11y/tracing/net_http_patch.rb +33 -0
  197. data/lib/e11y/tracing/propagator.rb +116 -0
  198. data/lib/e11y/tracing.rb +47 -0
  199. data/lib/e11y/version.rb +1 -1
  200. data/lib/e11y/versioning/version_extractor.rb +32 -0
  201. data/lib/e11y.rb +141 -265
  202. data/lib/generators/e11y/event/event_generator.rb +22 -0
  203. data/lib/generators/e11y/event/templates/event.rb.tt +16 -0
  204. data/lib/generators/e11y/grafana_dashboard/grafana_dashboard_generator.rb +30 -0
  205. data/lib/generators/e11y/grafana_dashboard/templates/e11y_dashboard.json +81 -0
  206. data/lib/generators/e11y/install/install_generator.rb +34 -0
  207. data/lib/generators/e11y/install/templates/e11y.rb +239 -0
  208. data/lib/generators/e11y/prometheus_alerts/prometheus_alerts_generator.rb +29 -0
  209. data/lib/generators/e11y/prometheus_alerts/templates/e11y_alerts.yml +28 -0
  210. data/lib/tasks/e11y_docs.rake +30 -0
  211. data/lib/tasks/e11y_events.rake +71 -0
  212. data/lib/tasks/e11y_lint.rake +91 -0
  213. data/lib/tasks/e11y_slo.rake +29 -0
  214. metadata +129 -39
  215. data/docs/ADR-010-developer-experience.md +0 -2166
  216. data/docs/API-REFERENCE-L28.md +0 -914
  217. data/docs/COMPREHENSIVE-CONFIGURATION.md +0 -2366
  218. data/docs/CONTRIBUTING.md +0 -312
  219. data/docs/IMPLEMENTATION_NOTES.md +0 -2804
  220. data/docs/IMPLEMENTATION_PLAN.md +0 -1971
  221. data/docs/IMPLEMENTATION_PLAN_ARCHITECTURE.md +0 -586
  222. data/docs/PLAN.md +0 -148
  223. data/docs/README.md +0 -296
  224. data/docs/design/00-memory-optimization.md +0 -593
  225. data/docs/guides/MIGRATION-L27-L28.md +0 -692
  226. data/docs/guides/PERFORMANCE-BENCHMARKS.md +0 -434
  227. data/docs/guides/README.md +0 -44
  228. data/docs/use_cases/UC-003-pattern-based-metrics.md +0 -1627
  229. data/lib/e11y/adapters/registry.rb +0 -141
  230. /data/docs/{ADR-012-event-evolution.md → architecture/ADR-012-event-evolution.md} +0 -0
@@ -17,7 +17,7 @@ module E11y
17
17
  #
18
18
  # @example Enable SLO tracking
19
19
  # E11y.configure do |config|
20
- # config.slo_tracking.enabled = true
20
+ # config.slo_tracking_enabled = true
21
21
  # end
22
22
  #
23
23
  # @example Track HTTP request
@@ -28,10 +28,31 @@ module E11y
28
28
  # duration_ms: 42.5
29
29
  # )
30
30
  #
31
- # @note C11 Resolution (Sampling Correction): Not yet implemented.
32
- # Requires Phase 2.8 (Stratified Sampling) for accurate SLO with sampling.
31
+ # @note C11 Resolution: Event-driven SLO (EventSlo middleware) applies stratified
32
+ # sampling correction via E11y::Sampling.stratified_tracker. HTTP/job SLO
33
+ # are tracked directly (no sampling) and need no correction.
33
34
  module Tracker
35
+ # In-memory store for tracked request data (per endpoint).
36
+ # @api private Intended for test assertions only; not part of public API.
37
+ @_store = {}
38
+
34
39
  class << self
40
+ # Return a snapshot of all tracked endpoints and their request counts.
41
+ # @api private Intended for test assertions only.
42
+ #
43
+ # @return [Hash] Map of "controller#action" => { requests: Integer }
44
+ def status
45
+ @_store.dup
46
+ end
47
+
48
+ # Reset the in-memory store (useful for testing and per-request isolation).
49
+ #
50
+ # @return [void]
51
+ # @api private
52
+ def reset!
53
+ @_store = {}
54
+ end
55
+
35
56
  # Track HTTP request for SLO metrics.
36
57
  #
37
58
  # @param controller [String] Controller name
@@ -58,6 +79,12 @@ module E11y
58
79
  labels.except(:status),
59
80
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
60
81
  )
82
+
83
+ # Store in-memory for status reporting
84
+ key = "#{controller}##{action}"
85
+ @_store ||= {}
86
+ @_store[key] ||= { requests: 0 }
87
+ @_store[key][:requests] += 1
61
88
  end
62
89
 
63
90
  # Track background job for SLO metrics.
@@ -94,7 +121,7 @@ module E11y
94
121
  #
95
122
  # @return [Boolean] true if enabled
96
123
  def enabled?
97
- E11y.config.respond_to?(:slo_tracking) && E11y.config.slo_tracking&.enabled
124
+ E11y.config.respond_to?(:slo_tracking_enabled) && E11y.config.slo_tracking_enabled
98
125
  end
99
126
 
100
127
  # Normalize HTTP status code to category (2xx, 3xx, 4xx, 5xx).
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Testing
5
+ # RSpec matcher for asserting that an event was tracked during block execution.
6
+ #
7
+ # @example Basic usage
8
+ # expect { OrdersController.create }.to have_tracked_event(Events::OrderCreated)
9
+ #
10
+ # @example With payload
11
+ # expect { action }.to have_tracked_event(Events::OrderPaid).with(order_id: 123)
12
+ #
13
+ # @example With count
14
+ # expect { action }.to have_tracked_event(Events::OrderPaid).once
15
+ # rubocop:disable Metrics/ClassLength
16
+ class HaveTrackedEventMatcher
17
+ def initialize(event_class_or_pattern)
18
+ @event_class_or_pattern = event_class_or_pattern
19
+ @payload_matchers = {}
20
+ @severity_matcher = nil
21
+ @count_matcher = nil
22
+ @trace_id_matcher = nil
23
+ end
24
+
25
+ def with(payload_hash)
26
+ @payload_matchers = payload_hash
27
+ self
28
+ end
29
+
30
+ def with_severity(severity)
31
+ @severity_matcher = severity
32
+ self
33
+ end
34
+
35
+ def exactly(count)
36
+ @count_matcher = count
37
+ self
38
+ end
39
+
40
+ def at_least(count)
41
+ @count_matcher = [:at_least, count]
42
+ self
43
+ end
44
+
45
+ def at_most(count)
46
+ @count_matcher = [:at_most, count]
47
+ self
48
+ end
49
+
50
+ def once
51
+ exactly(1)
52
+ end
53
+
54
+ def twice
55
+ exactly(2)
56
+ end
57
+
58
+ def with_trace_id(trace_id)
59
+ @trace_id_matcher = trace_id
60
+ self
61
+ end
62
+
63
+ def matches?(actual = nil)
64
+ actual.call if actual.respond_to?(:call) # Execute block before checking events
65
+ @events = find_matching_events
66
+ return false if @events.empty?
67
+ return false unless count_matches?
68
+ return false unless payload_matches?
69
+ return false unless severity_matches?
70
+ return false unless trace_id_matches?
71
+
72
+ true
73
+ end
74
+
75
+ def failure_message
76
+ if @events.empty?
77
+ no_events_message
78
+ elsif !count_matches?
79
+ count_mismatch_message
80
+ elsif !payload_matches?
81
+ payload_mismatch_message
82
+ elsif !severity_matches?
83
+ severity_mismatch_message
84
+ else
85
+ trace_id_mismatch_message
86
+ end
87
+ end
88
+
89
+ def failure_message_when_negated
90
+ "expected not to have tracked #{event_name}, but it was tracked"
91
+ end
92
+
93
+ def supports_block_expectations?
94
+ true
95
+ end
96
+
97
+ private
98
+
99
+ def find_matching_events
100
+ adapter = E11y.test_adapter
101
+ return [] unless adapter
102
+
103
+ adapter.find_events(@event_class_or_pattern)
104
+ end
105
+
106
+ def event_name
107
+ case @event_class_or_pattern
108
+ when Class
109
+ @event_class_or_pattern.name
110
+ else
111
+ @event_class_or_pattern.to_s
112
+ end
113
+ end
114
+
115
+ def count_matches?
116
+ return true unless @count_matcher
117
+
118
+ case @count_matcher
119
+ when Integer
120
+ @events.size == @count_matcher
121
+ when Array
122
+ operator, expected = @count_matcher
123
+ case operator
124
+ when :at_least then @events.size >= expected
125
+ when :at_most then @events.size <= expected
126
+ else false
127
+ end
128
+ end
129
+ end
130
+
131
+ def payload_matches?
132
+ return true if @payload_matchers.empty?
133
+
134
+ @events.any? do |event|
135
+ payload = event[:payload] || {}
136
+ @payload_matchers.all? do |key, expected_value|
137
+ actual_value = payload[key.to_s] || payload[key.to_sym]
138
+ actual_value == expected_value
139
+ end
140
+ end
141
+ end
142
+
143
+ def severity_matches?
144
+ return true unless @severity_matcher
145
+
146
+ @events.any? { |event| event[:severity].to_s == @severity_matcher.to_s }
147
+ end
148
+
149
+ def trace_id_matches?
150
+ return true unless @trace_id_matcher
151
+
152
+ @events.any? { |event| event[:trace_id] == @trace_id_matcher }
153
+ end
154
+
155
+ def no_events_message
156
+ adapter = E11y.test_adapter
157
+ if !adapter || adapter.events.empty?
158
+ "expected to have tracked #{event_name}, but no events were tracked at all"
159
+ else
160
+ tracked = adapter.events.map { |e| e[:event_name] }.uniq.join(", ")
161
+ "expected to have tracked #{event_name}, but only tracked: #{tracked}"
162
+ end
163
+ end
164
+
165
+ def count_mismatch_message
166
+ expected = case @count_matcher
167
+ when Integer then "exactly #{@count_matcher}"
168
+ when Array then "#{@count_matcher[0].to_s.tr('_', ' ')} #{@count_matcher[1]}"
169
+ end
170
+ "expected to track #{event_name} #{expected} times, but tracked #{@events.size} times"
171
+ end
172
+
173
+ def payload_mismatch_message
174
+ "expected #{event_name} with payload #{@payload_matchers.inspect}, " \
175
+ "but got:\n#{@events.map { |e| " #{e[:payload].inspect}" }.join("\n")}"
176
+ end
177
+
178
+ def severity_mismatch_message
179
+ severities = @events.map { |e| e[:severity] }.uniq.join(", ")
180
+ "expected #{event_name} with severity :#{@severity_matcher}, but got: #{severities}"
181
+ end
182
+
183
+ def trace_id_mismatch_message
184
+ trace_ids = @events.map { |e| e[:trace_id] }.uniq.join(", ")
185
+ "expected #{event_name} with trace_id #{@trace_id_matcher}, but got: #{trace_ids}"
186
+ end
187
+ end
188
+ # rubocop:enable Metrics/ClassLength
189
+ end
190
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Testing
5
+ # RSpec matchers for event tracking assertions (ADR-011 F-002)
6
+ #
7
+ # @example
8
+ # expect { OrdersController.create }.to have_tracked_event(Events::OrderCreated)
9
+ # expect { action }.to have_tracked_event(Events::OrderPaid).with(order_id: 123)
10
+ # expect { action }.to have_tracked_event(Events::OrderPaid).once
11
+ module RSpecMatchers
12
+ # rubocop:disable Naming/PredicatePrefix -- RSpec matcher convention: have_tracked_event
13
+ def have_tracked_event(event_class_or_pattern)
14
+ HaveTrackedEventMatcher.new(event_class_or_pattern)
15
+ end
16
+ # rubocop:enable Naming/PredicatePrefix
17
+
18
+ alias track_event have_tracked_event
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module E11y
7
+ module Testing
8
+ # Snapshot matcher for event comparison (ADR-011 F-006).
9
+ #
10
+ # Compares event hashes against YAML snapshots, normalizing volatile fields
11
+ # (timestamp, trace_id, span_id). First run creates the snapshot; subsequent
12
+ # runs compare against it. Use UPDATE_SNAPSHOTS=1 to update snapshots.
13
+ #
14
+ # @example
15
+ # event = E11y.test_adapter.find_event(Events::OrderCreated)
16
+ # expect(event).to match_snapshot("order_created_event")
17
+ class SnapshotMatcher
18
+ SNAPSHOTS_DIR = "spec/snapshots/events"
19
+ VOLATILE_KEYS = %i[timestamp trace_id span_id retention_until routed_at].freeze
20
+
21
+ def initialize(snapshot_name)
22
+ @snapshot_name = snapshot_name
23
+ end
24
+
25
+ def matches?(actual)
26
+ @actual = actual
27
+ @normalized = normalize_event(actual)
28
+ @snapshot_path = File.join(SNAPSHOTS_DIR, "#{@snapshot_name}.yml")
29
+
30
+ if update_snapshots? || !File.exist?(@snapshot_path)
31
+ write_snapshot(@normalized)
32
+ true
33
+ else
34
+ @expected = YAML.load_file(@snapshot_path)
35
+ @normalized == @expected
36
+ end
37
+ end
38
+
39
+ def failure_message
40
+ if @expected
41
+ "expected event to match snapshot #{@snapshot_name}, but it differed:\n" \
42
+ "Expected:\n#{@expected.to_yaml}\n" \
43
+ "Actual (normalized):\n#{@normalized.to_yaml}"
44
+ else
45
+ "snapshot #{@snapshot_name} not found at #{@snapshot_path}"
46
+ end
47
+ end
48
+
49
+ def failure_message_when_negated
50
+ "expected event not to match snapshot #{@snapshot_name}, but it did"
51
+ end
52
+
53
+ private
54
+
55
+ def normalize_event(event)
56
+ return {} if event.nil?
57
+
58
+ event = event.dup
59
+ VOLATILE_KEYS.each { |k| event.delete(k) }
60
+ event.delete(:context) # context contains trace_id, span_id, etc.
61
+ event.delete(:routing)
62
+ deep_stringify(event)
63
+ end
64
+
65
+ def deep_stringify(obj)
66
+ case obj
67
+ when Hash
68
+ obj.transform_values { |v| deep_stringify(v) }.transform_keys(&:to_s)
69
+ when Array
70
+ obj.map { |v| deep_stringify(v) }
71
+ else
72
+ obj
73
+ end
74
+ end
75
+
76
+ def update_snapshots?
77
+ %w[1 true].include?(ENV.fetch("UPDATE_SNAPSHOTS", nil))
78
+ end
79
+
80
+ def write_snapshot(data)
81
+ FileUtils.mkdir_p(File.dirname(@snapshot_path))
82
+ File.write(@snapshot_path, data.to_yaml)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module TraceContext
5
+ # Trace entry sampler (ADR-005 §7).
6
+ # Decides if trace should be sampled; respects parent decision when configured.
7
+ class Sampler
8
+ class << self
9
+ def should_sample?(context = {})
10
+ cfg = E11y.config
11
+ respect = cfg&.tracing_respect_parent_sampling != false
12
+
13
+ return context[:sampled] if respect && context.key?(:sampled)
14
+
15
+ rate = determine_sample_rate(context, cfg)
16
+ rand < rate
17
+ end
18
+
19
+ private
20
+
21
+ def determine_sample_rate(context, cfg)
22
+ return 1.0 if context[:error]
23
+ return 1.0 if cfg&.tracing_always_sample_if&.call(context)
24
+
25
+ if context[:event_name] && cfg&.tracing_per_event_sample_rates
26
+ rate = cfg.tracing_per_event_sample_rates[context[:event_name].to_s]
27
+ return rate if rate
28
+ end
29
+
30
+ (cfg&.tracing_default_sample_rate || 0.1).to_f
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is only loaded explicitly via E11y::Tracing.install_faraday_middleware!
4
+ # (which calls require "faraday" first) and is NOT autoloaded by Zeitwerk on startup.
5
+ # Faraday is an optional dependency — see e11y.gemspec.
6
+
7
+ module E11y
8
+ module Tracing
9
+ # Faraday middleware that injects W3C traceparent header into outgoing requests.
10
+ #
11
+ # Register once via E11y::Tracing.install_faraday_middleware!, then use per connection:
12
+ #
13
+ # conn = Faraday.new(url: "https://api.example.com") do |f|
14
+ # f.request :e11y_tracing
15
+ # f.adapter Faraday.default_adapter
16
+ # end
17
+ #
18
+ # @see E11y::Tracing.install_faraday_middleware!
19
+ # @see E11y::Tracing::Propagator
20
+ class FaradayMiddleware < ::Faraday::Middleware
21
+ # Inject traceparent into outgoing request headers and pass to next middleware.
22
+ #
23
+ # @param env [Faraday::Env] Faraday request environment
24
+ # @return [Faraday::Response]
25
+ def call(env)
26
+ Propagator.inject(env.request_headers)
27
+ @app.call(env)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Tracing
5
+ # Monkey-patch for Net::HTTP that injects W3C traceparent into every request.
6
+ #
7
+ # Applied via +prepend+ so it wraps the original +#request+ method:
8
+ #
9
+ # E11y::Tracing.patch_net_http!
10
+ # # From this point all Net::HTTP requests carry the traceparent header.
11
+ #
12
+ # The patch is idempotent — prepending twice is prevented by checking
13
+ # +Net::HTTP.ancestors+.
14
+ #
15
+ # @see E11y::Tracing.patch_net_http!
16
+ # @see E11y::Tracing::Propagator
17
+ module NetHTTPPatch
18
+ # Inject traceparent header then delegate to the original Net::HTTP#request.
19
+ #
20
+ # Skips injection if traceparent is already set on the request object
21
+ # (e.g., caller set it manually).
22
+ #
23
+ # @param req [Net::HTTPRequest] Outgoing HTTP request object
24
+ # @param body [String, nil] Optional body
25
+ # @return [Net::HTTPResponse]
26
+ def request(req, body = nil, &)
27
+ header_value = Propagator.build_traceparent
28
+ req[Propagator::TRACEPARENT_HEADER] = header_value if header_value && !req[Propagator::TRACEPARENT_HEADER]
29
+ super
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module E11y
6
+ module Tracing
7
+ # W3C Trace Context propagator.
8
+ #
9
+ # Builds, injects, and parses W3C traceparent headers.
10
+ # Format: {version}-{trace-id}-{parent-id}-{flags}
11
+ # Example: 00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01
12
+ #
13
+ # @see https://www.w3.org/TR/trace-context/
14
+ # @see UC-009 Multi-Service Tracing
15
+ class Propagator
16
+ TRACEPARENT_VERSION = "00"
17
+ SAMPLED_FLAG = "01"
18
+ TRACEPARENT_HEADER = "traceparent"
19
+ TRACESTATE_HEADER = "tracestate"
20
+
21
+ # Build a W3C traceparent header value from the current trace context.
22
+ #
23
+ # Falls back to E11y::Current if explicit ids are not provided.
24
+ # Generates a random span_id if none is set.
25
+ # Returns nil when no trace_id is available.
26
+ #
27
+ # @param trace_id [String, nil] Override trace_id (optional)
28
+ # @param span_id [String, nil] Override span_id (optional)
29
+ # @return [String, nil] e.g. "00-abc...32hex-def...16hex-01", or nil
30
+ def self.build_traceparent(trace_id: nil, span_id: nil)
31
+ t_id = trace_id || E11y::Current.trace_id
32
+ return nil if t_id.nil? || t_id.empty?
33
+
34
+ s_id = span_id || E11y::Current.span_id
35
+ s_id = SecureRandom.hex(8) if s_id.nil? || s_id.empty?
36
+
37
+ sampled = E11y::Current.respond_to?(:sampled) ? E11y::Current.sampled : true
38
+ flags = sampled == false ? "00" : SAMPLED_FLAG
39
+ "#{TRACEPARENT_VERSION}-#{t_id}-#{s_id}-#{flags}"
40
+ end
41
+
42
+ # Inject W3C trace context headers into a plain Hash of headers.
43
+ #
44
+ # Mutates +headers+ in place and returns it.
45
+ # Does NOT override an existing traceparent entry.
46
+ # Adds tracestate when E11y::Current.baggage is present (F-014).
47
+ #
48
+ # @param headers [Hash] Headers hash to mutate
49
+ # @param trace_id [String, nil] Override trace_id (optional)
50
+ # @param span_id [String, nil] Override span_id (optional)
51
+ # @return [Hash] The (possibly mutated) headers hash
52
+ def self.inject(headers, trace_id: nil, span_id: nil)
53
+ unless headers[TRACEPARENT_HEADER]
54
+ header_value = build_traceparent(trace_id: trace_id, span_id: span_id)
55
+ headers[TRACEPARENT_HEADER] = header_value if header_value
56
+ end
57
+
58
+ if !headers[TRACESTATE_HEADER] && E11y::Current.respond_to?(:baggage) && E11y::Current.baggage&.any?
59
+ filtered = filter_baggage_for_propagation(E11y::Current.baggage)
60
+ headers[TRACESTATE_HEADER] = build_tracestate(filtered) if filtered.any?
61
+ end
62
+
63
+ headers
64
+ end
65
+
66
+ # Parse W3C tracestate header to Hash (key=value pairs).
67
+ # @param tracestate [String, nil] Raw header value
68
+ # @return [Hash] String keys and values (empty hash if invalid)
69
+ def self.parse_tracestate(tracestate)
70
+ return {} unless tracestate.is_a?(String)
71
+
72
+ tracestate.split(",").each_with_object({}) do |entry, hash|
73
+ key, value = entry.split("=", 2)
74
+ hash[key.strip] = value.to_s.strip if key && !key.strip.empty?
75
+ end
76
+ end
77
+
78
+ # Build W3C tracestate header from baggage Hash.
79
+ # @param baggage_hash [Hash] String keys and values
80
+ # @return [String] e.g. "key1=value1,key2=value2"
81
+ def self.build_tracestate(baggage_hash)
82
+ return "" unless baggage_hash.is_a?(Hash) && baggage_hash.any?
83
+
84
+ baggage_hash.map { |k, v| "#{k}=#{v}" }.join(",")
85
+ end
86
+
87
+ # Filter baggage to allowed keys only (ADR-006 §5.5, PII protection).
88
+ def self.filter_baggage_for_propagation(baggage_hash)
89
+ cfg = E11y.config
90
+ return baggage_hash if cfg.nil?
91
+
92
+ cfg.filter_baggage_for_propagation(baggage_hash)
93
+ end
94
+
95
+ # Parse a W3C traceparent header string.
96
+ #
97
+ # @param traceparent [String, nil] Raw header value
98
+ # @return [Hash, nil] +{ trace_id:, parent_span_id:, sampled: }+ or nil if invalid
99
+ def self.parse(traceparent)
100
+ return nil unless traceparent.is_a?(String)
101
+
102
+ parts = traceparent.split("-")
103
+ return nil unless parts.size == 4
104
+
105
+ _version, trace_id, parent_span_id, flags = parts
106
+ return nil if trace_id.nil? || trace_id.empty?
107
+
108
+ {
109
+ trace_id: trace_id,
110
+ parent_span_id: parent_span_id,
111
+ sampled: flags == SAMPLED_FLAG
112
+ }
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ # Outgoing HTTP trace context propagation (UC-009).
5
+ #
6
+ # Provides W3C Trace Context injection into outgoing HTTP requests
7
+ # via Faraday middleware and Net::HTTP monkey-patch.
8
+ #
9
+ # @example Enable Net::HTTP tracing
10
+ # E11y::Tracing.patch_net_http!
11
+ #
12
+ # @example Enable Faraday tracing (register middleware, then use in connection)
13
+ # E11y::Tracing.install_faraday_middleware!
14
+ # conn = Faraday.new { |f| f.request :e11y_tracing }
15
+ #
16
+ # @see UC-009 Multi-Service Tracing
17
+ # @see https://www.w3.org/TR/trace-context/
18
+ module Tracing
19
+ # Install Net::HTTP tracing patch (idempotent).
20
+ #
21
+ # Prepends E11y::Tracing::NetHTTPPatch into Net::HTTP so that every
22
+ # outgoing request automatically carries a W3C traceparent header.
23
+ #
24
+ # @return [void]
25
+ def self.patch_net_http!
26
+ require "net/http"
27
+ require "e11y/tracing/net_http_patch"
28
+ return if ::Net::HTTP <= E11y::Tracing::NetHTTPPatch
29
+
30
+ ::Net::HTTP.prepend(E11y::Tracing::NetHTTPPatch)
31
+ end
32
+
33
+ # Register the Faraday middleware so it can be referenced by name (idempotent).
34
+ #
35
+ # After calling this, add +f.request :e11y_tracing+ to any Faraday connection
36
+ # that should propagate trace context.
37
+ #
38
+ # @return [void]
39
+ def self.install_faraday_middleware!
40
+ require "faraday"
41
+ require "e11y/tracing/faraday_middleware"
42
+ return if ::Faraday::Request.registered_middleware.key?(:e11y_tracing)
43
+
44
+ ::Faraday::Request.register_middleware(e11y_tracing: E11y::Tracing::FaradayMiddleware)
45
+ end
46
+ end
47
+ end
data/lib/e11y/version.rb CHANGED
@@ -5,5 +5,5 @@ module E11y
5
5
  # - MAJOR: Breaking changes (incompatible API changes)
6
6
  # - MINOR: New features (backwards-compatible)
7
7
  # - PATCH: Bug fixes (backwards-compatible)
8
- VERSION = "0.2.0"
8
+ VERSION = "1.0.0"
9
9
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Versioning
5
+ # Extracts version number and base name from event class names (ADR-012 §3.2).
6
+ #
7
+ # @example
8
+ # VersionExtractor.extract_version("Events::OrderPaidV2") # => 2
9
+ # VersionExtractor.extract_version("Events::OrderPaid") # => 1
10
+ # VersionExtractor.extract_base_name("Events::OrderPaidV2") # => "Events::OrderPaid"
11
+ class VersionExtractor
12
+ VERSION_REGEX = /V(\d+)$/
13
+
14
+ # @param class_name [String] Event class name (e.g. "Events::OrderPaidV2")
15
+ # @return [Integer] Version number (1 if no suffix)
16
+ def self.extract_version(class_name)
17
+ return 1 unless class_name
18
+
19
+ match = class_name.to_s.match(VERSION_REGEX)
20
+ match ? match[1].to_i : 1
21
+ end
22
+
23
+ # @param class_name [String] Event class name
24
+ # @return [String] Base name without version suffix (e.g. "Events::OrderPaid")
25
+ def self.extract_base_name(class_name)
26
+ return class_name unless class_name
27
+
28
+ class_name.to_s.sub(VERSION_REGEX, "")
29
+ end
30
+ end
31
+ end
32
+ end