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.
- checksums.yaml +4 -4
- data/.rubocop.yml +130 -10
- data/CHANGELOG.md +56 -1
- data/CLAUDE.md +168 -0
- data/CONTRIBUTING.md +640 -0
- data/README.md +134 -702
- data/RELEASE.md +18 -3
- data/Rakefile +108 -29
- data/config/README.md +1 -1
- data/config/loki-local-config.yaml +12 -0
- data/config/otel-collector-config.yaml +44 -0
- data/cucumber.yml +1 -0
- data/docker-compose.yml +18 -2
- data/docs/ADAPTERS.md +76 -0
- data/docs/ADAPTIVE_SAMPLING.md +59 -0
- data/docs/COMPARISON.md +104 -0
- data/docs/CONFIGURATION.md +52 -0
- data/docs/DISTRIBUTED_TRACING.md +44 -0
- data/docs/LIMITATIONS.md +13 -0
- data/docs/METRICS_DSL.md +84 -0
- data/docs/PERFORMANCE.md +60 -0
- data/docs/PII_FILTERING.md +40 -0
- data/docs/PRESETS.md +65 -0
- data/docs/QUICK-START.md +546 -587
- data/docs/RAILS_INTEGRATION.md +29 -0
- data/docs/SCHEMA_VALIDATION.md +63 -0
- data/docs/SLO-PROMQL-ALERTS.md +161 -0
- data/docs/TESTING.md +69 -0
- data/docs/{ADR-001-architecture.md → architecture/ADR-001-architecture.md} +35 -64
- data/docs/{ADR-002-metrics-yabeda.md → architecture/ADR-002-metrics-yabeda.md} +62 -236
- data/docs/{ADR-003-slo-observability.md → architecture/ADR-003-slo-observability.md} +27 -466
- data/docs/{ADR-004-adapter-architecture.md → architecture/ADR-004-adapter-architecture.md} +163 -146
- data/docs/{ADR-005-tracing-context.md → architecture/ADR-005-tracing-context.md} +10 -9
- data/docs/{ADR-006-security-compliance.md → architecture/ADR-006-security-compliance.md} +184 -191
- data/docs/{ADR-007-opentelemetry-integration.md → architecture/ADR-007-opentelemetry-integration.md} +3 -21
- data/docs/{ADR-008-rails-integration.md → architecture/ADR-008-rails-integration.md} +209 -339
- data/docs/{ADR-009-cost-optimization.md → architecture/ADR-009-cost-optimization.md} +45 -54
- data/docs/architecture/ADR-010-developer-experience.md +522 -0
- data/docs/{ADR-011-testing-strategy.md → architecture/ADR-011-testing-strategy.md} +41 -83
- data/docs/{ADR-013-reliability-error-handling.md → architecture/ADR-013-reliability-error-handling.md} +37 -12
- data/docs/{ADR-014-event-driven-slo.md → architecture/ADR-014-event-driven-slo.md} +12 -24
- data/docs/{ADR-015-middleware-order.md → architecture/ADR-015-middleware-order.md} +23 -41
- data/docs/{ADR-016-self-monitoring-slo.md → architecture/ADR-016-self-monitoring-slo.md} +52 -349
- data/docs/{ADR-017-multi-rails-compatibility.md → architecture/ADR-017-multi-rails-compatibility.md} +4 -11
- data/docs/architecture/ADR-018-memory-optimization.md +366 -0
- data/docs/{ADR-INDEX.md → architecture/ADR-INDEX.md} +11 -6
- data/docs/{00-ICP-AND-TIMELINE.md → prd/00-ICP-AND-TIMELINE.md} +6 -6
- data/docs/{01-SCALE-REQUIREMENTS.md → prd/01-SCALE-REQUIREMENTS.md} +6 -6
- data/docs/prd/01-overview-vision.md +19 -14
- data/docs/use_cases/README.md +22 -23
- data/docs/use_cases/UC-001-request-scoped-debug-buffering.md +50 -44
- data/docs/use_cases/UC-002-business-event-tracking.md +26 -95
- data/docs/use_cases/UC-003-event-metrics.md +66 -0
- data/docs/use_cases/UC-004-zero-config-slo-tracking.md +42 -101
- data/docs/use_cases/UC-005-sentry-integration.md +13 -15
- data/docs/use_cases/UC-006-trace-context-management.md +30 -28
- data/docs/use_cases/UC-007-pii-filtering.md +35 -87
- data/docs/use_cases/UC-008-opentelemetry-integration.md +51 -89
- data/docs/use_cases/UC-009-multi-service-tracing.md +4 -4
- data/docs/use_cases/UC-010-background-job-tracking.md +5 -5
- data/docs/use_cases/UC-011-rate-limiting.md +95 -168
- data/docs/use_cases/UC-012-audit-trail.md +21 -46
- data/docs/use_cases/UC-013-high-cardinality-protection.md +29 -167
- data/docs/use_cases/UC-014-adaptive-sampling.md +2 -2
- data/docs/use_cases/UC-015-cost-optimization.md +46 -99
- data/docs/use_cases/UC-016-rails-logger-migration.md +39 -213
- data/docs/use_cases/UC-017-local-development.md +203 -777
- data/docs/use_cases/UC-018-testing-events.md +3 -3
- data/docs/use_cases/UC-019-retention-based-routing.md +53 -106
- data/docs/use_cases/UC-020-event-versioning.md +8 -9
- data/docs/use_cases/UC-021-error-handling-retry-dlq.md +18 -22
- data/docs/use_cases/UC-022-event-registry.md +15 -21
- data/docs/use_cases/backlog.md +119 -87
- data/e11y.gemspec +2 -2
- data/gems/e11y-devtools/README.md +136 -0
- data/gems/e11y-devtools/config/routes.rb +8 -0
- data/gems/e11y-devtools/e11y-devtools.gemspec +25 -0
- data/gems/e11y-devtools/exe/e11y +34 -0
- data/gems/e11y-devtools/lib/e11y/devtools/mcp/server.rb +96 -0
- data/gems/e11y-devtools/lib/e11y/devtools/mcp/tool_base.rb +25 -0
- data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/clear.rb +31 -0
- data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/errors.rb +35 -0
- data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/event_detail.rb +33 -0
- data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/events_by_trace.rb +33 -0
- data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/interactions.rb +40 -0
- data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/recent_events.rb +34 -0
- data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/search.rb +34 -0
- data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/stats.rb +30 -0
- data/gems/e11y-devtools/lib/e11y/devtools/overlay/assets/overlay.js +115 -0
- data/gems/e11y-devtools/lib/e11y/devtools/overlay/controller.rb +54 -0
- data/gems/e11y-devtools/lib/e11y/devtools/overlay/engine.rb +26 -0
- data/gems/e11y-devtools/lib/e11y/devtools/overlay/middleware.rb +80 -0
- data/gems/e11y-devtools/lib/e11y/devtools/overlay/rails_controller.rb +42 -0
- data/gems/e11y-devtools/lib/e11y/devtools/tui/app.rb +262 -0
- data/gems/e11y-devtools/lib/e11y/devtools/tui/grouping.rb +66 -0
- data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/event_detail.rb +62 -0
- data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/event_list.rb +70 -0
- data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/interaction_list.rb +47 -0
- data/gems/e11y-devtools/lib/e11y/devtools/version.rb +8 -0
- data/gems/e11y-devtools/lib/e11y/devtools.rb +13 -0
- data/gems/e11y-devtools/spec/e11y/devtools/mcp/tools_spec.rb +107 -0
- data/gems/e11y-devtools/spec/e11y/devtools/overlay/controller_spec.rb +58 -0
- data/gems/e11y-devtools/spec/e11y/devtools/overlay/middleware_spec.rb +46 -0
- data/gems/e11y-devtools/spec/e11y/devtools/tui/app_spec.rb +85 -0
- data/gems/e11y-devtools/spec/e11y/devtools/tui/grouping_spec.rb +64 -0
- data/gems/e11y-devtools/spec/spec_helper.rb +5 -0
- data/gems/e11y-devtools/spec/tui/widgets/event_list_spec.rb +44 -0
- data/gems/e11y-devtools/spec/tui/widgets/interaction_list_spec.rb +62 -0
- data/lib/e11y/adapters/audit_encrypted.rb +53 -11
- data/lib/e11y/adapters/base.rb +33 -34
- data/lib/e11y/adapters/dev_log/file_store.rb +143 -0
- data/lib/e11y/adapters/dev_log/query.rb +219 -0
- data/lib/e11y/adapters/dev_log.rb +118 -0
- data/lib/e11y/adapters/file.rb +3 -6
- data/lib/e11y/adapters/in_memory.rb +52 -5
- data/lib/e11y/adapters/in_memory_test.rb +29 -0
- data/lib/e11y/adapters/loki.rb +58 -23
- data/lib/e11y/adapters/null.rb +82 -0
- data/lib/e11y/adapters/opentelemetry_collector.rb +183 -0
- data/lib/e11y/adapters/otel_logs.rb +136 -23
- data/lib/e11y/adapters/sentry.rb +4 -7
- data/lib/e11y/adapters/stdout.rb +73 -7
- data/lib/e11y/adapters/yabeda.rb +153 -29
- data/lib/e11y/buffers/adaptive_buffer.rb +3 -17
- data/lib/e11y/buffers/{request_scoped_buffer.rb → ephemeral_buffer.rb} +72 -58
- data/lib/e11y/buffers/ring_buffer.rb +3 -16
- data/lib/e11y/configuration.rb +272 -0
- data/lib/e11y/console.rb +10 -17
- data/lib/e11y/current.rb +53 -1
- data/lib/e11y/debug/pipeline_inspector.rb +96 -0
- data/lib/e11y/documentation/generator.rb +48 -0
- data/lib/e11y/event/base.rb +176 -82
- data/lib/e11y/event/value_sampling_config.rb +1 -5
- data/lib/e11y/events/rails/database/query.rb +1 -4
- data/lib/e11y/events/rails/job/failed.rb +2 -0
- data/lib/e11y/instruments/active_job.rb +46 -12
- data/lib/e11y/instruments/rails_instrumentation.rb +49 -24
- data/lib/e11y/instruments/sidekiq.rb +137 -31
- data/lib/e11y/linters/base.rb +11 -0
- data/lib/e11y/linters/pii/pii_declaration_linter.rb +120 -0
- data/lib/e11y/linters/slo/config_consistency_linter.rb +76 -0
- data/lib/e11y/linters/slo/explicit_declaration_linter.rb +36 -0
- data/lib/e11y/linters/slo/slo_status_from_linter.rb +41 -0
- data/lib/e11y/logger/bridge.rb +26 -7
- data/lib/e11y/metrics/cardinality_protection.rb +10 -15
- data/lib/e11y/metrics/cardinality_tracker.rb +16 -6
- data/lib/e11y/metrics/registry.rb +3 -5
- data/lib/e11y/metrics/test_backend.rb +62 -0
- data/lib/e11y/metrics.rb +56 -10
- data/lib/e11y/middleware/adapter_resolver.rb +40 -0
- data/lib/e11y/middleware/audit_signing.rb +43 -6
- data/lib/e11y/middleware/baggage_protection.rb +75 -0
- data/lib/e11y/middleware/dev_log_source.rb +24 -0
- data/lib/e11y/middleware/event_slo.rb +23 -9
- data/lib/e11y/middleware/otel_span.rb +23 -0
- data/lib/e11y/middleware/pii_filter.rb +104 -75
- data/lib/e11y/middleware/rate_limiting.rb +54 -27
- data/lib/e11y/middleware/request.rb +70 -23
- data/lib/e11y/middleware/routing.rb +78 -21
- data/lib/e11y/middleware/sampling.rb +66 -17
- data/lib/e11y/middleware/self_monitoring_emit.rb +39 -0
- data/lib/e11y/middleware/trace_context.rb +45 -10
- data/lib/e11y/middleware/track_latency.rb +34 -0
- data/lib/e11y/middleware/validation.rb +7 -16
- data/lib/e11y/middleware/versioning.rb +26 -22
- data/lib/e11y/opentelemetry/semantic_conventions.rb +109 -0
- data/lib/e11y/opentelemetry/span_creator.rb +142 -0
- data/lib/e11y/pii/patterns.rb +12 -1
- data/lib/e11y/pipeline/builder.rb +1 -1
- data/lib/e11y/presets/audit_event.rb +13 -2
- data/lib/e11y/railtie.rb +52 -15
- data/lib/e11y/registry.rb +306 -0
- data/lib/e11y/reliability/circuit_breaker.rb +19 -21
- data/lib/e11y/reliability/dlq/base.rb +71 -0
- data/lib/e11y/reliability/dlq/file_adapter.rb +301 -0
- data/lib/e11y/reliability/dlq/file_storage.rb +63 -34
- data/lib/e11y/reliability/dlq/filter.rb +37 -54
- data/lib/e11y/reliability/retry_handler.rb +26 -29
- data/lib/e11y/reliability/retry_rate_limiter.rb +3 -11
- data/lib/e11y/sampling/error_spike_detector.rb +0 -2
- data/lib/e11y/sampling/load_monitor.rb +5 -9
- data/lib/e11y/sampling/stratified_tracker.rb +18 -0
- data/lib/e11y/self_monitoring/buffer_monitor.rb +2 -0
- data/lib/e11y/self_monitoring/performance_monitor.rb +19 -61
- data/lib/e11y/self_monitoring/reliability_monitor.rb +4 -74
- data/lib/e11y/slo/config_loader.rb +40 -0
- data/lib/e11y/slo/config_validator.rb +58 -0
- data/lib/e11y/slo/dashboard_generator.rb +122 -0
- data/lib/e11y/slo/event_driven.rb +8 -0
- data/lib/e11y/slo/tracker.rb +31 -4
- data/lib/e11y/testing/have_tracked_event_matcher.rb +190 -0
- data/lib/e11y/testing/rspec_matchers.rb +21 -0
- data/lib/e11y/testing/snapshot_matcher.rb +86 -0
- data/lib/e11y/trace_context/sampler.rb +35 -0
- data/lib/e11y/tracing/faraday_middleware.rb +31 -0
- data/lib/e11y/tracing/net_http_patch.rb +33 -0
- data/lib/e11y/tracing/propagator.rb +116 -0
- data/lib/e11y/tracing.rb +47 -0
- data/lib/e11y/version.rb +1 -1
- data/lib/e11y/versioning/version_extractor.rb +32 -0
- data/lib/e11y.rb +141 -265
- data/lib/generators/e11y/event/event_generator.rb +22 -0
- data/lib/generators/e11y/event/templates/event.rb.tt +16 -0
- data/lib/generators/e11y/grafana_dashboard/grafana_dashboard_generator.rb +30 -0
- data/lib/generators/e11y/grafana_dashboard/templates/e11y_dashboard.json +81 -0
- data/lib/generators/e11y/install/install_generator.rb +34 -0
- data/lib/generators/e11y/install/templates/e11y.rb +239 -0
- data/lib/generators/e11y/prometheus_alerts/prometheus_alerts_generator.rb +29 -0
- data/lib/generators/e11y/prometheus_alerts/templates/e11y_alerts.yml +28 -0
- data/lib/tasks/e11y_docs.rake +30 -0
- data/lib/tasks/e11y_events.rake +71 -0
- data/lib/tasks/e11y_lint.rake +91 -0
- data/lib/tasks/e11y_slo.rake +29 -0
- metadata +129 -39
- data/docs/ADR-010-developer-experience.md +0 -2166
- data/docs/API-REFERENCE-L28.md +0 -914
- data/docs/COMPREHENSIVE-CONFIGURATION.md +0 -2366
- data/docs/CONTRIBUTING.md +0 -312
- data/docs/IMPLEMENTATION_NOTES.md +0 -2804
- data/docs/IMPLEMENTATION_PLAN.md +0 -1971
- data/docs/IMPLEMENTATION_PLAN_ARCHITECTURE.md +0 -586
- data/docs/PLAN.md +0 -148
- data/docs/README.md +0 -296
- data/docs/design/00-memory-optimization.md +0 -593
- data/docs/guides/MIGRATION-L27-L28.md +0 -692
- data/docs/guides/PERFORMANCE-BENCHMARKS.md +0 -434
- data/docs/guides/README.md +0 -44
- data/docs/use_cases/UC-003-pattern-based-metrics.md +0 -1627
- data/lib/e11y/adapters/registry.rb +0 -141
- /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
|
data/lib/e11y/adapters/file.rb
CHANGED
|
@@ -26,11 +26,8 @@ module E11y
|
|
|
26
26
|
#
|
|
27
27
|
# adapter.write(event_name: "user.login", severity: :info)
|
|
28
28
|
#
|
|
29
|
-
# @example
|
|
30
|
-
# E11y::Adapters::
|
|
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]
|
|
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 =
|
|
131
|
-
@events.select { |event|
|
|
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
|
-
|
|
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
|