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,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+
5
+ module E11y
6
+ module Debug
7
+ # Debug utility to trace events through the pipeline with per-middleware logging.
8
+ #
9
+ # Runs the full pipeline including adapter writes. For debugging, use Stdout or
10
+ # InMemory adapter. Stub adapters in tests if needed.
11
+ #
12
+ # @see E11y.trace
13
+ class PipelineInspector
14
+ # Wraps a middleware to log enter/exit for pipeline tracing.
15
+ class TracingWrapper
16
+ def initialize(middleware_class, next_app, name, args: [], options: {})
17
+ @middleware_class = middleware_class
18
+ @next_app = next_app
19
+ @name = name
20
+ @args = args
21
+ @options = options
22
+ end
23
+
24
+ def call(event_data)
25
+ log_enter(@name)
26
+ result = @middleware_class.new(@next_app, *@args, **@options).call(event_data)
27
+ log_exit(@name)
28
+ result
29
+ end
30
+
31
+ private
32
+
33
+ def log_enter(name)
34
+ prefix = $stdout.tty? ? "\e[33m" : ""
35
+ suffix = $stdout.tty? ? "\e[0m" : ""
36
+ print " #{prefix}#{name}#{suffix}... "
37
+ end
38
+
39
+ def log_exit(_name)
40
+ mark = $stdout.tty? ? "\e[32m✓\e[0m" : "✓"
41
+ puts mark
42
+ end
43
+ end
44
+
45
+ class << self
46
+ # Traces an event through the pipeline with per-middleware logging.
47
+ # Note: adapters WILL receive the event.
48
+ #
49
+ # @param event_class [Class] Event class
50
+ # @param payload [Hash] Event payload
51
+ # @return [Hash] event_data after pipeline
52
+ def trace_event(event_class, **payload)
53
+ event_name = event_class.respond_to?(:event_name) ? event_class.event_name : event_class.name
54
+ puts "\n🔍 Tracing Event Pipeline: #{event_name}\n\n"
55
+ event_data = build_event_data(event_class, payload)
56
+ pipeline = build_tracing_pipeline
57
+ result = pipeline.call(event_data)
58
+ puts "\n✅ Pipeline trace complete\n"
59
+ result
60
+ end
61
+
62
+ private
63
+
64
+ def build_event_data(event_class, payload)
65
+ {
66
+ event_class: event_class,
67
+ event_name: event_class.respond_to?(:event_name) ? event_class.event_name : event_class.name,
68
+ payload: payload,
69
+ severity: event_class.respond_to?(:severity) ? event_class.severity : :info,
70
+ version: event_class.respond_to?(:version) ? event_class.version : 1,
71
+ adapters: event_class.respond_to?(:adapters) ? event_class.adapters : nil,
72
+ timestamp: Time.now.utc,
73
+ retention_period: event_class.respond_to?(:retention_period) ? event_class.retention_period : 30.days,
74
+ context: {}
75
+ }
76
+ end
77
+
78
+ def build_tracing_pipeline
79
+ builder = E11y.configuration.pipeline
80
+ final_app = ->(event_data) { event_data }
81
+
82
+ builder.middlewares.reverse.reduce(final_app) do |next_app, entry|
83
+ name = entry.middleware_class.name.split("::").last
84
+ TracingWrapper.new(
85
+ entry.middleware_class,
86
+ next_app,
87
+ name,
88
+ args: entry.args,
89
+ options: entry.options
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Documentation
5
+ # Generates Markdown documentation for registered E11y events.
6
+ class Generator
7
+ def self.generate(output_dir, criteria: {}, grep: nil)
8
+ classes = criteria.any? ? E11y::Registry.where(**criteria) : E11y::Registry.event_classes
9
+ classes = classes.select { |c| (c.respond_to?(:event_name) ? c.event_name : c.name).to_s.include?(grep) } if grep
10
+
11
+ FileUtils.mkdir_p(output_dir)
12
+ write_index(output_dir, classes)
13
+ classes.each { |klass| write_event_doc(output_dir, klass) }
14
+ end
15
+
16
+ def self.write_index(output_dir, classes)
17
+ lines = ["# E11y Events", "", "| Event | Class | Severity |", "|-------|-------|----------|"]
18
+ classes.each do |klass|
19
+ name = klass.respond_to?(:event_name) ? klass.event_name : klass.name
20
+ sev = klass.respond_to?(:severity) ? klass.severity : "—"
21
+ lines << "| #{name} | #{klass.name} | #{sev} |"
22
+ end
23
+ File.write(File.join(output_dir, "README.md"), "#{lines.join("\n")}\n")
24
+ end
25
+
26
+ def self.write_event_doc(output_dir, klass)
27
+ name = klass.respond_to?(:event_name) ? klass.event_name : klass.name
28
+ schema_keys = extract_schema_keys(klass)
29
+ sev = klass.respond_to?(:severity) ? klass.severity : "—"
30
+ lines = ["# #{name}", "", "- **Class:** #{klass.name}", "- **Severity:** #{sev}"]
31
+ lines << "- **Schema keys:** #{schema_keys.join(', ')}" if schema_keys&.any?
32
+ lines << ""
33
+ File.write(File.join(output_dir, "#{name.to_s.tr('.', '_')}.md"), "#{lines.join("\n")}\n")
34
+ end
35
+
36
+ def self.extract_schema_keys(klass)
37
+ return nil unless klass.respond_to?(:compiled_schema)
38
+
39
+ schema = klass.compiled_schema
40
+ return nil if schema.nil? || !schema.respond_to?(:key_map)
41
+
42
+ schema.key_map.keys.map(&:name)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -81,22 +81,38 @@ module E11y
81
81
  # - Auto-calculated retention_until from retention_period
82
82
  #
83
83
  # @param payload [Hash] Event data matching the schema
84
+ # @yield Optional block — measured for duration; adds :duration_ms to payload
84
85
  # @return [Hash] Event hash (includes metadata)
85
86
  #
86
- # @example
87
+ # @example Without block
87
88
  # UserSignupEvent.track(user_id: 123, email: "user@example.com")
88
89
  # # => { event_name: "UserSignupEvent", payload: {...}, severity: :info, adapters: [:logs], ... }
89
90
  #
91
+ # @example With block (duration measurement)
92
+ # Events::OrderPaid.track(order_id: '123') { ExternalPaymentService.charge! }
93
+ # # => payload includes duration_ms automatically
94
+ #
90
95
  # @raise [E11y::ValidationError] if payload doesn't match schema (when validation runs)
91
- def track(**payload)
96
+ def track(**payload, &block)
92
97
  return unless E11y.config.enabled
93
98
 
99
+ # Block form: execute block, measure duration, capture return value
100
+ block_result = nil
101
+ if block
102
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
103
+ block_result = yield
104
+ payload = payload.merge(duration_ms: Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - start)
105
+ end
106
+
107
+ # Severity: payload override (e.g. exception → :error) or class default
108
+ resolved_severity = payload[:severity] || payload["severity"] || severity
109
+
94
110
  # Build event data hash for pipeline processing
95
111
  event_data = {
96
112
  event_class: self,
97
113
  event_name: event_name,
98
114
  payload: payload,
99
- severity: severity,
115
+ severity: resolved_severity,
100
116
  version: version,
101
117
  adapters: adapters,
102
118
  timestamp: Time.now.utc,
@@ -109,8 +125,8 @@ module E11y
109
125
  # Routing middleware is the LAST middleware and it writes to adapters directly
110
126
  E11y.config.built_pipeline.call(event_data)
111
127
 
112
- # Return event data for testing/debugging
113
- event_data
128
+ # With block: return block's result (caller cares about it); without: return event_data
129
+ block ? block_result : event_data
114
130
  end
115
131
 
116
132
  # Build event hash
@@ -240,9 +256,7 @@ module E11y
240
256
  # end
241
257
  def severity(value = nil)
242
258
  if value
243
- unless SEVERITIES.include?(value)
244
- raise ArgumentError, "Invalid severity: #{value}. Must be one of: #{SEVERITIES.join(', ')}"
245
- end
259
+ raise ArgumentError, "Invalid severity: #{value}. Must be one of: #{SEVERITIES.join(', ')}" unless SEVERITIES.include?(value)
246
260
 
247
261
  @severity = value
248
262
  end
@@ -263,13 +277,15 @@ module E11y
263
277
  # class OrderPaidEventV2 < E11y::Event::Base
264
278
  # version 2
265
279
  # end
280
+ VERSION_REGEX = /V(\d+)$/
281
+
266
282
  def version(value = nil)
267
283
  @version = value if value
268
- # Return explicitly set version OR inherit from parent (if set) OR default to 1
269
284
  return @version if @version
270
- return superclass.version if superclass != E11y::Event::Base && superclass.instance_variable_get(:@version)
271
285
 
272
- 1
286
+ # Auto-extract from class name (e.g. OrderPaidV2 → 2)
287
+ match = name&.match(VERSION_REGEX)
288
+ match ? match[1].to_i : 1
273
289
  end
274
290
 
275
291
  # Set or get retention period for this event
@@ -299,14 +315,15 @@ module E11y
299
315
  @retention_period = value if value
300
316
  # Return explicitly set retention_period OR inherit from parent (if set) OR config default OR final fallback
301
317
  return @retention_period if @retention_period
302
- if superclass != E11y::Event::Base && superclass.instance_variable_get(:@retention_period)
303
- return superclass.retention_period
304
- end
318
+ return superclass.retention_period if superclass != E11y::Event::Base && superclass.instance_variable_get(:@retention_period)
305
319
 
306
320
  # Fallback to configuration or 30 days
307
321
  E11y.configuration&.default_retention_period || 30.days
308
322
  end
309
323
 
324
+ # Convenience alias — matches Quick Start documentation.
325
+ alias retention retention_period
326
+
310
327
  # Set or get adapters for this event
311
328
  #
312
329
  # Adapters are referenced by NAME (e.g., :logs, :errors_tracker).
@@ -331,16 +348,42 @@ module E11y
331
348
  return @adapters if @adapters
332
349
  return superclass.adapters if superclass != E11y::Event::Base && superclass.instance_variable_get(:@adapters)
333
350
 
351
+ # No explicit adapters: inherit from parent or resolve from severity
352
+ # (audit events and regular events both use severity-based mapping)
334
353
  resolved_adapters
335
354
  end
336
355
 
337
- # Get event name (normalized)
356
+ # Get or set event name (normalized)
338
357
  #
339
- # @return [String] Event name without version suffix
358
+ # When called with a value, stores it and auto-registers the class in `E11y::Registry`.
359
+ # When called without a value, derives the name from the class name (stripping version suffix).
340
360
  #
341
- # @example
361
+ # @param value [String, Symbol, nil] Explicit event name to set, or nil to read
362
+ # @return [String] Event name
363
+ #
364
+ # @example Explicit name
365
+ # class OrderPaidEvent < E11y::Event::Base
366
+ # event_name "order.paid"
367
+ # end
368
+ #
369
+ # @example Auto-derived name
342
370
  # OrderPaidEventV2.event_name # => "OrderPaidEvent"
343
- def event_name
371
+ def event_name(value = nil)
372
+ if value
373
+ @event_name = value.to_s
374
+ @event_name_explicit = true
375
+ # Auto-register in E11y::Registry when an explicit name is set.
376
+ # Guard with defined? so that loading order does not matter.
377
+ # NOTE: call register AFTER setting @event_name_explicit so that any
378
+ # re-entrant call to event_name (from Registry#register) returns the
379
+ # correct value instead of falling through to the auto-derive path.
380
+ E11y::Registry.register(self) if defined?(E11y::Registry)
381
+ return @event_name
382
+ end
383
+
384
+ # Return explicitly-set name unconditionally (works for anonymous classes too)
385
+ return @event_name if @event_name_explicit
386
+
344
387
  # Don't cache for anonymous classes (name returns nil)
345
388
  return @event_name if @event_name && name
346
389
 
@@ -365,7 +408,6 @@ module E11y
365
408
  # class CriticalEvent < E11y::Event::Base
366
409
  # sample_rate 1.0 # 100% sampling
367
410
  # end
368
- # rubocop:disable Metrics/CyclomaticComplexity
369
411
  def sample_rate(value = nil)
370
412
  if value
371
413
  unless value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
@@ -377,13 +419,10 @@ module E11y
377
419
 
378
420
  # Return explicitly set sample_rate OR inherit from parent (if set) OR nil (use resolve_sample_rate)
379
421
  return @sample_rate if @sample_rate
380
- if superclass != E11y::Event::Base && superclass.instance_variable_get(:@sample_rate)
381
- return superclass.sample_rate
382
- end
422
+ return superclass.sample_rate if superclass != E11y::Event::Base && superclass.instance_variable_get(:@sample_rate)
383
423
 
384
424
  nil
385
425
  end
386
- # rubocop:enable Metrics/CyclomaticComplexity
387
426
 
388
427
  # Configure value-based sampling (FEAT-4849)
389
428
  #
@@ -460,9 +499,7 @@ module E11y
460
499
 
461
500
  # Return explicitly set config OR inherit from parent (if set) OR nil
462
501
  return @adaptive_sampling if @adaptive_sampling
463
- if superclass != E11y::Event::Base && superclass.instance_variable_get(:@adaptive_sampling)
464
- return superclass.adaptive_sampling
465
- end
502
+ return superclass.adaptive_sampling if superclass != E11y::Event::Base && superclass.instance_variable_get(:@adaptive_sampling)
466
503
 
467
504
  nil
468
505
  end
@@ -476,12 +513,36 @@ module E11y
476
513
  def resolve_rate_limit
477
514
  case severity
478
515
  when :error, :fatal
479
- nil # Unlimited - не теряем ошибки
516
+ nil # Unlimited - never drop errors
480
517
  else
481
518
  1000 # 1000 events/sec
482
519
  end
483
520
  end
484
521
 
522
+ # Set a per-event-class rate limit for the RateLimiting middleware.
523
+ #
524
+ # Overrides the global rate limit for events of this class.
525
+ # error/fatal events are always exempt (never rate-limited).
526
+ #
527
+ # @param count [Integer] Max events allowed per window
528
+ # @param window [Numeric, ActiveSupport::Duration] Time window in seconds (default: 1.0)
529
+ #
530
+ # @example Strict limit for login failures (brute-force protection)
531
+ # class Events::UserLoginFailed < E11y::Event::Base
532
+ # rate_limit 100, window: 60
533
+ # end
534
+ def rate_limit(count, window: 1.0)
535
+ @rate_limit_count = count
536
+ @rate_limit_window = window.to_f
537
+ end
538
+
539
+ # Per-event rate limit configuration.
540
+ #
541
+ # @return [Hash] { count: Integer|nil, window: Float|nil }
542
+ def rate_limit_config
543
+ { count: @rate_limit_count, window: @rate_limit_window }
544
+ end
545
+
485
546
  private
486
547
 
487
548
  # Determine if validation should run for this event
@@ -581,21 +642,21 @@ module E11y
581
642
  # end
582
643
  def contains_pii(value = nil)
583
644
  if value.nil?
584
- # Getter
645
+ return superclass.contains_pii if !instance_variable_defined?(:@contains_pii) && superclass.respond_to?(:contains_pii)
646
+
585
647
  @contains_pii
586
648
  else
587
- # Setter
588
649
  @contains_pii = value
589
650
  end
590
651
  end
591
652
 
592
- # Determine the PII filtering tier for this event.
593
- # @return [Symbol] :tier1, :tier2, or :tier3
594
- def pii_tier
653
+ # PII filtering mode for this event.
654
+ # @return [Symbol] :no_pii, :rails_filters, or :explicit_pii
655
+ def pii_filtering_mode
595
656
  case contains_pii
596
- when false then :tier1
597
- when true then :tier3
598
- else :tier2 # Default if not explicitly declared
657
+ when false then :no_pii
658
+ when true then :explicit_pii
659
+ else :rails_filters # Default if not explicitly declared
599
660
  end
600
661
  end
601
662
 
@@ -610,15 +671,22 @@ module E11y
610
671
  # allows :user_id, :amount
611
672
  # end
612
673
  def pii_filtering(&)
613
- @pii_filtering_config ||= { fields: {} }
674
+ if @pii_filtering_config.nil?
675
+ parent_config = superclass.respond_to?(:pii_filtering_config) && superclass.pii_filtering_config
676
+ @pii_filtering_config = parent_config ? { fields: parent_config[:fields].dup } : { fields: {} }
677
+ end
614
678
  builder = PIIFilteringBuilder.new(@pii_filtering_config)
615
679
  builder.instance_eval(&)
616
680
  end
617
681
 
618
- # Get PII filtering configuration
682
+ # Get PII filtering configuration (inherits from superclass if not defined)
619
683
  #
620
- # @return [Hash] PII filtering config
621
- attr_reader :pii_filtering_config
684
+ # @return [Hash, nil] PII filtering config
685
+ def pii_filtering_config
686
+ return @pii_filtering_config if instance_variable_defined?(:@pii_filtering_config) && @pii_filtering_config
687
+
688
+ superclass.pii_filtering_config if superclass.respond_to?(:pii_filtering_config)
689
+ end
622
690
 
623
691
  # PII Filtering DSL Builder
624
692
  #
@@ -666,6 +734,30 @@ module E11y
666
734
  def allows(*fields)
667
735
  fields.each { |field| @config[:fields][field] = { strategy: :allow } }
668
736
  end
737
+
738
+ # Per-field config with exclude_adapters (Tier 3 per-adapter filtering).
739
+ #
740
+ # @param field [Symbol] Field name
741
+ # @yield Block with strategy, exclude_adapters
742
+ # @example
743
+ # field :email do
744
+ # strategy :hash
745
+ # exclude_adapters [:file_audit] # Audit gets original (GDPR)
746
+ # end
747
+ def field(field_name, &)
748
+ return unless block_given?
749
+
750
+ opts = { strategy: :allow }
751
+ dsl = Class.new do
752
+ attr_reader :opts
753
+
754
+ def initialize(opts) = @opts = opts
755
+ def strategy(val) = @opts.[]=(:strategy, val)
756
+ def exclude_adapters(adapters) = @opts.[]=(:exclude_adapters, Array(adapters).map(&:to_sym))
757
+ end.new(opts)
758
+ dsl.instance_eval(&)
759
+ @config[:fields][field_name] = opts
760
+ end
669
761
  end
670
762
 
671
763
  # === Audit Event DSL (ADR-006, UC-012) ===
@@ -705,6 +797,33 @@ module E11y
705
797
  @audit_event == true
706
798
  end
707
799
 
800
+ # === DLQ Filter DSL (ADR-013, UC-021) ===
801
+
802
+ # Declare whether this event should be saved to DLQ on failure.
803
+ #
804
+ # @param value [Boolean, nil] true = save, false = discard, nil = use severity + default
805
+ # @example
806
+ # class Events::PaymentFailed < E11y::Event::Base
807
+ # use_dlq true
808
+ # end
809
+ #
810
+ # class Events::DebugTrace < E11y::Event::Base
811
+ # use_dlq false
812
+ # end
813
+ def use_dlq(value = nil)
814
+ if value.nil?
815
+ return superclass.use_dlq if !instance_variable_defined?(:@use_dlq) && superclass.respond_to?(:use_dlq)
816
+
817
+ @use_dlq
818
+ else
819
+ @use_dlq = value
820
+ end
821
+ end
822
+
823
+ def use_dlq?
824
+ use_dlq == true
825
+ end
826
+
708
827
  # Configure cryptographic signing for audit event
709
828
  #
710
829
  # By default, all audit events are signed with HMAC-SHA256.
@@ -759,7 +878,7 @@ module E11y
759
878
  audit_event? && signing_enabled?
760
879
  end
761
880
 
762
- # === Metrics DSL (ADR-002, UC-003) ===
881
+ # === Metrics DSL (ADR-002, UC-003 Event Metrics) ===
763
882
 
764
883
  # Define metrics for this event
765
884
  #
@@ -810,6 +929,23 @@ module E11y
810
929
  register_metrics_in_registry!
811
930
  end
812
931
 
932
+ # Single-call metric shorthand — equivalent to a one-metric `metrics` block.
933
+ #
934
+ # @param type [Symbol] :counter, :histogram, or :gauge
935
+ # @param name [Symbol] Metric name
936
+ # @param opts [Hash] Options: tags:, value: (histogram/gauge), buckets: (histogram)
937
+ #
938
+ # @example
939
+ # metric :counter, name: :orders_total, tags: [:currency]
940
+ # metric :histogram, name: :order_amount, value: :amount, tags: [:currency]
941
+ def metric(type, name:, **opts)
942
+ raise ArgumentError, "Unknown metric type: #{type}. Use :counter, :histogram, or :gauge" unless %i[counter histogram gauge].include?(type)
943
+
944
+ @metrics_config ||= []
945
+ @metrics_config << { type: type, name: name }.merge(opts).compact
946
+ register_metrics_in_registry!
947
+ end
948
+
813
949
  # Get metrics configuration
814
950
  #
815
951
  # @return [Array<Hash>] Metrics configuration
@@ -911,48 +1047,6 @@ module E11y
911
1047
  end
912
1048
  end
913
1049
  end
914
-
915
- # Builder for PII filtering DSL
916
- class PIIFilteringBuilder
917
- def initialize(config)
918
- @config = config
919
- end
920
-
921
- # Mask fields (strategy: :mask)
922
- def masks(*fields)
923
- fields.each do |field|
924
- @config[:fields][field] = { strategy: :mask }
925
- end
926
- end
927
-
928
- # Hash fields (strategy: :hash)
929
- def hashes(*fields)
930
- fields.each do |field|
931
- @config[:fields][field] = { strategy: :hash }
932
- end
933
- end
934
-
935
- # Allow fields (strategy: :allow)
936
- def allows(*fields)
937
- fields.each do |field|
938
- @config[:fields][field] = { strategy: :allow }
939
- end
940
- end
941
-
942
- # Partial mask fields (strategy: :partial)
943
- def partials(*fields)
944
- fields.each do |field|
945
- @config[:fields][field] = { strategy: :partial }
946
- end
947
- end
948
-
949
- # Redact fields (strategy: :redact)
950
- def redacts(*fields)
951
- fields.each do |field|
952
- @config[:fields][field] = { strategy: :redact }
953
- end
954
- end
955
- end
956
1050
  end
957
1051
  # rubocop:enable Metrics/ClassLength
958
1052
  end
@@ -69,7 +69,6 @@ module E11y
69
69
 
70
70
  private
71
71
 
72
- # rubocop:disable Metrics/CyclomaticComplexity
73
72
  # Validation requires checking multiple comparison types and threshold types
74
73
  def validate_comparisons!
75
74
  raise ArgumentError, "At least one comparison required" if comparisons.empty?
@@ -79,12 +78,9 @@ module E11y
79
78
 
80
79
  raise ArgumentError, "in_range requires a Range" if type == :in_range && !threshold.is_a?(Range)
81
80
 
82
- if NUMERIC_COMPARISON_TYPES.include?(type) && !threshold.is_a?(Numeric)
83
- raise ArgumentError, "#{type} requires a Numeric threshold"
84
- end
81
+ raise ArgumentError, "#{type} requires a Numeric threshold" if NUMERIC_COMPARISON_TYPES.include?(type) && !threshold.is_a?(Numeric)
85
82
  end
86
83
  end
87
- # rubocop:enable Metrics/CyclomaticComplexity
88
84
  end
89
85
  end
90
86
  end
@@ -16,10 +16,7 @@ module E11y
16
16
  # @example Custom override
17
17
  # # config/initializers/e11y.rb
18
18
  # E11y.configure do |config|
19
- # config.rails_instrumentation.event_class_for(
20
- # 'sql.active_record',
21
- # MyApp::CustomDatabaseQuery
22
- # )
19
+ # config.rails_instrumentation_custom_mappings['sql.active_record'] = MyApp::CustomDatabaseQuery
23
20
  # end
24
21
  #
25
22
  # @see ADR-008 §4.3 (Built-in Event Classes)
@@ -12,6 +12,8 @@ module E11y
12
12
  optional(:job_class).maybe(:string)
13
13
  optional(:job_id).maybe(:string)
14
14
  optional(:queue).maybe(:string)
15
+ optional(:error_class).maybe(:string)
16
+ optional(:error_message).maybe(:string)
15
17
  end
16
18
 
17
19
  severity :error