e11y 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +130 -10
  3. data/CHANGELOG.md +80 -1
  4. data/CLAUDE.md +168 -0
  5. data/CONTRIBUTING.md +640 -0
  6. data/README.md +165 -701
  7. data/RELEASE.md +41 -12
  8. data/Rakefile +249 -57
  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 +79 -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} +36 -65
  30. data/docs/{ADR-002-metrics-yabeda.md → architecture/ADR-002-metrics-yabeda.md} +62 -236
  31. data/docs/architecture/ADR-003-slo-observability.md +1402 -0
  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} +182 -743
  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} +44 -86
  40. data/docs/{ADR-012-event-evolution.md → architecture/ADR-012-event-evolution.md} +11 -11
  41. data/docs/{ADR-013-reliability-error-handling.md → architecture/ADR-013-reliability-error-handling.md} +37 -12
  42. data/docs/{ADR-014-event-driven-slo.md → architecture/ADR-014-event-driven-slo.md} +12 -24
  43. data/docs/{ADR-015-middleware-order.md → architecture/ADR-015-middleware-order.md} +43 -59
  44. data/docs/{ADR-016-self-monitoring-slo.md → architecture/ADR-016-self-monitoring-slo.md} +58 -355
  45. data/docs/{ADR-017-multi-rails-compatibility.md → architecture/ADR-017-multi-rails-compatibility.md} +4 -11
  46. data/docs/architecture/ADR-018-memory-optimization.md +366 -0
  47. data/docs/{ADR-INDEX.md → architecture/ADR-INDEX.md} +11 -6
  48. data/docs/plans/2026-03-20-browser-overlay-svelte.md +281 -0
  49. data/docs/{00-ICP-AND-TIMELINE.md → prd/00-ICP-AND-TIMELINE.md} +6 -6
  50. data/docs/{01-SCALE-REQUIREMENTS.md → prd/01-SCALE-REQUIREMENTS.md} +6 -6
  51. data/docs/prd/01-overview-vision.md +19 -14
  52. data/docs/use_cases/README.md +22 -23
  53. data/docs/use_cases/UC-001-request-scoped-debug-buffering.md +50 -44
  54. data/docs/use_cases/UC-002-business-event-tracking.md +26 -95
  55. data/docs/use_cases/UC-003-event-metrics.md +66 -0
  56. data/docs/use_cases/UC-004-zero-config-slo-tracking.md +33 -684
  57. data/docs/use_cases/UC-005-sentry-integration.md +13 -15
  58. data/docs/use_cases/UC-006-trace-context-management.md +30 -28
  59. data/docs/use_cases/UC-007-pii-filtering.md +35 -87
  60. data/docs/use_cases/UC-008-opentelemetry-integration.md +51 -89
  61. data/docs/use_cases/UC-009-multi-service-tracing.md +30 -178
  62. data/docs/use_cases/UC-010-background-job-tracking.md +24 -91
  63. data/docs/use_cases/UC-011-rate-limiting.md +95 -168
  64. data/docs/use_cases/UC-012-audit-trail.md +21 -46
  65. data/docs/use_cases/UC-013-high-cardinality-protection.md +29 -167
  66. data/docs/use_cases/UC-014-adaptive-sampling.md +2 -2
  67. data/docs/use_cases/UC-015-cost-optimization.md +46 -99
  68. data/docs/use_cases/UC-016-rails-logger-migration.md +39 -213
  69. data/docs/use_cases/UC-017-local-development.md +203 -777
  70. data/docs/use_cases/UC-018-testing-events.md +3 -3
  71. data/docs/use_cases/UC-019-retention-based-routing.md +53 -106
  72. data/docs/use_cases/UC-020-event-versioning.md +8 -9
  73. data/docs/use_cases/UC-021-error-handling-retry-dlq.md +18 -22
  74. data/docs/use_cases/UC-022-event-registry.md +15 -21
  75. data/docs/use_cases/backlog.md +119 -87
  76. data/e11y.gemspec +2 -2
  77. data/gems/e11y-devtools/README.md +158 -0
  78. data/gems/e11y-devtools/config/routes.rb +15 -0
  79. data/gems/e11y-devtools/e11y-devtools.gemspec +25 -0
  80. data/gems/e11y-devtools/exe/e11y +34 -0
  81. data/gems/e11y-devtools/frontend/.gitignore +24 -0
  82. data/gems/e11y-devtools/frontend/README.md +51 -0
  83. data/gems/e11y-devtools/frontend/index.html +14 -0
  84. data/gems/e11y-devtools/frontend/package-lock.json +3707 -0
  85. data/gems/e11y-devtools/frontend/package.json +28 -0
  86. data/gems/e11y-devtools/frontend/public/mocks/v1/events/recent.json +4205 -0
  87. data/gems/e11y-devtools/frontend/public/mocks/v1/interactions.json +194 -0
  88. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/0a2e04027cfa22d014bc22e8b27cd913/events.json +86 -0
  89. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/0e1543af6a630fb3af6b52283154b3e0/events.json +169 -0
  90. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/1838b691faa49564f97db8592ff3978d/events.json +78 -0
  91. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/29f198f6588dacffb687777eb5f8f118/events.json +197 -0
  92. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/34bc3c9c0097de28a7a6f99b90a8e7bc/events.json +194 -0
  93. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/3ba6c20d068ab9cee00e51b180e66444/events.json +184 -0
  94. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/435bfd8f17b9009146a79812d7c3726d/events.json +144 -0
  95. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/4c7676e3fe668e99edb2b94d7d5678a9/events.json +222 -0
  96. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/6daf0d47974bedfc55d5de7004a3ea9f/events.json +194 -0
  97. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/8a81ada42834d15f287bb40010043605/events.json +194 -0
  98. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/8c0a98900edaae105469df8daedccf02/events.json +198 -0
  99. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/8e4f645180f8a7d1dce426b07380466b/events.json +222 -0
  100. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/93db346fa5d44a032605a13b627f4b80/events.json +128 -0
  101. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/98ff6146faf7bd9be8bd03a8275817ba/events.json +223 -0
  102. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/9997ddd0247bc7e25f2ca7a5c415c93d/events.json +197 -0
  103. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/99e35f8ef3baedd798cc4fd085980ad9/events.json +194 -0
  104. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/b4f3095c1909924cbc98889a86c83d6d/events.json +131 -0
  105. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/b54b7fc32b7575a7110de809d11ccda0/events.json +128 -0
  106. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/c0b48033fa06746bcc5886745e053cff/events.json +169 -0
  107. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/c44649ac76701b4558927cd2305ab535/events.json +169 -0
  108. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/d601ae3320057580a39dbdac2edfdf4a/events.json +248 -0
  109. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/e67e724bab422d2b52eeb49635e512e1/events.json +194 -0
  110. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/e6c72765a28f158a8485b35fa63f73da/events.json +194 -0
  111. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/f541b87405c9a54819b18ebe529f6419/events.json +194 -0
  112. data/gems/e11y-devtools/frontend/scripts/generate_mocks.rb +397 -0
  113. data/gems/e11y-devtools/frontend/src/App.svelte +827 -0
  114. data/gems/e11y-devtools/frontend/src/components/Fab.svelte +19 -0
  115. data/gems/e11y-devtools/frontend/src/components/FilterBar.svelte +38 -0
  116. data/gems/e11y-devtools/frontend/src/components/FullscreenPanel.svelte +82 -0
  117. data/gems/e11y-devtools/frontend/src/components/InteractionsTimeline.svelte +264 -0
  118. data/gems/e11y-devtools/frontend/src/components/RecentHistogram.svelte +354 -0
  119. data/gems/e11y-devtools/frontend/src/lib/api.ts +37 -0
  120. data/gems/e11y-devtools/frontend/src/lib/eventIdentity.ts +12 -0
  121. data/gems/e11y-devtools/frontend/src/lib/format.ts +37 -0
  122. data/gems/e11y-devtools/frontend/src/lib/listFilter.ts +43 -0
  123. data/gems/e11y-devtools/frontend/src/lib/recentVolume.ts +80 -0
  124. data/gems/e11y-devtools/frontend/src/lib/router.ts +12 -0
  125. data/gems/e11y-devtools/frontend/src/lib/transitions.ts +34 -0
  126. data/gems/e11y-devtools/frontend/src/lib/viewportOrigin.ts +25 -0
  127. data/gems/e11y-devtools/frontend/src/main.ts +8 -0
  128. data/gems/e11y-devtools/frontend/src/overlay-entry.ts +24 -0
  129. data/gems/e11y-devtools/frontend/src/overlay.css +1080 -0
  130. data/gems/e11y-devtools/frontend/svelte.config.js +2 -0
  131. data/gems/e11y-devtools/frontend/test_puppeteer.js +41 -0
  132. data/gems/e11y-devtools/frontend/test_scale.js +3 -0
  133. data/gems/e11y-devtools/frontend/tsconfig.app.json +21 -0
  134. data/gems/e11y-devtools/frontend/tsconfig.json +7 -0
  135. data/gems/e11y-devtools/frontend/tsconfig.node.json +26 -0
  136. data/gems/e11y-devtools/frontend/vite.config.ts +36 -0
  137. data/gems/e11y-devtools/lib/e11y/devtools/mcp/server.rb +96 -0
  138. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tool_base.rb +25 -0
  139. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/clear.rb +31 -0
  140. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/errors.rb +35 -0
  141. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/event_detail.rb +33 -0
  142. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/events_by_trace.rb +33 -0
  143. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/interactions.rb +40 -0
  144. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/recent_events.rb +34 -0
  145. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/search.rb +34 -0
  146. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/stats.rb +30 -0
  147. data/gems/e11y-devtools/lib/e11y/devtools/overlay/assets/overlay.js +20 -0
  148. data/gems/e11y-devtools/lib/e11y/devtools/overlay/controller.rb +94 -0
  149. data/gems/e11y-devtools/lib/e11y/devtools/overlay/engine.rb +26 -0
  150. data/gems/e11y-devtools/lib/e11y/devtools/overlay/middleware.rb +80 -0
  151. data/gems/e11y-devtools/lib/e11y/devtools/overlay/rails_controller.rb +67 -0
  152. data/gems/e11y-devtools/lib/e11y/devtools/tui/app.rb +262 -0
  153. data/gems/e11y-devtools/lib/e11y/devtools/tui/grouping.rb +66 -0
  154. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/event_detail.rb +62 -0
  155. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/event_list.rb +70 -0
  156. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/interaction_list.rb +47 -0
  157. data/gems/e11y-devtools/lib/e11y/devtools/version.rb +8 -0
  158. data/gems/e11y-devtools/lib/e11y/devtools.rb +13 -0
  159. data/gems/e11y-devtools/spec/e11y/devtools/mcp/tools_spec.rb +107 -0
  160. data/gems/e11y-devtools/spec/e11y/devtools/overlay/controller_spec.rb +91 -0
  161. data/gems/e11y-devtools/spec/e11y/devtools/overlay/middleware_spec.rb +46 -0
  162. data/gems/e11y-devtools/spec/e11y/devtools/tui/app_spec.rb +85 -0
  163. data/gems/e11y-devtools/spec/e11y/devtools/tui/grouping_spec.rb +64 -0
  164. data/gems/e11y-devtools/spec/spec_helper.rb +5 -0
  165. data/gems/e11y-devtools/spec/tui/widgets/event_list_spec.rb +44 -0
  166. data/gems/e11y-devtools/spec/tui/widgets/interaction_list_spec.rb +62 -0
  167. data/lib/e11y/adapters/audit_encrypted.rb +53 -11
  168. data/lib/e11y/adapters/base.rb +33 -34
  169. data/lib/e11y/adapters/dev_log/file_store.rb +143 -0
  170. data/lib/e11y/adapters/dev_log/query.rb +219 -0
  171. data/lib/e11y/adapters/dev_log.rb +118 -0
  172. data/lib/e11y/adapters/file.rb +3 -6
  173. data/lib/e11y/adapters/in_memory.rb +52 -5
  174. data/lib/e11y/adapters/in_memory_test.rb +29 -0
  175. data/lib/e11y/adapters/loki.rb +58 -23
  176. data/lib/e11y/adapters/null.rb +82 -0
  177. data/lib/e11y/adapters/opentelemetry_collector.rb +183 -0
  178. data/lib/e11y/adapters/otel_logs.rb +136 -23
  179. data/lib/e11y/adapters/sentry.rb +4 -7
  180. data/lib/e11y/adapters/stdout.rb +73 -7
  181. data/lib/e11y/adapters/yabeda.rb +153 -29
  182. data/lib/e11y/buffers/adaptive_buffer.rb +3 -17
  183. data/lib/e11y/buffers/{request_scoped_buffer.rb → ephemeral_buffer.rb} +72 -58
  184. data/lib/e11y/buffers/ring_buffer.rb +3 -16
  185. data/lib/e11y/configuration.rb +272 -0
  186. data/lib/e11y/console.rb +10 -17
  187. data/lib/e11y/current.rb +53 -1
  188. data/lib/e11y/debug/pipeline_inspector.rb +96 -0
  189. data/lib/e11y/documentation/generator.rb +48 -0
  190. data/lib/e11y/event/base.rb +176 -82
  191. data/lib/e11y/event/value_sampling_config.rb +1 -5
  192. data/lib/e11y/events/rails/database/query.rb +1 -4
  193. data/lib/e11y/events/rails/job/failed.rb +2 -0
  194. data/lib/e11y/instruments/active_job.rb +44 -12
  195. data/lib/e11y/instruments/rails_instrumentation.rb +49 -24
  196. data/lib/e11y/instruments/sidekiq.rb +135 -31
  197. data/lib/e11y/linters/base.rb +11 -0
  198. data/lib/e11y/linters/pii/pii_declaration_linter.rb +120 -0
  199. data/lib/e11y/linters/slo/config_consistency_linter.rb +76 -0
  200. data/lib/e11y/linters/slo/explicit_declaration_linter.rb +36 -0
  201. data/lib/e11y/linters/slo/slo_status_from_linter.rb +41 -0
  202. data/lib/e11y/logger/bridge.rb +26 -7
  203. data/lib/e11y/metrics/cardinality_protection.rb +10 -15
  204. data/lib/e11y/metrics/cardinality_tracker.rb +16 -6
  205. data/lib/e11y/metrics/registry.rb +3 -5
  206. data/lib/e11y/metrics/test_backend.rb +62 -0
  207. data/lib/e11y/metrics.rb +56 -10
  208. data/lib/e11y/middleware/adapter_resolver.rb +40 -0
  209. data/lib/e11y/middleware/audit_signing.rb +43 -6
  210. data/lib/e11y/middleware/baggage_protection.rb +75 -0
  211. data/lib/e11y/middleware/dev_log_source.rb +24 -0
  212. data/lib/e11y/middleware/event_slo.rb +23 -9
  213. data/lib/e11y/middleware/otel_span.rb +23 -0
  214. data/lib/e11y/middleware/pii_filter.rb +104 -75
  215. data/lib/e11y/middleware/rate_limiting.rb +54 -27
  216. data/lib/e11y/middleware/request.rb +70 -23
  217. data/lib/e11y/middleware/routing.rb +78 -21
  218. data/lib/e11y/middleware/sampling.rb +66 -17
  219. data/lib/e11y/middleware/self_monitoring_emit.rb +39 -0
  220. data/lib/e11y/middleware/trace_context.rb +45 -10
  221. data/lib/e11y/middleware/track_latency.rb +34 -0
  222. data/lib/e11y/middleware/validation.rb +7 -16
  223. data/lib/e11y/middleware/versioning.rb +26 -22
  224. data/lib/e11y/opentelemetry/semantic_conventions.rb +109 -0
  225. data/lib/e11y/opentelemetry/span_creator.rb +142 -0
  226. data/lib/e11y/pii/patterns.rb +12 -1
  227. data/lib/e11y/pipeline/builder.rb +4 -4
  228. data/lib/e11y/presets/audit_event.rb +13 -2
  229. data/lib/e11y/railtie.rb +52 -14
  230. data/lib/e11y/registry.rb +306 -0
  231. data/lib/e11y/reliability/circuit_breaker.rb +19 -21
  232. data/lib/e11y/reliability/dlq/base.rb +71 -0
  233. data/lib/e11y/reliability/dlq/file_adapter.rb +301 -0
  234. data/lib/e11y/reliability/dlq/file_storage.rb +63 -34
  235. data/lib/e11y/reliability/dlq/filter.rb +37 -54
  236. data/lib/e11y/reliability/retry_handler.rb +26 -29
  237. data/lib/e11y/reliability/retry_rate_limiter.rb +3 -11
  238. data/lib/e11y/sampling/error_spike_detector.rb +0 -2
  239. data/lib/e11y/sampling/load_monitor.rb +5 -9
  240. data/lib/e11y/sampling/stratified_tracker.rb +18 -0
  241. data/lib/e11y/self_monitoring/buffer_monitor.rb +2 -0
  242. data/lib/e11y/self_monitoring/performance_monitor.rb +19 -61
  243. data/lib/e11y/self_monitoring/reliability_monitor.rb +4 -74
  244. data/lib/e11y/slo/config_loader.rb +40 -0
  245. data/lib/e11y/slo/config_validator.rb +58 -0
  246. data/lib/e11y/slo/dashboard_generator.rb +122 -0
  247. data/lib/e11y/slo/event_driven.rb +8 -0
  248. data/lib/e11y/slo/tracker.rb +31 -4
  249. data/lib/e11y/testing/have_tracked_event_matcher.rb +190 -0
  250. data/lib/e11y/testing/rspec_matchers.rb +21 -0
  251. data/lib/e11y/testing/snapshot_matcher.rb +86 -0
  252. data/lib/e11y/trace_context/sampler.rb +35 -0
  253. data/lib/e11y/tracing/faraday_middleware.rb +31 -0
  254. data/lib/e11y/tracing/net_http_patch.rb +33 -0
  255. data/lib/e11y/tracing/propagator.rb +144 -0
  256. data/lib/e11y/tracing.rb +47 -0
  257. data/lib/e11y/version.rb +1 -1
  258. data/lib/e11y/versioning/version_extractor.rb +32 -0
  259. data/lib/e11y.rb +123 -266
  260. data/lib/generators/e11y/event/event_generator.rb +22 -0
  261. data/lib/generators/e11y/event/templates/event.rb.tt +16 -0
  262. data/lib/generators/e11y/grafana_dashboard/grafana_dashboard_generator.rb +30 -0
  263. data/lib/generators/e11y/grafana_dashboard/templates/e11y_dashboard.json +81 -0
  264. data/lib/generators/e11y/install/install_generator.rb +34 -0
  265. data/lib/generators/e11y/install/templates/e11y.rb +239 -0
  266. data/lib/generators/e11y/prometheus_alerts/prometheus_alerts_generator.rb +29 -0
  267. data/lib/generators/e11y/prometheus_alerts/templates/e11y_alerts.yml +28 -0
  268. data/lib/tasks/e11y_docs.rake +30 -0
  269. data/lib/tasks/e11y_events.rake +71 -0
  270. data/lib/tasks/e11y_lint.rake +91 -0
  271. data/lib/tasks/e11y_slo.rake +29 -0
  272. metadata +186 -39
  273. data/docs/ADR-003-slo-observability.md +0 -3337
  274. data/docs/ADR-010-developer-experience.md +0 -2166
  275. data/docs/API-REFERENCE-L28.md +0 -914
  276. data/docs/COMPREHENSIVE-CONFIGURATION.md +0 -2366
  277. data/docs/CONTRIBUTING.md +0 -312
  278. data/docs/IMPLEMENTATION_NOTES.md +0 -2804
  279. data/docs/IMPLEMENTATION_PLAN.md +0 -1971
  280. data/docs/IMPLEMENTATION_PLAN_ARCHITECTURE.md +0 -586
  281. data/docs/PLAN.md +0 -148
  282. data/docs/README.md +0 -296
  283. data/docs/design/00-memory-optimization.md +0 -593
  284. data/docs/guides/MIGRATION-L27-L28.md +0 -692
  285. data/docs/guides/PERFORMANCE-BENCHMARKS.md +0 -434
  286. data/docs/guides/README.md +0 -44
  287. data/docs/use_cases/UC-003-pattern-based-metrics.md +0 -1627
  288. data/lib/e11y/adapters/registry.rb +0 -141
@@ -8,7 +8,7 @@ module E11y
8
8
  #
9
9
  # Transparent wrapper around Rails.logger that:
10
10
  # 1. Delegates all calls to the original logger (preserves Rails behavior)
11
- # 2. Tracks log calls as E11y events (when logger_bridge.enabled = true)
11
+ # 2. Tracks log calls as E11y events (when logger_bridge_enabled = true)
12
12
  #
13
13
  # **Why SimpleDelegator instead of full replacement:**
14
14
  # - ✅ Simpler: No need to reimplement entire Logger API
@@ -17,12 +17,12 @@ module E11y
17
17
  # - ✅ Rails Way: Extends functionality without replacing core components
18
18
  #
19
19
  # @example Basic usage
20
- # # Automatically enabled by E11y::Railtie if config.logger_bridge.enabled = true
20
+ # # Automatically enabled by E11y::Railtie if config.logger_bridge_enabled = true
21
21
  # Rails.logger = E11y::Logger::Bridge.new(Rails.logger)
22
22
  #
23
23
  # @example Manual setup
24
24
  # E11y.configure do |config|
25
- # config.logger_bridge.enabled = true # Wrap Rails.logger and send logs to E11y
25
+ # config.logger_bridge_enabled = true # Wrap Rails.logger and send logs to E11y
26
26
  # end
27
27
  #
28
28
  # @see ADR-008 §7 (Rails.logger Migration)
@@ -34,7 +34,7 @@ module E11y
34
34
  #
35
35
  # @return [void]
36
36
  def self.setup!
37
- return unless E11y.config.logger_bridge&.enabled
37
+ return unless E11y.config.logger_bridge_enabled
38
38
  return unless defined?(::Rails)
39
39
 
40
40
  # Wrap Rails.logger (preserves original behavior)
@@ -53,6 +53,8 @@ module E11y
53
53
  ::Logger::FATAL => :fatal,
54
54
  ::Logger::UNKNOWN => :warn
55
55
  }
56
+ @track_severities_set = build_track_severities_set(E11y.config.logger_bridge_track_severities)
57
+ @ignore_patterns = build_compiled_patterns(E11y.config.logger_bridge_ignore_patterns)
56
58
  end
57
59
 
58
60
  # Intercept logger methods to track to E11y
@@ -124,18 +126,22 @@ module E11y
124
126
  # @param message [String, nil] Log message
125
127
  # @yield Block that returns log message
126
128
  # @return [void]
127
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
128
129
  # Logger tracking requires message extraction, validation, event class lookup, and error handling
129
130
  def track_to_e11y(severity, message = nil)
130
131
  # Extract message
131
132
  msg = message || (block_given? ? yield : nil)
132
133
  return if msg.nil? || (msg.respond_to?(:empty?) && msg.empty?)
133
134
 
135
+ msg_str = msg.to_s
136
+
137
+ return if @track_severities_set && !@track_severities_set.include?(severity)
138
+ return if @ignore_patterns.any? { |re| re.match?(msg_str) }
139
+
134
140
  # Track to E11y using severity-specific class
135
141
  require "e11y/events/rails/log"
136
142
  event_class = event_class_for_severity(severity)
137
143
  event_class.track(
138
- message: msg.to_s,
144
+ message: msg_str,
139
145
  caller_location: extract_caller_location
140
146
  )
141
147
  rescue StandardError => e
@@ -143,7 +149,6 @@ module E11y
143
149
  # In development/test, you might want to log this
144
150
  warn "E11y logger tracking failed: #{e.message}" if defined?(::Rails) && ::Rails.env.development?
145
151
  end
146
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
147
152
 
148
153
  # Get event class for severity
149
154
  # @param severity [Symbol] E11y severity
@@ -162,6 +167,20 @@ module E11y
162
167
  end
163
168
  # rubocop:enable Lint/DuplicateBranch
164
169
 
170
+ def build_track_severities_set(severities)
171
+ return nil if severities.nil? || (severities.respond_to?(:empty?) && severities.empty?)
172
+
173
+ Set.new(Array(severities).map(&:to_sym))
174
+ end
175
+
176
+ def build_compiled_patterns(patterns)
177
+ return [] if patterns.nil? || !patterns.respond_to?(:any?) || patterns.none?
178
+
179
+ Array(patterns).map do |p|
180
+ p.is_a?(Regexp) ? p : Regexp.new(Regexp.escape(p.to_s))
181
+ end.freeze
182
+ end
183
+
165
184
  # Extract caller location (first caller outside E11y)
166
185
  # @return [String, nil] Caller location string
167
186
  def extract_caller_location
@@ -7,11 +7,10 @@ module E11y
7
7
  module Metrics
8
8
  # Cardinality protection for metrics labels.
9
9
  #
10
- # Implements 4-layer defense system to prevent cardinality explosions:
10
+ # Implements 3-layer defense system to prevent cardinality explosions:
11
11
  # 1. Universal Denylist - Block high-cardinality fields (user_id, order_id, etc.)
12
12
  # 2. Per-Metric Limits - Track unique values per metric, drop if exceeded
13
- # 3. Dynamic Monitoring - Alert when approaching limits
14
- # 4. Dynamic Actions - Auto-relabeling, alerting, or dropping on overflow
13
+ # 3. Dynamic Actions - Drop, alert, or relabel on overflow
15
14
  #
16
15
  # Now supports optional relabeling to reduce cardinality while preserving signal.
17
16
  #
@@ -37,7 +36,7 @@ module E11y
37
36
  # @see ADR-002 §4 (Cardinality Protection)
38
37
  # @see UC-013 (High Cardinality Protection)
39
38
  # rubocop:disable Metrics/ClassLength
40
- # Cardinality protection is a cohesive 4-layer defense system against metric explosions
39
+ # Cardinality protection is a cohesive 3-layer defense system against metric explosions
41
40
  class CardinalityProtection
42
41
  # Universal denylist - high-cardinality fields that should NEVER be labels
43
42
  UNIVERSAL_DENYLIST = %i[
@@ -64,7 +63,7 @@ module E11y
64
63
  # Default per-metric cardinality limit
65
64
  DEFAULT_CARDINALITY_LIMIT = 1000
66
65
 
67
- # Overflow strategies (Layer 4: Dynamic Actions)
66
+ # Overflow strategies (Layer 3: Dynamic Actions)
68
67
  OVERFLOW_STRATEGIES = %i[drop alert relabel].freeze
69
68
 
70
69
  # Default overflow strategy
@@ -85,7 +84,6 @@ module E11y
85
84
  # @option config [Float] :alert_threshold (0.8) Alert when cardinality reaches this ratio
86
85
  # @option config [Proc] :alert_callback Optional callback when alert triggered
87
86
  # @option config [Boolean] :auto_relabel (false) Auto-relabel to [OTHER] on overflow
88
- # rubocop:disable Metrics/AbcSize
89
87
  # Cardinality protection initialization requires extracting multiple config options and setting up components
90
88
  def initialize(config = {})
91
89
  @cardinality_limit = config.fetch(:cardinality_limit, DEFAULT_CARDINALITY_LIMIT)
@@ -109,7 +107,6 @@ module E11y
109
107
  @overflow_counts = Hash.new(0)
110
108
  @overflow_mutex = Mutex.new
111
109
  end
112
- # rubocop:enable Metrics/AbcSize
113
110
 
114
111
  # Define relabeling rule for a label
115
112
  #
@@ -222,7 +219,6 @@ module E11y
222
219
 
223
220
  # Check if approaching alert threshold (Layer 3: Monitoring)
224
221
  # @param metric_name [String] Metric name
225
- # rubocop:disable Metrics/MethodLength
226
222
  # Alert threshold checking requires calculating ratio, checking conditions, and sending detailed alerts
227
223
  def check_alert_threshold(metric_name)
228
224
  return unless @alert_threshold
@@ -252,7 +248,6 @@ module E11y
252
248
  # Track metric
253
249
  track_cardinality_metric(metric_name, :threshold_exceeded, current_cardinality)
254
250
  end
255
- # rubocop:enable Metrics/MethodLength
256
251
 
257
252
  # Handle overflow when cardinality limit exceeded (Layer 4: Dynamic Actions)
258
253
  # @param metric_name [String] Metric name
@@ -311,9 +306,11 @@ module E11y
311
306
  severity: :error
312
307
  )
313
308
 
314
- # Also log warning
315
- warn "E11y Metrics: Cardinality limit exceeded for #{metric_name}:#{key} " \
316
- "(limit: #{@cardinality_limit}, current: #{current_cardinality})"
309
+ # Also log warning (via E11y.logger so it respects Rails.logger in test env)
310
+ E11y.logger&.warn(
311
+ "E11y Metrics: Cardinality limit exceeded for #{metric_name}:#{key} " \
312
+ "(limit: #{@cardinality_limit}, current: #{current_cardinality})"
313
+ )
317
314
  end
318
315
 
319
316
  # Handle relabel strategy - relabel to [OTHER]
@@ -385,7 +382,6 @@ module E11y
385
382
  # @param metric_name [String] Metric name
386
383
  # @param action [Symbol] Action type (:threshold_exceeded, :drop, :alert, :relabel)
387
384
  # @param value [Integer] Metric value
388
- # rubocop:disable Metrics/MethodLength
389
385
  # Cardinality tracking requires incrementing overflow counters and updating gauge metrics
390
386
  def track_cardinality_metric(metric_name, action, value)
391
387
  return unless defined?(E11y::Metrics)
@@ -408,9 +404,8 @@ module E11y
408
404
  )
409
405
  rescue StandardError => e
410
406
  # Don't fail on metrics tracking errors
411
- warn "E11y: Failed to track cardinality metric: #{e.message}"
407
+ E11y.logger&.warn("E11y: Failed to track cardinality metric: #{e.message}")
412
408
  end
413
- # rubocop:enable Metrics/MethodLength
414
409
  end
415
410
  # rubocop:enable Metrics/ClassLength
416
411
  end
@@ -33,13 +33,15 @@ module E11y
33
33
  # Records unique label values per metric+label combination.
34
34
  # Thread-safe operation.
35
35
  #
36
- # @param metric_name [String] Metric name
36
+ # @param metric_name [String, Symbol] Metric name
37
37
  # @param label_key [Symbol, String] Label key
38
38
  # @param label_value [Object] Label value to track
39
39
  # @return [Boolean] true if within limit, false if limit exceeded
40
40
  def track(metric_name, label_key, label_value)
41
41
  @mutex.synchronize do
42
- value_set = @tracker[metric_name][label_key]
42
+ # Normalize metric_name to string for consistent key access
43
+ metric_key = metric_name.to_s
44
+ value_set = @tracker[metric_key][label_key]
43
45
 
44
46
  # Allow if already tracked (existing value)
45
47
  return true if value_set.include?(label_value)
@@ -66,7 +68,9 @@ module E11y
66
68
  # @return [void]
67
69
  def force_track(metric_name, label_key, label_value)
68
70
  @mutex.synchronize do
69
- value_set = @tracker[metric_name][label_key]
71
+ # Normalize metric_name to string for consistent key access
72
+ metric_key = metric_name.to_s
73
+ value_set = @tracker[metric_key][label_key]
70
74
  value_set.add(label_value) unless value_set.include?(label_value)
71
75
  end
72
76
  end
@@ -78,7 +82,9 @@ module E11y
78
82
  # @return [Boolean] true if at or above limit
79
83
  def exceeded?(metric_name, label_key)
80
84
  @mutex.synchronize do
81
- @tracker.dig(metric_name, label_key)&.size.to_i >= @limit
85
+ # Normalize metric_name to string for consistent key access
86
+ metric_key = metric_name.to_s
87
+ @tracker.dig(metric_key, label_key)&.size.to_i >= @limit
82
88
  end
83
89
  end
84
90
 
@@ -89,7 +95,9 @@ module E11y
89
95
  # @return [Integer] Number of unique values tracked
90
96
  def cardinality(metric_name, label_key)
91
97
  @mutex.synchronize do
92
- @tracker.dig(metric_name, label_key)&.size || 0
98
+ # Normalize metric_name to string for consistent key access
99
+ metric_key = metric_name.to_s
100
+ @tracker.dig(metric_key, label_key)&.size || 0
93
101
  end
94
102
  end
95
103
 
@@ -99,7 +107,9 @@ module E11y
99
107
  # @return [Hash{Symbol => Integer}] Label key => cardinality
100
108
  def cardinalities(metric_name)
101
109
  @mutex.synchronize do
102
- metric_data = @tracker[metric_name]
110
+ # Normalize metric_name to string for consistent key access
111
+ metric_key = metric_name.to_s
112
+ metric_data = @tracker[metric_key]
103
113
  metric_data.transform_values(&:size)
104
114
  end
105
115
  end
@@ -6,7 +6,7 @@ module E11y
6
6
  module Metrics
7
7
  # Registry for metric configurations.
8
8
  #
9
- # Stores metric definitions and provides pattern-based matching.
9
+ # Stores metric definitions and provides event-name matching.
10
10
  # This is a singleton class - use Registry.instance to access it.
11
11
  # All metrics (global, event-level, preset) are registered here for validation.
12
12
  #
@@ -144,7 +144,6 @@ module E11y
144
144
  # Validate metric configuration
145
145
  # @param config [Hash] Metric configuration
146
146
  # @raise [ArgumentError] if configuration is invalid
147
- # rubocop:disable Metrics/AbcSize
148
147
  def validate_config!(config)
149
148
  raise ArgumentError, "Metric type is required" unless config[:type]
150
149
  raise ArgumentError, "Invalid metric type: #{config[:type]}" unless %i[counter histogram
@@ -156,14 +155,13 @@ module E11y
156
155
 
157
156
  raise ArgumentError, "Value extractor is required for #{config[:type]} metrics"
158
157
  end
159
- # rubocop:enable Metrics/AbcSize
160
158
 
161
159
  # Validate that new metric doesn't conflict with existing one
162
160
  # @param existing [Hash] Existing metric configuration
163
161
  # @param new_config [Hash] New metric configuration
164
162
  # @raise [TypeConflictError] if types don't match
165
163
  # @raise [LabelConflictError] if labels don't match
166
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
164
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
167
165
  # Conflict validation requires checking type and labels with detailed error messages
168
166
  def validate_no_conflicts!(existing, new_config)
169
167
  # Check 1: Type must match
@@ -215,7 +213,7 @@ module E11y
215
213
  Using existing buckets.
216
214
  WARNING
217
215
  end
218
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
216
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
219
217
 
220
218
  # Compile glob pattern to regex
221
219
  # @param pattern [String] Glob pattern (e.g., "order.*", "user.*.created")
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Metrics
5
+ # In-memory metrics backend for tests.
6
+ #
7
+ # Records all metric calls so test assertions can verify what was tracked
8
+ # without using mocks on E11y::Metrics directly.
9
+ #
10
+ # @example
11
+ # backend = E11y::Metrics::TestBackend.new
12
+ # E11y::Metrics.instance_variable_set(:@backend, backend)
13
+ #
14
+ # MyService.call
15
+ #
16
+ # expect(backend.increment_count(:orders_total)).to eq(1)
17
+ # expect(backend.increments).to include(hash_including(name: :orders_total))
18
+ class TestBackend
19
+ attr_reader :increments, :histograms, :gauges
20
+
21
+ def initialize
22
+ reset!
23
+ end
24
+
25
+ # @param name [Symbol] Metric name
26
+ # @param labels [Hash] Metric labels
27
+ # @param value [Integer] Increment amount
28
+ def increment(name, labels = {}, value: 1)
29
+ @increments << { name: name, labels: labels, value: value }
30
+ end
31
+
32
+ # @param name [Symbol] Metric name
33
+ # @param value [Numeric] Observed value
34
+ # @param labels [Hash] Metric labels
35
+ def histogram(name, value, labels = {}, buckets: nil) # rubocop:todo Lint/UnusedMethodArgument
36
+ @histograms << { name: name, value: value, labels: labels }
37
+ end
38
+
39
+ # @param name [Symbol] Metric name
40
+ # @param value [Numeric] Gauge value
41
+ # @param labels [Hash] Metric labels
42
+ def gauge(name, value, labels = {})
43
+ @gauges << { name: name, value: value, labels: labels }
44
+ end
45
+
46
+ # Reset all recorded metrics.
47
+ def reset!
48
+ @increments = []
49
+ @histograms = []
50
+ @gauges = []
51
+ end
52
+
53
+ # Count how many times a counter was incremented (any labels).
54
+ #
55
+ # @param name [Symbol] Metric name
56
+ # @return [Integer]
57
+ def increment_count(name)
58
+ @increments.count { |r| r[:name] == name }
59
+ end
60
+ end
61
+ end
62
+ end
data/lib/e11y/metrics.rb CHANGED
@@ -21,18 +21,41 @@ module E11y
21
21
  # @see ADR-002 §3 (Metrics Integration)
22
22
  # @see ADR-016 §3 (Self-Monitoring Metrics)
23
23
  module Metrics
24
+ # No-op metrics backend used when no real backend (e.g. Yabeda) is configured.
25
+ # Accepts all metric calls and silently discards them so callers never
26
+ # need to guard against a nil backend.
27
+ class NullBackend
28
+ def increment(_name, _labels = {}, value: 1); end
29
+ def histogram(_name, _value, _labels = {}, buckets: nil); end
30
+ def gauge(_name, _value, _labels = {}); end
31
+ end
32
+
24
33
  class << self
25
34
  # Track a counter metric (monotonically increasing value).
26
35
  #
27
- # @param name [Symbol] Metric name (e.g., :http_requests_total)
36
+ # Accepts dotted names (e.g., "e11y.ephemeral_buffer.flushed") and normalizes to
37
+ # underscores. DLQ metrics get _total suffix. Labels[:events] is used as value if present.
38
+ # Safe: no-op when backend unavailable, rescues errors.
39
+ #
40
+ # @param name [Symbol, String] Metric name (e.g., :http_requests_total or "e11y.ephemeral_buffer.flushed")
28
41
  # @param labels [Hash] Metric labels (e.g., { method: 'GET', status: 200 })
29
- # @param value [Integer] Increment value (default: 1)
42
+ # @param value [Integer] Increment value (default: 1, overridden by labels[:events] if present)
30
43
  # @return [void]
31
44
  #
32
45
  # @example
33
- # E11y::Metrics.increment(:e11y_events_tracked, { event_type: 'order.created' })
34
- def increment(name, labels = {}, value: 1)
35
- backend&.increment(name, labels, value: value)
46
+ # E11y::Metrics.increment(:e11y_events_tracked, event_type: 'order.created')
47
+ # E11y::Metrics.increment("e11y.ephemeral_buffer.flushed_on_error", value: 5)
48
+ def increment(name, labels = {}, value: 1, **labels_kw)
49
+ return unless backend
50
+
51
+ labels = labels.merge(labels_kw) unless labels_kw.empty?
52
+ value = labels.delete(:events) if labels.key?(:events)
53
+ value ||= 1
54
+
55
+ normalized = normalized_metric_name(name)
56
+ backend.increment(normalized, labels, value: value)
57
+ rescue StandardError => e
58
+ E11y.logger&.debug("E11y metrics: #{e.message}")
36
59
  end
37
60
 
38
61
  # Track a histogram metric (distribution of values).
@@ -44,9 +67,15 @@ module E11y
44
67
  # @return [void]
45
68
  #
46
69
  # @example
47
- # E11y::Metrics.histogram(:e11y_track_duration_seconds, 0.0005, { event_type: 'order.created' })
48
- def histogram(name, value, labels = {}, buckets: nil)
49
- backend&.histogram(name, value, labels, buckets: buckets)
70
+ # E11y::Metrics.histogram(:e11y_track_duration_seconds, 0.0005, event_type: 'order.created')
71
+ def histogram(name, value, labels = {}, buckets: nil, **labels_kw)
72
+ return unless backend
73
+
74
+ labels = labels.merge(labels_kw) unless labels_kw.empty?
75
+ normalized = normalized_metric_name(name)
76
+ backend.histogram(normalized, value, labels, buckets: buckets)
77
+ rescue StandardError => e
78
+ E11y.logger&.debug("E11y metrics: #{e.message}")
50
79
  end
51
80
 
52
81
  # Track a gauge metric (current value that can go up or down).
@@ -81,10 +110,27 @@ module E11y
81
110
  # @api private
82
111
  def reset_backend!
83
112
  remove_instance_variable(:@backend) if defined?(@backend)
113
+ @name_cache = nil if defined?(@name_cache)
84
114
  end
85
115
 
86
116
  private
87
117
 
118
+ # Normalize metric name: dots to underscores, DLQ metrics get _total suffix.
119
+ # Cached to avoid repeated string allocations for hot-path metrics.
120
+ #
121
+ # @param name [Symbol, String] Raw metric name
122
+ # @return [Symbol] Normalized name for Prometheus (e.g., e11y_ephemeral_buffer_flushed_on_error)
123
+ def normalized_metric_name(name)
124
+ @name_cache ||= {}
125
+ @name_cache[name] ||= compute_normalized_name(name)
126
+ end
127
+
128
+ def compute_normalized_name(name)
129
+ s = name.to_s.tr(".", "_")
130
+ s = "#{s}_total" if s.include?("e11y_dlq_") && !s.end_with?("_total")
131
+ s.to_sym
132
+ end
133
+
88
134
  # Detect the metrics backend from configured adapters.
89
135
  #
90
136
  # @return [Object, nil] Metrics backend or nil
@@ -99,8 +145,8 @@ module E11y
99
145
  # rubocop:enable Style/ClassEqualityComparison
100
146
  return yabeda_adapter if yabeda_adapter
101
147
 
102
- # No backend configured noop
103
- nil
148
+ # No Yabeda adapter configured fall back to NullBackend
149
+ NullBackend.new
104
150
  end
105
151
  end
106
152
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Middleware
5
+ # Resolves target adapter names for an event (shared by PIIFilter and Routing).
6
+ #
7
+ # @api private
8
+ module AdapterResolver
9
+ # Resolve target adapters for event_data (explicit or routing rules).
10
+ #
11
+ # @param event_data [Hash] Event data with :adapters, :audit_event, :retention_until, etc.
12
+ # @return [Array<Symbol>] Target adapter names
13
+ def self.resolve(event_data)
14
+ if event_data[:adapters]&.any?
15
+ Array(event_data[:adapters]).map(&:to_sym)
16
+ else
17
+ apply_routing_rules(event_data)
18
+ end
19
+ end
20
+
21
+ def self.apply_routing_rules(event_data)
22
+ matched_adapters = []
23
+ rules = E11y.configuration.routing_rules || []
24
+
25
+ rules.each do |rule|
26
+ result = rule.call(event_data)
27
+ matched_adapters.concat(Array(result)) if result
28
+ rescue StandardError => e
29
+ warn "[E11y] Routing rule error: #{e.message}"
30
+ end
31
+
32
+ if matched_adapters.any?
33
+ matched_adapters.uniq
34
+ else
35
+ E11y.configuration.fallback_adapters || [:stdout]
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -44,9 +44,7 @@ module E11y
44
44
  def self.signing_key
45
45
  @signing_key ||= ENV.fetch("E11Y_AUDIT_SIGNING_KEY") do
46
46
  # Development fallback (NOT for production!)
47
- if defined?(::Rails) && ::Rails.env.production?
48
- raise E11y::Error, "E11Y_AUDIT_SIGNING_KEY must be set in production"
49
- end
47
+ raise E11y::Error, "E11Y_AUDIT_SIGNING_KEY must be set in production" if defined?(::Rails) && ::Rails.env.production?
50
48
 
51
49
  "development_key_#{SecureRandom.hex(32)}"
52
50
  end
@@ -73,20 +71,59 @@ module E11y
73
71
 
74
72
  # Verify signature (for testing/validation)
75
73
  #
74
+ # Uses the stored audit_canonical to recompute the expected HMAC and compares
75
+ # against audit_signature. Detects tampering with the canonical representation
76
+ # (e.g., if someone modifies the stored canonical in the audit log).
77
+ #
76
78
  # @param event_data [Hash] Event data with signature
77
79
  # @return [Boolean] true if signature is valid
78
80
  # rubocop:disable Naming/PredicateMethod
79
81
  def self.verify_signature(event_data)
80
82
  expected_signature = event_data[:audit_signature]
81
- canonical = event_data[:audit_canonical]
83
+ return false unless expected_signature
82
84
 
83
- return false unless expected_signature && canonical
85
+ # Recompute canonical from CURRENT payload (detects payload tampering)
86
+ recomputed = canonical_representation(event_data)
87
+ # Verify stored canonical matches recomputed (detects canonical tampering)
88
+ return false if event_data[:audit_canonical] && event_data[:audit_canonical] != recomputed
84
89
 
85
- actual_signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key, canonical)
90
+ actual_signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key, recomputed)
86
91
  actual_signature == expected_signature
87
92
  end
88
93
  # rubocop:enable Naming/PredicateMethod
89
94
 
95
+ # Create canonical representation for signing (class method for verification)
96
+ #
97
+ # @param event_data [Hash] Event data
98
+ # @return [String] Canonical JSON string
99
+ def self.canonical_representation(event_data)
100
+ # Extract fields that should be signed
101
+ signable_data = {
102
+ event_name: event_data[:event_name],
103
+ payload: event_data[:payload],
104
+ timestamp: event_data[:timestamp],
105
+ version: event_data[:version]
106
+ }
107
+
108
+ # Convert to sorted JSON (deterministic)
109
+ JSON.generate(sort_hash(signable_data))
110
+ end
111
+
112
+ # Sort hash recursively for deterministic JSON (class method)
113
+ #
114
+ # @param obj [Object] Object to sort
115
+ # @return [Object] Sorted object
116
+ def self.sort_hash(obj)
117
+ case obj
118
+ when Hash
119
+ obj.keys.sort.to_h { |k| [k, sort_hash(obj[k])] }
120
+ when Array
121
+ obj.map { |v| sort_hash(v) }
122
+ else
123
+ obj
124
+ end
125
+ end
126
+
90
127
  private
91
128
 
92
129
  # Check if event is marked as audit event
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Middleware
5
+ # BaggageProtection middleware — blocks PII from OpenTelemetry Baggage (ADR-006 §5.5, C08).
6
+ #
7
+ # When enabled, prepends an interceptor to OpenTelemetry::Baggage that blocks
8
+ # set_value calls for keys not in the allowlist. Prevents PII from propagating
9
+ # via W3C Baggage headers to downstream services.
10
+ #
11
+ # @example Configuration
12
+ # E11y.configure do |config|
13
+ # config.security_baggage_protection_enabled = true
14
+ # config.security_baggage_protection_allowed_keys = %w[trace_id span_id request_id]
15
+ # config.security_baggage_protection_block_mode = :warn # :silent, :warn, :raise
16
+ # end
17
+ #
18
+ # @see ADR-006 §5.5 OpenTelemetry Baggage PII Protection
19
+ # @see CONFLICT-ANALYSIS.md C08
20
+ class BaggageProtection < Base
21
+ middleware_zone :security
22
+
23
+ def initialize(app)
24
+ super
25
+ @protected = false
26
+ end
27
+
28
+ def call(event_data)
29
+ protect_baggage! if should_protect?
30
+ @app.call(event_data)
31
+ end
32
+
33
+ private
34
+
35
+ def should_protect?
36
+ return false unless defined?(OpenTelemetry::Baggage)
37
+ return false unless E11y.config&.security_baggage_protection_enabled
38
+
39
+ true
40
+ end
41
+
42
+ def protect_baggage!
43
+ return if @protected
44
+
45
+ @protected = true
46
+ cfg = E11y.config
47
+ allowed_keys = (cfg&.security_baggage_protection_allowed_keys || E11y::BAGGAGE_PROTECTION_DEFAULT_ALLOWED_KEYS).map(&:to_s)
48
+ block_mode = cfg&.security_baggage_protection_block_mode || :silent
49
+ logger = E11y.logger
50
+
51
+ interceptor = build_interceptor(allowed_keys, block_mode, logger)
52
+ # Baggage uses extend self, so prepend to the module (instance methods become singleton)
53
+ OpenTelemetry::Baggage.prepend(interceptor)
54
+ end
55
+
56
+ def build_interceptor(allowed_keys, block_mode, logger)
57
+ Module.new do
58
+ define_method(:set_value) do |key, value, metadata: nil, context: nil|
59
+ ctx = context || (defined?(OpenTelemetry::Context) && OpenTelemetry::Context.current)
60
+ unless allowed_keys.include?(key.to_s)
61
+ message = "[E11y] Blocked PII from OpenTelemetry baggage: key=#{key.inspect}"
62
+ case block_mode
63
+ when :silent then logger&.debug(message)
64
+ when :warn then logger&.warn(message)
65
+ when :raise then raise E11y::BaggagePiiError, "#{message}. Only allowed keys: #{allowed_keys.join(', ')}"
66
+ end
67
+ return ctx
68
+ end
69
+ super(key, value, metadata: metadata, context: ctx)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Middleware
5
+ # Sets Thread.current[:e11y_source] = "web" during a web request.
6
+ # Cleared after the request completes (even on exception).
7
+ #
8
+ # Also propagates trace_id to Rack env for the Browser Overlay:
9
+ # env["e11y.trace_id"] is set from Thread.current[:e11y_trace_id].
10
+ class DevLogSource
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ Thread.current[:e11y_source] = "web"
17
+ env["e11y.trace_id"] ||= Thread.current[:e11y_trace_id]
18
+ @app.call(env)
19
+ ensure
20
+ Thread.current[:e11y_source] = nil
21
+ end
22
+ end
23
+ end
24
+ end