e11y-devtools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 102fbf2c428a06eba9c77929f68c2e4843cb67cb7b46d1f83540997cee1a16be
4
+ data.tar.gz: 7c990a1c47e5e4a631efcb6ecb658cc1898e9530243c88d017cc0d13454b6c00
5
+ SHA512:
6
+ metadata.gz: c1fcdccf770d5f98fcac0e04b57f1b708b910eff26bfeeb70dbe1da87465ca5e88afa384692a706f16ea711b3ef5be02abf45ea465218698ca192fb3adfbf639
7
+ data.tar.gz: b48620b09a3327808f33ee3e7d3609652501a9450cf32b23c57238aa65b82ee5e552ed41544560821beb75c010ca07b7096d9cb79ec3c1ee4b2183f0ed70268e
data/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # e11y-devtools
2
+
3
+ Developer tools for [e11y](https://github.com/aseletskiy/e11y) — the Rails observability gem.
4
+
5
+ Three complementary viewers for the same JSONL log:
6
+
7
+ | Viewer | How to use |
8
+ |--------|-----------|
9
+ | **TUI** (terminal) | `bundle exec e11y` |
10
+ | **Browser Overlay** | Automatic in development — floating badge in bottom-right |
11
+ | **MCP Server** | `bundle exec e11y mcp` — AI integration for Cursor / Claude Code |
12
+
13
+ ## Installation
14
+
15
+ Add to your Gemfile (development group only):
16
+
17
+ ```ruby
18
+ # Gemfile
19
+ gem "e11y", "~> 1.0"
20
+ gem "e11y-devtools", "~> 0.1.0", group: :development
21
+ ```
22
+
23
+ Then run `bundle install`.
24
+
25
+ ## TUI — Interactive Log Viewer
26
+
27
+ ```bash
28
+ bundle exec e11y # Open TUI (default)
29
+ bundle exec e11y tui # Same as above
30
+ bundle exec e11y tail # Stream events to stdout
31
+ bundle exec e11y help # Show help
32
+ ```
33
+
34
+ ### TUI Keyboard Shortcuts
35
+
36
+ | Key | Action |
37
+ |-----|--------|
38
+ | `↑` / `k` | Move up |
39
+ | `↓` / `j` | Move down |
40
+ | `Enter` | Drill in (interactions → events → detail) |
41
+ | `Esc` / `b` | Go back |
42
+ | `w` | Filter: web requests only |
43
+ | `j` | Filter: background jobs only |
44
+ | `a` | Filter: all sources |
45
+ | `r` | Reload manually |
46
+ | `c` | Copy event JSON to clipboard (in detail view) |
47
+ | `q` | Quit |
48
+
49
+ ## Browser Overlay
50
+
51
+ When `gem "e11y-devtools"` is in your Gemfile, the overlay badge appears automatically in development. No configuration needed — the Railtie mounts it.
52
+
53
+ The badge shows:
54
+ - Total event count for the current request
55
+ - Error count (red badge when errors present)
56
+ - Click to expand the slide-in panel with full event list
57
+
58
+ ## MCP Server — AI Integration
59
+
60
+ Start the server:
61
+
62
+ ```bash
63
+ # stdio (for Cursor / Claude Code)
64
+ bundle exec e11y mcp
65
+
66
+ # HTTP (for direct integration)
67
+ bundle exec e11y mcp --port 3099
68
+ ```
69
+
70
+ Add to `.cursor/mcp.json` or `~/.claude/mcp.json`:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "e11y": {
76
+ "command": "bundle",
77
+ "args": ["exec", "e11y", "mcp"],
78
+ "cwd": "/path/to/your/rails/app"
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ ### Available MCP Tools
85
+
86
+ | Tool | Description |
87
+ |------|-------------|
88
+ | `recent_events` | Get latest N events (filterable by severity) |
89
+ | `events_by_trace` | Get all events for a trace ID |
90
+ | `search` | Full-text search across event names and payloads |
91
+ | `stats` | Aggregate statistics (total, by severity, oldest/newest) |
92
+ | `interactions` | Time-grouped interactions (parallel requests) |
93
+ | `event_detail` | Full payload for a single event by ID |
94
+ | `errors` | Recent error/fatal events only — fastest way to see what went wrong |
95
+ | `clear` | Clear the dev log |
96
+
97
+ ## Configuration
98
+
99
+ ```ruby
100
+ # config/initializers/e11y.rb
101
+ E11y.configure do |config|
102
+ config.register_adapter :dev_log, E11y::Adapters::DevLog.new(
103
+ path: Rails.root.join("log", "e11y_dev.jsonl"),
104
+ max_size: ENV.fetch("E11Y_MAX_SIZE", 50).to_i * 1024 * 1024, # 50 MB
105
+ max_lines: ENV.fetch("E11Y_MAX_EVENTS", 10_000).to_i,
106
+ keep_rotated: ENV.fetch("E11Y_KEEP_ROTATED", 5).to_i
107
+ )
108
+ end
109
+ ```
110
+
111
+ ### Environment Variables
112
+
113
+ | Variable | Default | Description |
114
+ |----------|---------|-------------|
115
+ | `E11Y_MAX_EVENTS` | `10000` | Max lines before rotation |
116
+ | `E11Y_MAX_SIZE` | `50` | Max log size in MB before rotation |
117
+ | `E11Y_KEEP_ROTATED` | `5` | Number of compressed `.gz` files to keep |
118
+
119
+ ## Log Format
120
+
121
+ Events are stored as JSONL (one JSON object per line) at `log/e11y_dev.jsonl`.
122
+ Rotated files are numbered and gzip-compressed: `e11y_dev.jsonl.1.gz`, `.2.gz`, etc.
123
+
124
+ ## Architecture
125
+
126
+ ```
127
+ log/e11y_dev.jsonl ← E11y::Adapters::DevLog (write)
128
+
129
+ E11y::Adapters::DevLog::Query (read, cache, search, grouping)
130
+
131
+ ┌─────┴──────┬──────────────┬──────────────┐
132
+ TUI Browser MCP Server
133
+ (ratatui_ruby) Overlay (gem 'mcp')
134
+ (Rack)
135
+ ```
136
+
137
+ The JSONL file is the single source of truth. All viewers are stateless readers — they never write.
data/exe/e11y ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "e11y/devtools"
5
+
6
+ command = ARGV.shift || "tui"
7
+
8
+ case command
9
+ when "tui"
10
+ require "e11y/devtools/tui/app"
11
+ E11y::Devtools::Tui::App.new.run
12
+ when "mcp"
13
+ require "e11y/devtools/mcp/server"
14
+ E11y::Devtools::Mcp::Server.new.run(
15
+ transport: ARGV.include?("--port") ? :http : :stdio,
16
+ port: (ARGV[ARGV.index("--port") + 1] if ARGV.include?("--port"))&.to_i
17
+ )
18
+ when "tail"
19
+ require "e11y/devtools/tui/tail"
20
+ E11y::Devtools::Tui::Tail.new.run
21
+ when "help", "--help", "-h"
22
+ puts <<~HELP
23
+ bundle exec e11y [command]
24
+
25
+ Commands:
26
+ tui (default) Interactive TUI — browse events and traces
27
+ mcp MCP server for Cursor / Claude Code AI integration
28
+ tail Stream new events to stdout (pipe-friendly)
29
+ help Show this help
30
+ HELP
31
+ else
32
+ warn "Unknown command: #{command}. Run `bundle exec e11y help`."
33
+ exit 1
34
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "e11y/adapters/dev_log/query"
5
+ require_relative "tools/recent_events"
6
+ require_relative "tools/events_by_trace"
7
+ require_relative "tools/search"
8
+ require_relative "tools/stats"
9
+ require_relative "tools/interactions"
10
+ require_relative "tools/event_detail"
11
+ require_relative "tools/errors"
12
+ require_relative "tools/clear"
13
+
14
+ module E11y
15
+ module Devtools
16
+ module Mcp
17
+ # MCP Server wrapping the E11y DevLog adapter.
18
+ #
19
+ # Exposes 8 tools to AI tools like Cursor and Claude Code.
20
+ # Supports stdio (default) and StreamableHTTP transports.
21
+ #
22
+ # @example Start stdio server
23
+ # E11y::Devtools::Mcp::Server.new.run
24
+ #
25
+ # @example Start HTTP server on port 3099
26
+ # E11y::Devtools::Mcp::Server.new.run(transport: :http, port: 3099)
27
+ class Server
28
+ TOOLS = [
29
+ Tools::RecentEvents, Tools::EventsByTrace, Tools::Search,
30
+ Tools::Stats, Tools::Interactions, Tools::EventDetail,
31
+ Tools::Errors, Tools::Clear
32
+ ].freeze
33
+
34
+ def initialize(log_path: nil)
35
+ @log_path = log_path || auto_detect_log_path
36
+ @store = E11y::Adapters::DevLog::Query.new(@log_path)
37
+ end
38
+
39
+ # Start the MCP server.
40
+ #
41
+ # @param transport [:stdio, :http] Transport to use
42
+ # @param port [Integer, nil] HTTP port (default 3099)
43
+ def run(transport: :stdio, port: nil)
44
+ require "mcp"
45
+ server = build_mcp_server
46
+ case transport
47
+ when :stdio then run_stdio(server)
48
+ when :http then run_http(server, port || 3099)
49
+ else raise ArgumentError, "Unknown transport: #{transport}"
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def build_mcp_server
56
+ MCP::Server.new(
57
+ name: "e11y",
58
+ version: E11y::Devtools::VERSION,
59
+ tools: TOOLS,
60
+ server_context: { store: @store }
61
+ )
62
+ end
63
+
64
+ def run_stdio(server)
65
+ t = MCP::Server::Transports::StdioTransport.new(server)
66
+ server.transport = t
67
+ t.open
68
+ end
69
+
70
+ def run_http(server, port)
71
+ require "webrick"
72
+ t = MCP::Server::Transports::StreamableHTTPTransport.new(server)
73
+ server.transport = t
74
+ s = WEBrick::HTTPServer.new(Port: port, Logger: WEBrick::Log.new(nil))
75
+ s.mount("/mcp", t)
76
+ trap("INT") { s.shutdown }
77
+ s.start
78
+ end
79
+
80
+ def auto_detect_log_path
81
+ dir = Pathname.new(Dir.pwd)
82
+ loop do
83
+ candidate = dir.join("log", "e11y_dev.jsonl")
84
+ return candidate.to_s if candidate.exist?
85
+
86
+ parent = dir.parent
87
+ break if parent == dir
88
+
89
+ dir = parent
90
+ end
91
+ "log/e11y_dev.jsonl"
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E11y
4
+ module Devtools
5
+ module Mcp
6
+ # Conditional base: use MCP::Tool if available, otherwise plain class.
7
+ # This allows tests to run without the mcp gem installed.
8
+ ToolBase = if defined?(MCP::Tool)
9
+ MCP::Tool
10
+ else
11
+ Class.new do
12
+ def self.description(desc = nil)
13
+ @description = desc if desc
14
+ @description
15
+ end
16
+
17
+ def self.input_schema(schema = nil)
18
+ @input_schema = schema if schema
19
+ @input_schema
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mcp"
5
+ rescue LoadError
6
+ # mcp gem not available — ToolBase will use plain class
7
+ end
8
+ require_relative "../tool_base"
9
+
10
+ module E11y
11
+ module Devtools
12
+ module Mcp
13
+ module Tools
14
+ # Clears the E11y development log file.
15
+ class Clear < ToolBase
16
+ description "Clear the E11y development log file"
17
+
18
+ input_schema(
19
+ type: :object,
20
+ properties: {}
21
+ )
22
+
23
+ def self.call(server_context:, **_opts)
24
+ server_context[:store].clear!
25
+ "E11y log cleared successfully"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mcp"
5
+ rescue LoadError
6
+ # mcp gem not available — ToolBase will use plain class
7
+ end
8
+ require_relative "../tool_base"
9
+
10
+ module E11y
11
+ module Devtools
12
+ module Mcp
13
+ module Tools
14
+ # Returns recent error and fatal events only.
15
+ class Errors < ToolBase
16
+ ERROR_SEVERITIES = %w[error fatal].freeze
17
+
18
+ description "Get recent error and fatal events only"
19
+
20
+ input_schema(
21
+ type: :object,
22
+ properties: {
23
+ limit: { type: :integer, description: "Max events", default: 20 }
24
+ }
25
+ )
26
+
27
+ def self.call(server_context:, limit: 20)
28
+ events = server_context[:store].stored_events(limit: limit * 5)
29
+ events.select { |e| ERROR_SEVERITIES.include?(e["severity"]) }.first(limit)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mcp"
5
+ rescue LoadError
6
+ # mcp gem not available — ToolBase will use plain class
7
+ end
8
+ require_relative "../tool_base"
9
+
10
+ module E11y
11
+ module Devtools
12
+ module Mcp
13
+ module Tools
14
+ # Returns the full payload of a single event by ID.
15
+ class EventDetail < ToolBase
16
+ description "Get full payload of a single event by ID"
17
+
18
+ input_schema(
19
+ type: :object,
20
+ required: ["event_id"],
21
+ properties: {
22
+ event_id: { type: :string, description: "Event UUID" }
23
+ }
24
+ )
25
+
26
+ def self.call(event_id:, server_context:)
27
+ server_context[:store].find_event(event_id) || { error: "Event #{event_id} not found" }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mcp"
5
+ rescue LoadError
6
+ # mcp gem not available — ToolBase will use plain class
7
+ end
8
+ require_relative "../tool_base"
9
+
10
+ module E11y
11
+ module Devtools
12
+ module Mcp
13
+ module Tools
14
+ # Returns all events for a specific trace ID in chronological order.
15
+ class EventsByTrace < ToolBase
16
+ description "Get all events for a specific trace ID in chronological order"
17
+
18
+ input_schema(
19
+ type: :object,
20
+ required: ["trace_id"],
21
+ properties: {
22
+ trace_id: { type: :string, description: "Trace ID" }
23
+ }
24
+ )
25
+
26
+ def self.call(trace_id:, server_context:)
27
+ server_context[:store].events_by_trace(trace_id)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mcp"
5
+ rescue LoadError
6
+ # mcp gem not available — ToolBase will use plain class
7
+ end
8
+ require_relative "../tool_base"
9
+
10
+ module E11y
11
+ module Devtools
12
+ module Mcp
13
+ module Tools
14
+ # Returns time-grouped interactions (parallel requests from one user action).
15
+ class Interactions < ToolBase
16
+ description "Get time-grouped interactions (parallel requests from one user action)"
17
+
18
+ input_schema(
19
+ type: :object,
20
+ properties: {
21
+ limit: { type: :integer, description: "Max interactions", default: 20 },
22
+ window_ms: { type: :integer, description: "Grouping window in ms", default: 500 }
23
+ }
24
+ )
25
+
26
+ def self.call(server_context:, limit: 20, window_ms: 500)
27
+ server_context[:store].interactions(limit: limit, window_ms: window_ms).map do |ix|
28
+ {
29
+ started_at: ix.started_at.iso8601(3),
30
+ trace_ids: ix.trace_ids,
31
+ has_error: ix.has_error?,
32
+ traces_count: ix.traces_count
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mcp"
5
+ rescue LoadError
6
+ # mcp gem not available — ToolBase will use plain class
7
+ end
8
+ require_relative "../tool_base"
9
+
10
+ module E11y
11
+ module Devtools
12
+ module Mcp
13
+ module Tools
14
+ # Returns the most recent events from the dev log.
15
+ class RecentEvents < ToolBase
16
+ description "Get recent E11y events from the development log"
17
+
18
+ input_schema(
19
+ type: :object,
20
+ properties: {
21
+ limit: { type: :integer, description: "Max events to return (default 50)", default: 50 },
22
+ severity: { type: :string, description: "Filter by severity",
23
+ enum: %w[debug info warn error fatal] }
24
+ }
25
+ )
26
+
27
+ def self.call(server_context:, limit: 50, severity: nil)
28
+ server_context[:store].stored_events(limit: limit, severity: severity)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mcp"
5
+ rescue LoadError
6
+ # mcp gem not available — ToolBase will use plain class
7
+ end
8
+ require_relative "../tool_base"
9
+
10
+ module E11y
11
+ module Devtools
12
+ module Mcp
13
+ module Tools
14
+ # Full-text search across event names and payload content.
15
+ class Search < ToolBase
16
+ description "Full-text search across event names and payload content"
17
+
18
+ input_schema(
19
+ type: :object,
20
+ required: ["query"],
21
+ properties: {
22
+ query: { type: :string, description: "Search term" },
23
+ limit: { type: :integer, description: "Max results", default: 50 }
24
+ }
25
+ )
26
+
27
+ def self.call(query:, server_context:, limit: 50)
28
+ server_context[:store].search(query, limit: limit)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mcp"
5
+ rescue LoadError
6
+ # mcp gem not available — ToolBase will use plain class
7
+ end
8
+ require_relative "../tool_base"
9
+
10
+ module E11y
11
+ module Devtools
12
+ module Mcp
13
+ module Tools
14
+ # Returns aggregate statistics about the E11y development log.
15
+ class Stats < ToolBase
16
+ description "Get aggregate statistics about the E11y development log"
17
+
18
+ input_schema(
19
+ type: :object,
20
+ properties: {}
21
+ )
22
+
23
+ def self.call(server_context:, **_opts)
24
+ server_context[:store].stats
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "e11y/adapters/dev_log/query"
4
+ require "e11y/adapters/dev_log"
5
+
6
+ module E11y
7
+ module Devtools
8
+ module Overlay
9
+ # Plain Ruby controller logic — testable without Rails.
10
+ # Used by the Rails route handlers (see config/routes.rb).
11
+ class Controller
12
+ def initialize(query = nil)
13
+ @query = query || resolve_query
14
+ end
15
+
16
+ def events_for(trace_id: nil, limit: 50)
17
+ if trace_id && !trace_id.empty?
18
+ @query.events_by_trace(trace_id)
19
+ else
20
+ @query.stored_events(limit: limit)
21
+ end
22
+ end
23
+
24
+ def recent_events(limit: 50)
25
+ clamped = limit.to_i.clamp(1, 500)
26
+ @query.stored_events(limit: clamped)
27
+ end
28
+
29
+ def clear_log!
30
+ @query.clear!
31
+ end
32
+
33
+ def stats
34
+ @query.stats
35
+ end
36
+
37
+ private
38
+
39
+ def resolve_query
40
+ if defined?(E11y) && E11y.respond_to?(:configuration)
41
+ adapter = E11y.configuration.adapters[:dev_log]
42
+ return adapter if adapter.respond_to?(:stored_events)
43
+ end
44
+ default_path = if defined?(Rails) && Rails.respond_to?(:root)
45
+ Rails.root.join("log", "e11y_dev.jsonl").to_s
46
+ else
47
+ "log/e11y_dev.jsonl"
48
+ end
49
+ E11y::Adapters::DevLog::Query.new(default_path)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module E11y
6
+ module Devtools
7
+ module Overlay
8
+ # Rails Engine that mounts JSON endpoints at /_e11y/
9
+ # and injects the overlay badge via Rack middleware.
10
+ class Engine < Rails::Engine
11
+ isolate_namespace E11y::Devtools::Overlay
12
+
13
+ initializer "e11y_devtools.overlay.middleware" do |app|
14
+ next unless Rails.env.development? || Rails.env.test?
15
+
16
+ require "e11y/devtools/overlay/middleware"
17
+ app.middleware.use E11y::Devtools::Overlay::Middleware
18
+ end
19
+
20
+ config.generators do |g|
21
+ g.test_framework :rspec
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end