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
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "rack/test"
5
+ require "rack/mock"
6
+ require "e11y/devtools/overlay/middleware"
7
+
8
+ RSpec.describe E11y::Devtools::Overlay::Middleware do
9
+ include Rack::Test::Methods
10
+
11
+ let(:html_body) { "<html><body><h1>Hello</h1></body></html>" }
12
+ let(:base_app) do
13
+ ->(_env) { [200, { "Content-Type" => "text/html" }, [html_body]] }
14
+ end
15
+
16
+ def app = described_class.new(base_app)
17
+
18
+ it "injects overlay script before </body>" do
19
+ get "/"
20
+ expect(last_response.body).to include("e11y-overlay")
21
+ expect(last_response.body).to include("</body>")
22
+ end
23
+
24
+ it "does not inject into non-HTML responses" do
25
+ json_app = ->(_env) { [200, { "Content-Type" => "application/json" }, ['{"ok":true}']] }
26
+ response = described_class.new(json_app).call(Rack::MockRequest.env_for("/"))
27
+ body = response[2].join
28
+ expect(body).not_to include("e11y-overlay")
29
+ end
30
+
31
+ it "does not inject into XHR requests" do
32
+ get "/", {}, { "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest" }
33
+ expect(last_response.body).not_to include("e11y-overlay")
34
+ end
35
+
36
+ it "does not inject into asset paths" do
37
+ get "/assets/application.js"
38
+ expect(last_response.body).not_to include("e11y-overlay")
39
+ end
40
+
41
+ it "preserves Content-Length consistency after injection" do
42
+ get "/"
43
+ content_length = last_response.headers["Content-Length"]
44
+ expect(content_length.to_i).to eq(last_response.body.bytesize) if content_length
45
+ end
46
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+ require "e11y/adapters/dev_log/query"
6
+ require "e11y/devtools/tui/grouping"
7
+ require "e11y/devtools/tui/app"
8
+
9
+ RSpec.describe E11y::Devtools::Tui::App do
10
+ subject(:app) { described_class.new(log_path: "/dev/null") }
11
+
12
+ describe "#initialize" do
13
+ it "starts in :interactions view" do
14
+ expect(app.current_view).to eq(:interactions)
15
+ end
16
+
17
+ it "starts with source_filter :web" do
18
+ expect(app.source_filter).to eq(:web)
19
+ end
20
+ end
21
+
22
+ describe "#handle_key" do
23
+ context "when in :interactions view" do
24
+ it "drills into :events on Enter" do
25
+ query = instance_double(E11y::Adapters::DevLog::Query, events_by_trace: [])
26
+ interaction = E11y::Devtools::Tui::Grouping::Interaction.new(
27
+ Time.now, ["t1"], false, "web"
28
+ )
29
+ app.instance_variable_set(:@query, query)
30
+ app.instance_variable_set(:@interactions, [interaction])
31
+ app.instance_variable_set(:@selected_ix, 0)
32
+ app.handle_key("enter")
33
+ expect(app.current_view).to eq(:events)
34
+ end
35
+
36
+ it "toggles source to :job on 'j'" do
37
+ app.handle_key("j")
38
+ expect(app.source_filter).to eq(:job)
39
+ end
40
+
41
+ it "toggles source to :all on 'a'" do
42
+ app.handle_key("a")
43
+ expect(app.source_filter).to eq(:all)
44
+ end
45
+
46
+ it "toggles source back to :web on 'w'" do
47
+ app.handle_key("a")
48
+ app.handle_key("w")
49
+ expect(app.source_filter).to eq(:web)
50
+ end
51
+ end
52
+
53
+ context "when in :events view" do
54
+ before do
55
+ app.instance_variable_set(:@current_view, :events)
56
+ app.instance_variable_set(:@current_trace_id, "t1")
57
+ app.instance_variable_set(:@events, [{ "id" => "e1", "event_name" => "x" }])
58
+ end
59
+
60
+ it "goes back to :interactions on Esc" do
61
+ app.handle_key("esc")
62
+ expect(app.current_view).to eq(:interactions)
63
+ end
64
+
65
+ it "drills into :detail on Enter" do
66
+ app.handle_key("enter")
67
+ expect(app.current_view).to eq(:detail)
68
+ end
69
+ end
70
+
71
+ context "when in :detail view" do
72
+ before { app.instance_variable_set(:@current_view, :detail) }
73
+
74
+ it "goes back to :events on Esc" do
75
+ app.handle_key("esc")
76
+ expect(app.current_view).to eq(:events)
77
+ end
78
+
79
+ it "goes back to :events on 'b'" do
80
+ app.handle_key("b")
81
+ expect(app.current_view).to eq(:events)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "time"
5
+ require "e11y/devtools/tui/grouping"
6
+
7
+ RSpec.describe E11y::Devtools::Tui::Grouping do
8
+ def make_trace(id, offset_ms, severity: "info", source: "web")
9
+ {
10
+ trace_id: id,
11
+ started_at: Time.now + (offset_ms / 1000.0),
12
+ severity: severity,
13
+ source: source
14
+ }
15
+ end
16
+
17
+ describe ".group" do
18
+ it "returns empty array for empty input" do
19
+ expect(described_class.group([])).to eq([])
20
+ end
21
+
22
+ it "places single trace in one interaction" do
23
+ groups = described_class.group([make_trace("t1", 0)])
24
+ expect(groups.size).to eq(1)
25
+ expect(groups.first.trace_ids).to eq(["t1"])
26
+ end
27
+
28
+ it "groups traces within window into one interaction" do
29
+ traces = [
30
+ make_trace("t1", 0),
31
+ make_trace("t2", 300), # 300ms after t1 — within 500ms window
32
+ make_trace("t3", 1200) # 1200ms after t1 — outside window
33
+ ]
34
+ groups = described_class.group(traces, window_ms: 500)
35
+ expect(groups.size).to eq(2)
36
+ # newest-first: groups[0] = t3 group, groups[1] = t1+t2 group
37
+ expect(groups.last.trace_ids.sort).to eq(%w[t1 t2].sort)
38
+ expect(groups.first.trace_ids).to eq(["t3"])
39
+ end
40
+
41
+ it "marks interaction has_error? when any trace has error severity" do
42
+ traces = [
43
+ make_trace("t1", 0, severity: "error"),
44
+ make_trace("t2", 100, severity: "info")
45
+ ]
46
+ groups = described_class.group(traces, window_ms: 500)
47
+ expect(groups.first.has_error?).to be true
48
+ end
49
+
50
+ it "marks interaction clean when no errors" do
51
+ groups = described_class.group([make_trace("t1", 0, severity: "info")])
52
+ expect(groups.first.has_error?).to be false
53
+ end
54
+
55
+ it "returns groups newest-first" do
56
+ traces = [
57
+ make_trace("old", 0),
58
+ make_trace("new", 2000)
59
+ ]
60
+ groups = described_class.group(traces, window_ms: 500)
61
+ expect(groups.first.trace_ids).to eq(["new"])
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "e11y/devtools/version"
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "time"
5
+
6
+ unless defined?(RATATUI_AVAILABLE)
7
+ begin
8
+ require "minitest"
9
+ require "ratatui_ruby"
10
+ require "ratatui_ruby/test_helper"
11
+ RATATUI_AVAILABLE = true
12
+ rescue LoadError
13
+ RATATUI_AVAILABLE = false
14
+ end
15
+ end
16
+
17
+ RSpec.describe "E11y::Devtools::Tui::Widgets::EventList" do
18
+ include RatatuiRuby::TestHelper if RATATUI_AVAILABLE
19
+
20
+ before do
21
+ skip "ratatui_ruby not available" unless RATATUI_AVAILABLE
22
+ require "e11y/devtools/tui/widgets/event_list"
23
+ end
24
+
25
+ let(:events) do
26
+ [
27
+ { "severity" => "error", "event_name" => "order.failed",
28
+ "timestamp" => Time.now.iso8601, "metadata" => {} },
29
+ { "severity" => "info", "event_name" => "order.created",
30
+ "timestamp" => Time.now.iso8601, "metadata" => { "duration_ms" => 42 } }
31
+ ]
32
+ end
33
+
34
+ it "renders without raising" do
35
+ widget = E11y::Devtools::Tui::Widgets::EventList.new(
36
+ events: events, trace_id: "trace-1", selected_index: 0
37
+ )
38
+ tui = RatatuiRuby::TUI.new
39
+ with_test_terminal(80, 10) do
40
+ expect { RatatuiRuby.draw { |frame| widget.render(tui, frame, frame.area) } }
41
+ .not_to raise_error
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "time"
5
+ require "e11y/devtools/tui/grouping"
6
+
7
+ unless defined?(RATATUI_AVAILABLE)
8
+ begin
9
+ require "minitest"
10
+ require "ratatui_ruby"
11
+ require "ratatui_ruby/test_helper"
12
+ RATATUI_AVAILABLE = true
13
+ rescue LoadError
14
+ RATATUI_AVAILABLE = false
15
+ end
16
+ end
17
+
18
+ RSpec.describe "E11y::Devtools::Tui::Widgets::InteractionList" do
19
+ include RatatuiRuby::TestHelper if RATATUI_AVAILABLE
20
+
21
+ before do
22
+ skip "ratatui_ruby not available" unless RATATUI_AVAILABLE
23
+ require "e11y/devtools/tui/widgets/interaction_list"
24
+ end
25
+
26
+ let(:t0) { Time.now }
27
+
28
+ def make_interaction(trace_ids:, has_error: false)
29
+ E11y::Devtools::Tui::Grouping::Interaction.new(
30
+ started_at: t0,
31
+ trace_ids: trace_ids,
32
+ has_error?: has_error,
33
+ source: "web"
34
+ )
35
+ end
36
+
37
+ it "renders bullet as ● red when interaction has error" do
38
+ widget = E11y::Devtools::Tui::Widgets::InteractionList.new(
39
+ interactions: [make_interaction(trace_ids: ["t1"], has_error: true)],
40
+ selected_index: 0
41
+ )
42
+ tui = RatatuiRuby::TUI.new
43
+ with_test_terminal(40, 5) do
44
+ expect { RatatuiRuby.draw { |frame| widget.render(tui, frame, frame.area) } }
45
+ .not_to raise_error
46
+ expect(buffer_content.join).to include("●")
47
+ end
48
+ end
49
+
50
+ it "renders bullet as ○ when interaction is clean" do
51
+ widget = E11y::Devtools::Tui::Widgets::InteractionList.new(
52
+ interactions: [make_interaction(trace_ids: ["t1"], has_error: false)],
53
+ selected_index: 0
54
+ )
55
+ tui = RatatuiRuby::TUI.new
56
+ with_test_terminal(40, 5) do
57
+ expect { RatatuiRuby.draw { |frame| widget.render(tui, frame, frame.area) } }
58
+ .not_to raise_error
59
+ expect(buffer_content.join).to include("○")
60
+ end
61
+ end
62
+ end
@@ -4,6 +4,7 @@ require "openssl"
4
4
  require "json"
5
5
  require "fileutils"
6
6
  require "base64"
7
+ require "e11y/event/base"
7
8
 
8
9
  module E11y
9
10
  module Adapters
@@ -84,14 +85,47 @@ module E11y
84
85
  # Read and decrypt audit event (for verification)
85
86
  #
86
87
  # @param event_id [String] Event ID
87
- # @return [Hash] Decrypted event data
88
+ # @return [Hash, nil] Decrypted event data, or nil if decryption fails
88
89
  def read(event_id)
89
90
  encrypted_data = read_from_storage(event_id)
90
91
  decrypt_event(encrypted_data)
92
+ rescue Errno::ENOENT => e
93
+ warn "AuditEncrypted read error (file not found): #{e.message}"
94
+ nil
95
+ rescue JSON::ParserError => e
96
+ warn "AuditEncrypted read error (corrupt data): #{e.message}"
97
+ nil
98
+ rescue OpenSSL::Cipher::CipherError => e
99
+ # SECURITY: decryption failure indicates tampered or corrupt ciphertext.
100
+ # Re-raise so callers can handle it; also attempt to emit a security event.
101
+ track_security_event(event_id, e)
102
+ raise
91
103
  end
92
104
 
93
105
  private
94
106
 
107
+ # Emit a security event when decryption fails (potential tampering).
108
+ # Guards against E11y not being fully configured in non-production envs.
109
+ #
110
+ # @param event_id [String] The event ID that failed to decrypt
111
+ # @param error [OpenSSL::Cipher::CipherError] The decryption error
112
+ # @return [void]
113
+ def track_security_event(event_id, error)
114
+ E11y::Event::Base.track(
115
+ event_name: "e11y.security.audit_decryption_failed",
116
+ severity: :error,
117
+ payload: {
118
+ event_id: event_id,
119
+ error_class: error.class.name,
120
+ error_message: error.message,
121
+ adapter: self.class.name
122
+ }
123
+ )
124
+ rescue StandardError
125
+ warn "AuditEncrypted: decryption failure detected for #{event_id} " \
126
+ "(#{error.message}); security event could not be tracked"
127
+ end
128
+
95
129
  # Encrypt event data with AES-256-GCM
96
130
  #
97
131
  # @param event_data [Hash] Event data
@@ -127,7 +161,6 @@ module E11y
127
161
  #
128
162
  # @param encrypted [Hash] Encrypted data with nonce and tag
129
163
  # @return [Hash] Decrypted event data
130
- # rubocop:disable Metrics/AbcSize
131
164
  # Cryptographic operations require multiple steps for secure decryption
132
165
  def decrypt_event(encrypted)
133
166
  cipher = OpenSSL::Cipher.new(CIPHER)
@@ -141,7 +174,6 @@ module E11y
141
174
 
142
175
  JSON.parse(plaintext, symbolize_names: true)
143
176
  end
144
- # rubocop:enable Metrics/AbcSize
145
177
 
146
178
  # Write encrypted data to storage
147
179
  #
@@ -216,17 +248,27 @@ module E11y
216
248
  #
217
249
  # @return [String] Encryption key
218
250
  def default_encryption_key
219
- key = ENV.fetch("E11Y_AUDIT_ENCRYPTION_KEY") do
220
- if defined?(::Rails) && ::Rails.env.production?
221
- raise E11y::Error, "E11Y_AUDIT_ENCRYPTION_KEY must be set in production"
222
- end
251
+ # Use ENV var if provided (required in production)
252
+ env_key = ENV.fetch("E11Y_AUDIT_ENCRYPTION_KEY", nil)
253
+ if env_key
254
+ return env_key.bytesize == 32 ? env_key : [env_key].pack("H*")
255
+ end
223
256
 
224
- # Development fallback
225
- OpenSSL::Random.random_bytes(32)
257
+ # In production without ENV var, raise a clear error
258
+ if defined?(::Rails) && ::Rails.env.production?
259
+ raise E11y::Error,
260
+ "E11Y_AUDIT_ENCRYPTION_KEY must be set in production. " \
261
+ "Generate with: openssl rand -hex 32"
226
262
  end
227
263
 
228
- # Ensure 32 bytes
229
- key.bytesize == 32 ? key : [key].pack("H*")
264
+ # Development/test: derive a stable key from a fixed seed.
265
+ # This is NOT secure for production — only for development/testing.
266
+ OpenSSL::PKCS5.pbkdf2_hmac_sha1(
267
+ "e11y-development-key-not-for-production",
268
+ "e11y-static-salt",
269
+ 1000,
270
+ 32
271
+ )
230
272
  end
231
273
 
232
274
  # Default storage path
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../reliability/retry_handler"
4
+ require_relative "../reliability/retry_rate_limiter"
4
5
  require_relative "../reliability/circuit_breaker"
5
6
 
6
7
  module E11y
@@ -98,14 +99,13 @@ module E11y
98
99
  # This is the recommended public API for sending events.
99
100
  # Automatically handles failures, retries, and DLQ.
100
101
  #
101
- # Respects `E11y.config.error_handling.fail_on_error` setting (C18 Resolution):
102
+ # Respects `E11y.config.error_handling_fail_on_error` setting (C18 Resolution):
102
103
  # - `true`: Raises exceptions (fast feedback for web requests)
103
104
  # - `false`: Swallows exceptions, saves to DLQ (don't fail background jobs)
104
105
  #
105
106
  # @param event_data [Hash] Event payload
106
107
  # @return [Boolean] true on success
107
108
  # @raise [RetryExhaustedError, CircuitOpenError] if fail_on_error=true
108
- # rubocop:disable Metrics/MethodLength
109
109
  # Core reliability logic with retry and circuit breaker - should stay as cohesive unit
110
110
  def write_with_reliability(event_data)
111
111
  return write(event_data) unless @reliability_enabled
@@ -129,7 +129,6 @@ module E11y
129
129
  handle_reliability_error(event_data, e, :circuit_open)
130
130
  end
131
131
  end
132
- # rubocop:enable Metrics/MethodLength
133
132
 
134
133
  # Write a batch of events (preferred for performance)
135
134
  #
@@ -288,7 +287,7 @@ module E11y
288
287
  raise unless retriable_error?(e) && attempt < max_attempts
289
288
 
290
289
  delay = calculate_backoff_delay(attempt, base_delay, max_delay, jitter)
291
- warn "[E11y] #{self.class.name} retry #{attempt}/#{max_attempts} after #{delay.round(2)}s: #{e.message}"
290
+ E11y.logger&.warn("[E11y] #{self.class.name} retry #{attempt}/#{max_attempts} after #{delay.round(2)}s: #{e.message}")
292
291
  sleep(delay)
293
292
  retry
294
293
  end
@@ -306,7 +305,7 @@ module E11y
306
305
  # def retriable_error?(error)
307
306
  # super || error.is_a?(CustomTransientError)
308
307
  # end
309
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
308
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
310
309
  # This method checks many different error types for retryability - splitting would reduce clarity
311
310
  def retriable_error?(error)
312
311
  # Network timeout errors
@@ -334,7 +333,7 @@ module E11y
334
333
 
335
334
  false
336
335
  end
337
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
336
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
338
337
 
339
338
  # Calculate exponential backoff delay with jitter
340
339
  #
@@ -381,16 +380,13 @@ module E11y
381
380
  # end
382
381
  #
383
382
  # @see ADR-004 Section 7.2 (Circuit Breaker)
384
- # rubocop:disable Metrics/MethodLength
385
383
  # Circuit breaker state machine logic should stay as cohesive unit
386
384
  def with_circuit_breaker(failure_threshold: 5, timeout: 60)
387
385
  init_circuit_breaker! unless @circuit_state
388
386
 
389
387
  @circuit_mutex.synchronize do
390
388
  if @circuit_state == :open
391
- unless circuit_timeout_expired?(timeout)
392
- raise CircuitOpenError, "Circuit breaker open for #{self.class.name}"
393
- end
389
+ raise CircuitOpenError, "Circuit breaker open for #{self.class.name}" unless circuit_timeout_expired?(timeout)
394
390
 
395
391
  @circuit_state = :half_open
396
392
  @circuit_success_count = 0
@@ -407,7 +403,6 @@ module E11y
407
403
  raise
408
404
  end
409
405
  end
410
- # rubocop:enable Metrics/MethodLength
411
406
 
412
407
  # Initialize circuit breaker state
413
408
  #
@@ -431,7 +426,7 @@ module E11y
431
426
  @circuit_success_count += 1
432
427
  if @circuit_success_count >= 2 # 2 successes → close
433
428
  @circuit_state = :closed
434
- warn "[E11y] #{self.class.name} circuit breaker closed (recovered)"
429
+ E11y.logger&.warn("[E11y] #{self.class.name} circuit breaker closed (recovered)")
435
430
  end
436
431
  end
437
432
  end
@@ -449,7 +444,7 @@ module E11y
449
444
 
450
445
  if @circuit_failure_count >= threshold && @circuit_state == :closed
451
446
  @circuit_state = :open
452
- warn "[E11y] #{self.class.name} circuit breaker opened (#{@circuit_failure_count} failures)"
447
+ E11y.logger&.warn("[E11y] #{self.class.name} circuit breaker opened (#{@circuit_failure_count} failures)")
453
448
  end
454
449
  end
455
450
  end
@@ -469,9 +464,14 @@ module E11y
469
464
  def setup_reliability_layer
470
465
  reliability_config = @config.fetch(:reliability, {})
471
466
 
472
- # Setup RetryHandler
467
+ # Setup RetryHandler (C06: wire RetryRateLimiter for thundering herd prevention)
473
468
  retry_config = reliability_config.fetch(:retry, {})
474
- @retry_handler = E11y::Reliability::RetryHandler.new(config: retry_config)
469
+ rate_limiter = reliability_config[:retry_rate_limiter] ||
470
+ E11y::Reliability::RetryRateLimiter.new
471
+ @retry_handler = E11y::Reliability::RetryHandler.new(
472
+ config: retry_config,
473
+ rate_limiter: rate_limiter
474
+ )
475
475
 
476
476
  # Setup CircuitBreaker
477
477
  circuit_breaker_config = reliability_config.fetch(:circuit_breaker, {})
@@ -479,15 +479,11 @@ module E11y
479
479
  adapter_name: self.class.name,
480
480
  config: circuit_breaker_config
481
481
  )
482
-
483
- # Setup DLQ components (will be initialized from E11y.config later)
484
- @dlq_filter = nil
485
- @dlq_storage = nil
486
482
  end
487
483
 
488
484
  # Handle reliability error (retry exhausted / circuit breaker open).
489
485
  #
490
- # Behavior depends on `E11y.config.error_handling.fail_on_error` (C18 Resolution):
486
+ # Behavior depends on `E11y.config.error_handling_fail_on_error` (C18 Resolution):
491
487
  # - `true`: Re-raises exception (fast feedback for web requests)
492
488
  # - `false`: Swallows exception, saves to DLQ (don't fail background jobs)
493
489
  #
@@ -505,35 +501,38 @@ module E11y
505
501
  save_to_dlq_if_needed(event_data, error, reason)
506
502
 
507
503
  # Log warning
508
- warn "[E11y] #{self.class.name} #{reason} for event #{event_data[:event_name]}: #{error.message}"
504
+ E11y.logger&.warn("[E11y] #{self.class.name} #{reason} for event #{event_data[:event_name]}: #{error.message}")
509
505
 
510
506
  # Check fail_on_error setting (C18 Resolution)
511
- raise error if E11y.config.error_handling.fail_on_error
507
+ raise error if E11y.config.error_handling_fail_on_error
512
508
 
513
509
  # Web request context: RAISE (fast feedback)
514
510
 
515
511
  # Background job context: SWALLOW (don't fail business logic)
516
- # TODO: Track metric e11y.event.tracking_failed_silent
517
512
  false
518
513
  end
519
514
  # rubocop:enable Naming/PredicateMethod
520
515
 
521
516
  # Save event to DLQ if filter allows.
517
+ # Uses E11y.config.dlq_filter and E11y.config.dlq_storage (F3 — wired from config).
522
518
  #
523
519
  # @api private
524
520
  def save_to_dlq_if_needed(event_data, error, reason)
525
- return unless @dlq_filter&.should_save?(event_data, error)
526
-
527
- @dlq_storage&.save(event_data, metadata: {
528
- error: error.message,
529
- error_class: error.class.name,
530
- reason: reason,
531
- adapter: self.class.name,
532
- timestamp: Time.now.utc.iso8601
533
- })
521
+ dlq_filter = E11y.config.respond_to?(:dlq_filter) ? E11y.config.dlq_filter : nil
522
+ dlq_storage = E11y.config.respond_to?(:dlq_storage) ? E11y.config.dlq_storage : nil
523
+ return unless dlq_filter&.should_save?(event_data, error)
524
+ return unless dlq_storage
525
+
526
+ dlq_storage.save(event_data, metadata: {
527
+ error: error,
528
+ error_class: error.class.name,
529
+ reason: reason,
530
+ adapter: self.class.name,
531
+ timestamp: Time.now.utc.iso8601
532
+ })
534
533
  rescue StandardError => e
535
534
  # C18: Don't fail if DLQ save fails
536
- warn "[E11y] Failed to save event to DLQ: #{e.message}"
535
+ E11y.logger&.warn("[E11y] Failed to save event to DLQ: #{e.message}")
537
536
  end
538
537
 
539
538
  # Track successful adapter write (self-monitoring).
@@ -558,7 +557,7 @@ module E11y
558
557
  )
559
558
  rescue StandardError => e
560
559
  # Don't fail if monitoring fails
561
- warn "[E11y] Self-monitoring error: #{e.message}"
560
+ E11y.logger&.warn("[E11y] Self-monitoring error: #{e.message}")
562
561
  end
563
562
 
564
563
  # Track failed adapter write (self-monitoring).