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
@@ -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
@@ -69,23 +69,30 @@ module E11y
69
69
  # Skip if SLO not enabled for this event
70
70
  # Support explicit event_class (for testing) or resolve from event_name
71
71
  event_class = event_data[:event_class] || resolve_event_class(event_data)
72
- return event_data unless event_class.respond_to?(:slo_config)
73
- return event_data unless event_class.slo_config&.enabled?
72
+ unless event_class.respond_to?(:slo_config) && event_class.slo_config&.enabled?
73
+ # Pass to next middleware even if SLO not enabled
74
+ return @app&.call(event_data) || event_data
75
+ end
74
76
 
75
77
  # Compute slo_status from payload
76
78
  slo_status = compute_slo_status(event_class, event_data[:payload])
77
- return event_data unless slo_status
79
+ unless slo_status
80
+ # Pass to next middleware even if slo_status is nil
81
+ return @app&.call(event_data) || event_data
82
+ end
78
83
 
79
- # Emit SLO metric
80
- emit_slo_metric(event_class, slo_status, event_data[:payload])
84
+ # Emit SLO metric (with sampling correction when stratified sampling enabled)
85
+ emit_slo_metric(event_class, slo_status, event_data[:payload], event_data)
81
86
 
82
- event_data # Passthrough (never modify event_data)
87
+ # Pass to next middleware (Routing writes to adapters)
88
+ @app&.call(event_data) || event_data
83
89
  rescue StandardError => e
84
90
  # Never fail event tracking due to SLO processing
85
91
  E11y.logger.error(
86
92
  "[E11y::Middleware::EventSlo] SLO processing failed for #{event_data[:event_name]}: #{e.message}"
87
93
  )
88
- event_data
94
+ # Still pass to next middleware even on error
95
+ @app&.call(event_data) || event_data
89
96
  end
90
97
 
91
98
  private
@@ -124,15 +131,22 @@ module E11y
124
131
  end
125
132
 
126
133
  # Emit SLO metric to Yabeda/Prometheus.
134
+ # C11: Applies stratified sampling correction when event was sampled.
127
135
  #
128
136
  # @param event_class [Class] Event class
129
137
  # @param slo_status [String] 'success' or 'failure'
130
138
  # @param payload [Hash] Event payload
139
+ # @param event_data [Hash] Full event data (for sample_rate)
131
140
  # @return [void]
132
- def emit_slo_metric(event_class, slo_status, payload)
141
+ def emit_slo_metric(event_class, slo_status, payload, _event_data = {})
133
142
  labels = build_slo_labels(event_class, slo_status, payload)
134
143
 
135
- E11y::Metrics.increment(:slo_event_result_total, labels)
144
+ # C11: Apply sampling correction for accurate SLO with stratified sampling
145
+ stratum = slo_status == "success" ? :success : :error
146
+ correction = E11y::Sampling.stratified_tracker.sampling_correction(stratum)
147
+ value = (correction * 100).round / 100.0 # Round to 2 decimals for Prometheus
148
+
149
+ E11y::Metrics.increment(:slo_event_result_total, labels, value: value)
136
150
  rescue StandardError => e
137
151
  E11y.logger.error(
138
152
  "[E11y::Middleware::EventSlo] Failed to emit SLO metric for #{event_class.name}: #{e.message}"
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Middleware
5
+ # OtelSpan middleware — creates OpenTelemetry spans from events (ADR-007 §6, F2).
6
+ #
7
+ # When config.opentelemetry_span_creation_patterns is set, creates OTel spans
8
+ # for matching events. Errors/fatal always create spans.
9
+ #
10
+ # @see E11y::OpenTelemetry::SpanCreator
11
+ # @see ADR-007 §6 Traces Signal Export
12
+ class OtelSpan < Base
13
+ middleware_zone :adapters
14
+
15
+ def call(event_data)
16
+ if defined?(::OpenTelemetry::Trace) && defined?(E11y::OpenTelemetry::SpanCreator)
17
+ E11y::OpenTelemetry::SpanCreator.create_span_from_event(event_data)
18
+ end
19
+ @app.call(event_data)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,18 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/parameter_filter"
4
+
3
5
  module E11y
4
6
  module Middleware
5
- # PII Filter Middleware - 3-Tier Strategy
7
+ # PII Filter Middleware
6
8
  #
7
9
  # Filters Personally Identifiable Information (PII) from event payloads
8
- # before they reach adapters. Implements ADR-006 3-tier security model.
10
+ # before they reach adapters. Implements ADR-006 security model.
9
11
  #
10
- # **Three-Tier Strategy:**
11
- # - Tier 1: No PII (`contains_pii false`) - Skip filtering (0ms overhead)
12
- # - Tier 2: Default - Rails filters only (~0.05ms overhead)
13
- # - Tier 3: Explicit PII (`contains_pii true`) - Deep filtering (~0.2ms overhead)
12
+ # **Filtering modes:**
13
+ # - :no_pii Skip filtering (contains_pii false, 0ms overhead)
14
+ # - :rails_filters Rails filter_parameters only (~0.05ms overhead)
15
+ # - :explicit_pii Field strategies, optionally per-adapter via exclude_adapters (~0.2ms)
14
16
  #
15
- # @example Basic Usage (Tier 2 - Default)
17
+ # @example Basic Usage (:rails_filters - default)
16
18
  # class Events::OrderCreated < E11y::Event::Base
17
19
  # schema do
18
20
  # required(:order_id).filled(:string)
@@ -20,12 +22,12 @@ module E11y
20
22
  # end
21
23
  # end
22
24
  #
23
- # @example Tier 1: No PII (High Performance)
25
+ # @example :no_pii (skip filtering)
24
26
  # class Events::HealthCheck < E11y::Event::Base
25
- # contains_pii false # Skip all filtering
27
+ # contains_pii false
26
28
  # end
27
29
  #
28
- # @example Tier 3: Explicit PII (Deep Filtering)
30
+ # @example :explicit_pii (field strategies)
29
31
  # class Events::UserRegistered < E11y::Event::Base
30
32
  # contains_pii true
31
33
  #
@@ -40,7 +42,6 @@ module E11y
40
42
  # @see UC-007 PII Filtering
41
43
  # @see E11y::PII::Patterns
42
44
  # rubocop:disable Metrics/ClassLength
43
- # PII filter is a cohesive security component with 3-tier filtering strategy
44
45
  class PIIFilter < Base
45
46
  middleware_zone :security
46
47
 
@@ -53,27 +54,24 @@ module E11y
53
54
  @config = config
54
55
  end
55
56
 
56
- # Process event and filter PII based on tier
57
+ # Process event and filter PII based on filtering mode
57
58
  #
58
59
  # @param event_data [Hash] Event data with payload
59
60
  # @return [Hash] Processed event data
60
61
  # rubocop:disable Lint/DuplicateBranch
61
- # Unknown tiers intentionally fallback to no filtering (same as tier1)
62
62
  def call(event_data)
63
- # Determine filtering tier
64
- tier = determine_tier(event_data)
63
+ return @app.call(event_data) if event_data[:dlq_replayed]
64
+
65
+ mode = filtering_mode(event_data)
65
66
 
66
- case tier
67
- when :tier1
68
- # Tier 1: No PII - Skip filtering (0ms overhead)
67
+ case mode
68
+ when :no_pii
69
69
  @app.call(event_data)
70
- when :tier2
71
- # Tier 2: Rails filters only (~0.05ms overhead)
70
+ when :rails_filters
72
71
  filtered_data = apply_rails_filters(event_data)
73
72
  @app.call(filtered_data)
74
- when :tier3
75
- # Tier 3: Deep filtering (~0.2ms overhead)
76
- filtered_data = apply_deep_filtering(event_data)
73
+ when :explicit_pii
74
+ filtered_data = apply_explicit_pii_filtering(event_data)
77
75
  @app.call(filtered_data)
78
76
  else
79
77
  @app.call(event_data)
@@ -83,16 +81,11 @@ module E11y
83
81
 
84
82
  private
85
83
 
86
- # Determine PII filtering tier for event
87
- #
88
- # @param event_data [Hash] Event data
89
- # @return [Symbol] :tier1, :tier2, or :tier3
90
- def determine_tier(event_data)
84
+ def filtering_mode(event_data)
91
85
  event_class = event_data[:event_class]
92
- return :tier2 unless event_class.respond_to?(:pii_tier)
86
+ return :rails_filters unless event_class.respond_to?(:pii_filtering_mode)
93
87
 
94
- # Return tier directly from event class
95
- event_class.pii_tier
88
+ event_class.pii_filtering_mode
96
89
  end
97
90
 
98
91
  # Apply Rails filter_parameters (Tier 2)
@@ -109,52 +102,71 @@ module E11y
109
102
  filtered_data
110
103
  end
111
104
 
112
- # Apply deep PII filtering (Tier 3)
113
- #
114
- # @param event_data [Hash] Event data
115
- # @return [Hash] Filtered event data
116
- def apply_deep_filtering(event_data)
105
+ # :explicit_pii field strategies, optionally payload_rewrites when exclude_adapters present.
106
+ def apply_explicit_pii_filtering(event_data)
117
107
  event_class = event_data[:event_class]
118
108
  return event_data unless event_class
119
109
 
120
- # Clone to avoid modifying original
121
- filtered_data = deep_dup(event_data)
122
-
123
- # Get PII filtering config from event class
124
110
  pii_config = event_class.pii_filtering_config if event_class.respond_to?(:pii_filtering_config)
125
- return filtered_data unless pii_config
111
+ return event_data unless pii_config
112
+
113
+ # 1. Base payload (most restrictive)
114
+ base_payload = apply_field_strategies(deep_dup(event_data[:payload]), pii_config, nil)
115
+ base_payload = apply_pattern_filtering(base_payload, pii_config, [])
126
116
 
127
- # Apply field-level strategies
128
- filtered_data[:payload] = apply_field_strategies(
129
- filtered_data[:payload],
130
- pii_config
131
- )
117
+ filtered_data = deep_dup(event_data)
118
+ filtered_data[:payload] = base_payload
132
119
 
133
- # Apply pattern-based filtering
134
- filtered_data[:payload] = apply_pattern_filtering(
135
- filtered_data[:payload]
136
- )
120
+ # 2. payload_rewrites: per-adapter overrides for exclude_adapters fields only
121
+ has_exclude_adapters = pii_config[:fields]&.any? { |_, v| v[:exclude_adapters]&.any? }
122
+ filtered_data[:payload_rewrites] = build_payload_rewrites(event_data, pii_config) if has_exclude_adapters
137
123
 
138
124
  filtered_data
139
125
  end
140
126
 
127
+ # Build payload_rewrites: { adapter_name => { field => original_value } }
128
+ # Only fields with exclude_adapters.include?(adapter) get original value.
129
+ def build_payload_rewrites(event_data, pii_config)
130
+ adapters = AdapterResolver.resolve(event_data)
131
+ return {} unless adapters.any?
132
+
133
+ original_payload = event_data[:payload] || {}
134
+ rewrites = {}
135
+
136
+ adapters.each do |adapter_name|
137
+ adapter_rewrites = {}
138
+ pii_config[:fields]&.each do |field, opts|
139
+ next unless opts[:exclude_adapters]&.include?(adapter_name)
140
+
141
+ key = original_payload.key?(field) ? field : field.to_s
142
+ adapter_rewrites[key] = original_payload[key] if original_payload.key?(key)
143
+ end
144
+ rewrites[adapter_name] = adapter_rewrites if adapter_rewrites.any?
145
+ end
146
+ rewrites
147
+ end
148
+
141
149
  # Apply field-level filtering strategies
142
150
  #
143
151
  # @param payload [Hash] Payload to filter
144
152
  # @param config [Hash] PII configuration
153
+ # @param adapter_name [Symbol, nil] When set, use :skip for fields with exclude_adapters.include?(adapter_name)
145
154
  # @return [Hash] Filtered payload
146
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
147
- # Field strategies require case/when for each PII filtering strategy type
148
- def apply_field_strategies(payload, config)
155
+ # rubocop:disable Metrics/MethodLength
156
+ def apply_field_strategies(payload, config, adapter_name = nil)
149
157
  return payload unless config
150
158
 
151
159
  filtered = {}
152
160
 
153
161
  payload.each do |key, value|
154
- strategy = config.dig(:fields, key, :strategy) || :allow
162
+ normalized_key = key.is_a?(Symbol) ? key : key.to_sym
163
+ field_config = config.dig(:fields, normalized_key) || {}
164
+ strategy = field_config[:strategy] || :allow
165
+
166
+ # Per-adapter: use :skip for excluded adapters (e.g. audit gets original)
167
+ strategy = :allow if adapter_name && field_config[:exclude_adapters]&.include?(adapter_name)
155
168
 
156
169
  # rubocop:disable Lint/DuplicateBranch
157
- # Unknown strategies intentionally fallback to allow (same as :allow)
158
170
  filtered[key] = case strategy
159
171
  when :mask
160
172
  "[FILTERED]"
@@ -164,7 +176,7 @@ module E11y
164
176
  partial_mask(value)
165
177
  when :redact
166
178
  nil
167
- when :allow
179
+ when :allow, :skip
168
180
  value
169
181
  else
170
182
  value
@@ -174,34 +186,45 @@ module E11y
174
186
 
175
187
  filtered
176
188
  end
177
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
189
+ # rubocop:enable Metrics/MethodLength
178
190
 
179
191
  # Apply pattern-based filtering to string values
180
- #
181
- # @param data [Object] Data to filter (recursively)
182
- # @return [Object] Filtered data
183
- def apply_pattern_filtering(data)
192
+ def apply_pattern_filtering(data, pii_config = nil, path = [])
184
193
  case data
185
- when Hash
186
- data.transform_values { |v| apply_pattern_filtering(v) }
187
- when Array
188
- data.map { |v| apply_pattern_filtering(v) }
189
- when String
190
- filter_string_patterns(data)
191
- else
192
- data
194
+ when Hash then apply_pattern_filtering_hash(data, pii_config, path)
195
+ when Array then data.map { |v| apply_pattern_filtering(v, pii_config, path) }
196
+ when String then filter_string_if_needed(data, path, pii_config)
197
+ else data
193
198
  end
194
199
  end
195
200
 
196
- # Filter PII patterns in string
201
+ def apply_pattern_filtering_hash(data, pii_config, path)
202
+ data.each_with_object({}) do |(k, v), acc|
203
+ key_sym = k.is_a?(Symbol) ? k : k.to_sym
204
+ acc[k] = apply_pattern_filtering(v, pii_config, path + [key_sym])
205
+ end
206
+ end
207
+
208
+ def filter_string_if_needed(str, path, pii_config)
209
+ path_under_allowed_key?(path, pii_config) ? str : filter_string_patterns(str)
210
+ end
211
+
212
+ # Check if any ancestor key in path is explicitly allowed
213
+ def path_under_allowed_key?(path, pii_config)
214
+ return false unless pii_config && pii_config[:fields]
215
+
216
+ allowed_keys = pii_config[:fields].select { |_k, v| %i[allow skip].include?(v[:strategy]) }.keys
217
+ path.any? { |p| allowed_keys.include?(p) }
218
+ end
219
+
220
+ # Filter PII patterns in string (VALUE_PATTERNS only, not PASSWORD_FIELDS)
197
221
  #
198
222
  # @param str [String] String to filter
199
223
  # @return [String] Filtered string
200
224
  def filter_string_patterns(str)
201
225
  result = str.dup
202
226
 
203
- # Apply all PII patterns
204
- E11y::PII::Patterns::ALL.each do |pattern|
227
+ E11y::PII::Patterns::VALUE_PATTERNS.each do |pattern|
205
228
  result = result.gsub(pattern, "[FILTERED]")
206
229
  end
207
230
 
@@ -261,12 +284,18 @@ module E11y
261
284
  # Get Rails parameter filter
262
285
  #
263
286
  # Uses Rails.application.config.filter_parameters for PII filtering.
287
+ # When Rails is not loaded (e.g. unit tests), uses empty filter (no-op).
264
288
  #
265
289
  # @return [ActiveSupport::ParameterFilter] Parameter filter
266
290
  def parameter_filter
267
- @parameter_filter ||= ActiveSupport::ParameterFilter.new(
268
- Rails.application.config.filter_parameters
269
- )
291
+ return @parameter_filter if defined?(@parameter_filter) && !@parameter_filter.nil?
292
+
293
+ filters = if defined?(Rails) && Rails.application
294
+ Rails.application.config.filter_parameters
295
+ else
296
+ []
297
+ end
298
+ @parameter_filter = ActiveSupport::ParameterFilter.new(filters)
270
299
  end
271
300
  end
272
301
  # rubocop:enable Metrics/ClassLength