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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "fileutils"
5
+
6
+ module E11y
7
+ module Adapters
8
+ class DevLog
9
+ # Handles JSONL file I/O with thread-safe append and numbered gzip rotation.
10
+ #
11
+ # Current file is always plain text for fast appends.
12
+ # Rotated files are gzip-compressed to save disk space.
13
+ # Rotation is triggered synchronously on the write that crosses the threshold.
14
+ class FileStore
15
+ DEFAULT_MAX_SIZE = 50 * 1024 * 1024 # 50 MB
16
+ DEFAULT_MAX_LINES = 10_000
17
+ DEFAULT_KEEP_ROTATED = 5
18
+
19
+ attr_reader :path
20
+
21
+ def initialize(path:,
22
+ max_size: DEFAULT_MAX_SIZE,
23
+ max_lines: DEFAULT_MAX_LINES,
24
+ keep_rotated: DEFAULT_KEEP_ROTATED)
25
+ @path = path.to_s
26
+ @max_size = max_size
27
+ @max_lines = max_lines
28
+ @keep_rotated = keep_rotated
29
+ @mutex = Mutex.new
30
+ @line_count = nil
31
+ @dir_ensured = false
32
+ end
33
+
34
+ # Append a JSON line to the log file. Thread-safe.
35
+ def append(json_line)
36
+ @mutex.synchronize do
37
+ ensure_dir!
38
+ # Warm the line count before writing so the post-write increment is accurate.
39
+ # If @line_count is nil (cold start or after clear!), scan the file now.
40
+ warm_count = @line_count || count_lines
41
+ ::File.open(@path, "a") do |f|
42
+ f.flock(::File::LOCK_EX)
43
+ begin
44
+ f.write("#{json_line}\n")
45
+ ensure
46
+ f.flock(::File::LOCK_UN)
47
+ end
48
+ end
49
+ @line_count = warm_count + 1
50
+ rotate_if_needed!
51
+ end
52
+ end
53
+
54
+ # Remove log file, rotated archives, and reset state.
55
+ def clear!
56
+ @mutex.synchronize do
57
+ ::FileUtils.rm_f(@path)
58
+ ::Dir.glob("#{@path}.*.gz").each { |f| ::FileUtils.rm_f(f) }
59
+ @line_count = nil
60
+ @dir_ensured = false
61
+ end
62
+ end
63
+
64
+ # Current file size in bytes (0 if file does not exist).
65
+ def file_size
66
+ ::File.size(@path)
67
+ rescue Errno::ENOENT
68
+ 0
69
+ end
70
+
71
+ # Number of lines in current file.
72
+ def line_count
73
+ @mutex.synchronize { @line_count || count_lines }
74
+ end
75
+
76
+ private
77
+
78
+ def ensure_dir!
79
+ return if @dir_ensured
80
+
81
+ ::FileUtils.mkdir_p(::File.dirname(@path))
82
+ @dir_ensured = true
83
+ end
84
+
85
+ def rotate_if_needed!
86
+ return unless should_rotate?
87
+
88
+ rotate!
89
+ @line_count = nil
90
+ end
91
+
92
+ def should_rotate?
93
+ file_size > @max_size ||
94
+ (@line_count && @line_count > @max_lines)
95
+ end
96
+
97
+ def rotate!
98
+ shift_rotated_files!
99
+ compress_current_file!
100
+ end
101
+
102
+ def shift_rotated_files!
103
+ @keep_rotated.downto(1) do |n|
104
+ src = rotated_path(n)
105
+ next unless ::File.exist?(src)
106
+
107
+ if n + 1 > @keep_rotated
108
+ ::FileUtils.rm_f(src)
109
+ else
110
+ ::File.rename(src, rotated_path(n + 1))
111
+ end
112
+ end
113
+ end
114
+
115
+ def compress_current_file!
116
+ return unless ::File.exist?(@path)
117
+
118
+ tmp_path = "#{rotated_path(1)}.tmp"
119
+ begin
120
+ ::Zlib::GzipWriter.open(tmp_path) do |gz|
121
+ ::File.open(@path, "rb") { |f| ::IO.copy_stream(f, gz) }
122
+ end
123
+ ::File.rename(tmp_path, rotated_path(1))
124
+ ensure
125
+ ::FileUtils.rm_f(tmp_path)
126
+ end
127
+ # Truncate rather than delete so the file always exists after rotation
128
+ ::File.open(@path, "w") { |f| f.truncate(0) }
129
+ end
130
+
131
+ def rotated_path(num)
132
+ "#{@path}.#{num}.gz"
133
+ end
134
+
135
+ def count_lines
136
+ return 0 unless ::File.exist?(@path)
137
+
138
+ ::File.foreach(@path).count
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,219 @@
1
+ # lib/e11y/adapters/dev_log/query.rb
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "time"
6
+ require "fileutils"
7
+
8
+ module E11y
9
+ module Adapters
10
+ class DevLog
11
+ # Read-only query interface for the JSONL dev log.
12
+ #
13
+ # Used by TUI, Browser Overlay, and MCP Server.
14
+ #
15
+ # Performance strategy:
16
+ # - In-memory cache invalidated by File.mtime
17
+ # - JSON parser: oj if available, stdlib JSON as fallback
18
+ # rubocop:disable Metrics/ClassLength
19
+ class Query
20
+ # Value object returned by #interactions
21
+ Interaction = Struct.new(:started_at, :trace_ids, :has_error?, :source) do
22
+ def traces_count = trace_ids.size
23
+ end
24
+
25
+ ERROR_SEVERITIES = %w[error fatal].freeze
26
+
27
+ # Choose fastest available JSON parser
28
+ JSON_LOAD = if defined?(Oj)
29
+ ->(str) { Oj.load(str) }
30
+ else
31
+ ->(str) { ::JSON.parse(str) }
32
+ end
33
+
34
+ def initialize(path)
35
+ @path = path.to_s
36
+ @cache = nil
37
+ @cache_mtime = nil
38
+ end
39
+
40
+ # Return last +limit+ events, newest-first.
41
+ def stored_events(limit: 1000, severity: nil, source: nil)
42
+ events = all_events
43
+ events = events.select { |e| e["severity"] == severity } if severity
44
+ events = events.select { |e| e.dig("metadata", "source") == source } if source
45
+ events.last(limit).reverse
46
+ end
47
+
48
+ # Find event by id (returns nil if not found).
49
+ def find_event(id)
50
+ all_events.find { |e| e["id"] == id }
51
+ end
52
+
53
+ # Full-text search in event_name and payload JSON.
54
+ def search(query_str, limit: 500)
55
+ q = query_str.downcase
56
+ all_events.select do |e|
57
+ e["event_name"].to_s.downcase.include?(q) ||
58
+ ::JSON.generate(e["payload"] || {}).downcase.include?(q)
59
+ end.last(limit).reverse
60
+ end
61
+
62
+ # All events for a given trace_id in chronological order.
63
+ def events_by_trace(trace_id)
64
+ all_events.select { |e| e["trace_id"] == trace_id }
65
+ end
66
+
67
+ # Aggregate stats about the log.
68
+ def stats
69
+ events = all_events
70
+ {
71
+ total_events: events.size,
72
+ file_size: file_size,
73
+ by_severity: events.group_by { |e| e["severity"] }.transform_values(&:count),
74
+ by_event_name: events.group_by { |e| e["event_name"] }.transform_values(&:count),
75
+ oldest_event: events.first&.dig("timestamp"),
76
+ newest_event: events.last&.dig("timestamp")
77
+ }
78
+ end
79
+
80
+ # True if log file was modified after +timestamp+.
81
+ def updated_since?(timestamp)
82
+ return false unless ::File.exist?(@path)
83
+
84
+ ::File.mtime(@path) > timestamp
85
+ end
86
+
87
+ # Remove the log file and invalidate cache.
88
+ def clear!
89
+ ::FileUtils.rm_f(@path)
90
+ invalidate_cache!
91
+ end
92
+
93
+ # Group traces into time-window interaction bands.
94
+ # Returns Array<Interaction> sorted chronologically.
95
+ def interactions(window_ms: 500, limit: 50, source: nil)
96
+ events = all_events
97
+ events = events.select { |e| e.dig("metadata", "source") == source } if source
98
+
99
+ trace_map = build_trace_map(events)
100
+ return [] if trace_map.empty?
101
+
102
+ build_interaction_groups(trace_map, window_ms: window_ms, limit: limit)
103
+ end
104
+
105
+ private
106
+
107
+ # --- interactions helpers ---
108
+
109
+ def build_trace_map(events)
110
+ trace_map = {}
111
+ events.each { |e| merge_trace_entry(trace_map, e) }
112
+ trace_map
113
+ end
114
+
115
+ def merge_trace_entry(trace_map, event)
116
+ tid = event["trace_id"]
117
+ return unless tid
118
+
119
+ started = parse_started_at(event)
120
+ return unless started
121
+
122
+ entry = trace_map[tid] ||= { started_at: started, has_error: false,
123
+ source: event.dig("metadata", "source") }
124
+ entry[:has_error] = true if ERROR_SEVERITIES.include?(event["severity"])
125
+ entry[:started_at] = started if started < entry[:started_at]
126
+ end
127
+
128
+ def build_interaction_groups(trace_map, window_ms:, limit:)
129
+ sorted = trace_map.sort_by { |_, v| v[:started_at] }
130
+ groups = []
131
+ current = nil
132
+
133
+ sorted.each do |trace_id, meta|
134
+ current = append_to_groups(groups, current, trace_id, meta, window_ms)
135
+ end
136
+
137
+ groups.last(limit).map { |grp| interaction_struct(grp) }
138
+ end
139
+
140
+ def append_to_groups(groups, current, trace_id, meta, window_ms)
141
+ if current.nil? || new_window?(current, meta, window_ms)
142
+ current = { started_at: meta[:started_at], last_started_at: meta[:started_at],
143
+ trace_ids: [], has_error: false, source: meta[:source] }
144
+ groups << current
145
+ end
146
+ current[:trace_ids] << trace_id
147
+ current[:has_error] ||= meta[:has_error]
148
+ current[:last_started_at] = meta[:started_at]
149
+ current
150
+ end
151
+
152
+ def new_window?(current, meta, window_ms)
153
+ (meta[:started_at] - current[:last_started_at]) * 1000 > window_ms
154
+ end
155
+
156
+ def interaction_struct(grp)
157
+ Interaction.new(grp[:started_at], grp[:trace_ids], grp[:has_error], grp[:source])
158
+ end
159
+
160
+ # --- cache helpers ---
161
+
162
+ def all_events
163
+ return @cache if cache_valid?
164
+
165
+ @cache = load_events
166
+ @cache_mtime = current_mtime
167
+ @cache
168
+ end
169
+
170
+ def cache_valid?
171
+ return false unless @cache && @cache_mtime
172
+ return false unless ::File.exist?(@path)
173
+
174
+ current_mtime == @cache_mtime
175
+ end
176
+
177
+ def current_mtime
178
+ ::File.mtime(@path)
179
+ rescue Errno::ENOENT
180
+ nil
181
+ end
182
+
183
+ def invalidate_cache!
184
+ @cache = nil
185
+ @cache_mtime = nil
186
+ end
187
+
188
+ def load_events
189
+ return [] unless ::File.exist?(@path)
190
+
191
+ events = []
192
+ ::File.foreach(@path) do |line|
193
+ line = line.chomp
194
+ next if line.empty?
195
+
196
+ events << JSON_LOAD.call(line)
197
+ rescue ::JSON::ParserError
198
+ next
199
+ end
200
+ events
201
+ end
202
+
203
+ def file_size
204
+ ::File.size(@path)
205
+ rescue Errno::ENOENT
206
+ 0
207
+ end
208
+
209
+ def parse_started_at(event)
210
+ ts = event.dig("metadata", "started_at") || event["timestamp"]
211
+ ::Time.parse(ts)
212
+ rescue ArgumentError, TypeError
213
+ nil
214
+ end
215
+ end
216
+ # rubocop:enable Metrics/ClassLength
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module E11y
7
+ module Adapters
8
+ # Development-only adapter that stores events in a local JSONL file
9
+ # and exposes a rich read API for TUI, Browser Overlay, and MCP Server.
10
+ #
11
+ # Auto-registered by Railtie in development/test environments.
12
+ # Do not use in production.
13
+ #
14
+ # @example Manual setup
15
+ # adapter = E11y::Adapters::DevLog.new(
16
+ # path: Rails.root.join("log", "e11y_dev.jsonl"),
17
+ # max_size: 50.megabytes,
18
+ # keep_rotated: 5
19
+ # )
20
+ class DevLog < Base
21
+ # @param path [String, Pathname]
22
+ # @param max_size [Integer] Rotation threshold in bytes (default 50 MB)
23
+ # @param max_lines [Integer] Rotation threshold in line count (default 10_000)
24
+ # @param keep_rotated [Integer] Number of .N.gz files to retain (default 5)
25
+ # @param enable_watcher [Boolean] Reserved for future file-watcher integration
26
+ def initialize(path: "log/e11y_dev.jsonl",
27
+ max_size: FileStore::DEFAULT_MAX_SIZE,
28
+ max_lines: FileStore::DEFAULT_MAX_LINES,
29
+ keep_rotated: FileStore::DEFAULT_KEEP_ROTATED,
30
+ enable_watcher: false)
31
+ super({})
32
+ @store = FileStore.new(path: path, max_size: max_size,
33
+ max_lines: max_lines, keep_rotated: keep_rotated)
34
+ @query = Query.new(@store.path)
35
+ @enable_watcher = enable_watcher
36
+ end
37
+
38
+ # Write a single event to the JSONL file.
39
+ #
40
+ # @param event_data [Hash] Event from the E11y pipeline
41
+ # @return [Boolean] true on success, false on error
42
+ def write(event_data)
43
+ @store.append(serialize(event_data))
44
+ true
45
+ rescue StandardError => e
46
+ warn "[E11y::DevLog] write failed: #{e.message}"
47
+ false
48
+ end
49
+
50
+ # --- Read API (delegated to Query) ---
51
+
52
+ # @see Query#stored_events
53
+ def stored_events(limit: 1000, severity: nil, source: nil)
54
+ @query.stored_events(limit: limit, severity: severity, source: source)
55
+ end
56
+
57
+ # @see Query#find_event
58
+ def find_event(id) = @query.find_event(id)
59
+
60
+ # @see Query#search
61
+ def search(query_str, limit: 500) = @query.search(query_str, limit: limit)
62
+
63
+ # @see Query#events_by_name
64
+ def events_by_name(name, limit: 500)
65
+ @query.stored_events(limit: limit).select { |e| e["event_name"] == name }
66
+ end
67
+
68
+ # @see Query#events_by_severity
69
+ def events_by_severity(sev, limit: 500)
70
+ @query.stored_events(limit: limit, severity: sev)
71
+ end
72
+
73
+ # @see Query#events_by_trace
74
+ def events_by_trace(trace_id) = @query.events_by_trace(trace_id)
75
+
76
+ # @see Query#interactions
77
+ def interactions(window_ms: 500, limit: 50, source: nil)
78
+ @query.interactions(window_ms: window_ms, limit: limit, source: source)
79
+ end
80
+
81
+ # @see Query#stats
82
+ def stats = @query.stats
83
+
84
+ # @see Query#updated_since?
85
+ def updated_since?(timestamp) = @query.updated_since?(timestamp)
86
+
87
+ # @see Query#clear!
88
+ def clear! = @query.clear!
89
+
90
+ # Advertise dev_log and readable capabilities.
91
+ def capabilities
92
+ super.merge(dev_log: true, readable: true)
93
+ end
94
+
95
+ private
96
+
97
+ def serialize(event_data)
98
+ data = event_data.is_a?(::Hash) ? event_data.transform_keys(&:to_s) : {}
99
+ enrich_ids!(data)
100
+ enrich_metadata!(data)
101
+ ::JSON.generate(data)
102
+ end
103
+
104
+ def enrich_ids!(data)
105
+ data["id"] ||= ::SecureRandom.uuid
106
+ data["timestamp"] ||= ::Time.now.utc.iso8601(3)
107
+ end
108
+
109
+ def enrich_metadata!(data)
110
+ source = ::Thread.current[:e11y_source] || "web"
111
+ meta = (data["metadata"] || {}).dup
112
+ meta["source"] ||= source
113
+ meta["started_at"] ||= data["timestamp"]
114
+ data["metadata"] = meta
115
+ end
116
+ end
117
+ end
118
+ end
@@ -26,11 +26,8 @@ module E11y
26
26
  #
27
27
  # adapter.write(event_name: "user.login", severity: :info)
28
28
  #
29
- # @example With Registry
30
- # E11y::Adapters::Registry.register(
31
- # :file_logger,
32
- # E11y::Adapters::File.new(path: "log/events.log")
33
- # )
29
+ # @example Configuration
30
+ # config.adapters[:file] = E11y::Adapters::File.new(path: "log/events.log")
34
31
  # rubocop:disable Metrics/ClassLength
35
32
  # File adapter contains file rotation and buffering logic as cohesive unit
36
33
  class File < Base
@@ -152,7 +149,7 @@ module E11y
152
149
 
153
150
  # Open file for writing
154
151
  def open_file!
155
- @file = ::File.open(@path, "a")
152
+ @file = ::File.open(@path, "a") # rubocop:disable Style/FileOpen -- intentional: adapter keeps file handle open for lifecycle of adapter
156
153
  @file.sync = true
157
154
  @current_date = Date.today if @rotation == :daily
158
155
  end
@@ -39,6 +39,7 @@ module E11y
39
39
  # test_adapter = E11y::Adapters::InMemory.new(max_events: nil)
40
40
  #
41
41
  # @see ADR-004 §9.1 (In-Memory Test Adapter)
42
+ # rubocop:disable Metrics/ClassLength
42
43
  class InMemory < Base
43
44
  # Default maximum number of events to store
44
45
  DEFAULT_MAX_EVENTS = 1000
@@ -118,17 +119,28 @@ module E11y
118
119
  end
119
120
  end
120
121
 
122
+ alias clear clear!
123
+
121
124
  # Find events matching pattern
122
125
  #
123
- # @param pattern [String, Regexp] Pattern to match against event_name
126
+ # @param pattern [String, Regexp, Class] Event name pattern or event class
124
127
  # @return [Array<Hash>] Matching events
125
128
  #
126
129
  # @example
127
130
  # adapter.find_events(/order/) # All order.* events
128
131
  # adapter.find_events("order.paid") # Exact match
132
+ # adapter.find_events(Events::OrderPaid) # By event class
129
133
  def find_events(pattern)
130
- pattern = Regexp.new(Regexp.escape(pattern)) if pattern.is_a?(String)
131
- @events.select { |event| event[:event_name].to_s.match?(pattern) }
134
+ pattern = event_pattern_for(pattern)
135
+ @events.select { |event| event_matches?(event, pattern) }
136
+ end
137
+
138
+ # Find first event matching pattern
139
+ #
140
+ # @param pattern [String, Regexp, Class] Event name pattern or event class
141
+ # @return [Hash, nil] First matching event or nil
142
+ def find_event(pattern)
143
+ find_events(pattern).first
132
144
  end
133
145
 
134
146
  # Count events by name
@@ -138,8 +150,10 @@ module E11y
138
150
  #
139
151
  # @example
140
152
  # adapter.event_count # Total events
141
- # adapter.event_count("order.paid") # Specific event count
142
- def event_count(event_name: nil)
153
+ # adapter.event_count("order.paid") # Specific event count (positional)
154
+ # adapter.event_count(event_name: "order.paid") # Specific event count (keyword)
155
+ def event_count(event_name = nil, **kwargs)
156
+ event_name ||= kwargs[:event_name]
143
157
  if event_name
144
158
  @events.count { |event| event[:event_name] == event_name }
145
159
  else
@@ -147,6 +161,16 @@ module E11y
147
161
  end
148
162
  end
149
163
 
164
+ # Get the most recently written event.
165
+ #
166
+ # @return [Hash, nil] The last event, or nil if none
167
+ #
168
+ # @example
169
+ # adapter.last_event # Most recently written event
170
+ def last_event
171
+ events.last
172
+ end
173
+
150
174
  # Get last N events
151
175
  #
152
176
  # @param count [Integer] Number of events to return
@@ -205,6 +229,28 @@ module E11y
205
229
 
206
230
  private
207
231
 
232
+ def event_pattern_for(pattern)
233
+ case pattern
234
+ when Class then pattern
235
+ when String, Regexp then pattern.is_a?(String) ? Regexp.new(Regexp.escape(pattern)) : pattern
236
+ else raise ArgumentError, "Pattern must be Class, String, or Regexp, got #{pattern.class}"
237
+ end
238
+ end
239
+
240
+ def event_matches?(event, pattern)
241
+ return event[:event_name].to_s.match?(pattern) if pattern.is_a?(Regexp)
242
+ return event_matches_class?(event, pattern) if pattern.is_a?(Class)
243
+
244
+ false
245
+ end
246
+
247
+ def event_matches_class?(event, klass)
248
+ event[:event_class] == klass ||
249
+ event[:event_class]&.name == klass.name ||
250
+ event[:event_name].to_s == (klass.respond_to?(:event_name) ? klass.event_name : klass.name) ||
251
+ event[:event_name].to_s.include?(klass.name)
252
+ end
253
+
208
254
  # Enforce max_events limit by dropping oldest events (FIFO)
209
255
  #
210
256
  # @return [void]
@@ -218,5 +264,6 @@ module E11y
218
264
  @dropped_count += excess
219
265
  end
220
266
  end
267
+ # rubocop:enable Metrics/ClassLength
221
268
  end
222
269
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "in_memory"
4
+
5
+ module E11y
6
+ module Adapters
7
+ # InMemoryTest Adapter — extends InMemory with test-specific helpers.
8
+ #
9
+ # Overrides `last_event` to skip Rails auto-instrumentation events
10
+ # (E11y::Events::Rails::*) that fire after each HTTP request and
11
+ # would otherwise obscure the event your test just tracked.
12
+ #
13
+ # Use this adapter in test suites; use `InMemory` in production configs.
14
+ #
15
+ # @example
16
+ # let(:adapter) { E11y::Adapters::InMemoryTest.new }
17
+ # before { E11y.register_adapter :memory, adapter }
18
+ class InMemoryTest < InMemory
19
+ # Return the last event that was NOT fired by Rails auto-instrumentation.
20
+ #
21
+ # @return [Hash, nil]
22
+ def last_event
23
+ events.reverse_each.find do |e|
24
+ !e[:event_name].to_s.start_with?("E11y::Events::Rails::")
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end