rails_trace_viewer 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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +224 -0
- data/app/channels/rails_trace_viewer/trace_channel.rb +7 -0
- data/app/controllers/rails_trace_viewer/traces_controller.rb +6 -0
- data/app/views/rails_trace_viewer/traces/show.html.erb +410 -0
- data/config/routes.rb +3 -0
- data/lib/rails_trace_viewer/broadcaster.rb +13 -0
- data/lib/rails_trace_viewer/collector.rb +151 -0
- data/lib/rails_trace_viewer/engine.rb +32 -0
- data/lib/rails_trace_viewer/job_link_registry.rb +42 -0
- data/lib/rails_trace_viewer/route_resolver.rb +20 -0
- data/lib/rails_trace_viewer/subscribers/active_job_subscriber.rb +72 -0
- data/lib/rails_trace_viewer/subscribers/controller_subscriber.rb +51 -0
- data/lib/rails_trace_viewer/subscribers/method_subscriber.rb +59 -0
- data/lib/rails_trace_viewer/subscribers/sidekiq_subscriber.rb +111 -0
- data/lib/rails_trace_viewer/subscribers/sql_subscriber.rb +43 -0
- data/lib/rails_trace_viewer/subscribers/view_subscriber.rb +34 -0
- data/lib/rails_trace_viewer/trace_context.rb +56 -0
- data/lib/rails_trace_viewer/version.rb +5 -0
- data/lib/rails_trace_viewer.rb +22 -0
- metadata +125 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ee7089d7fb74980ab3da97dff49ad0db37fca991d11c26deea295884f53d507e
|
|
4
|
+
data.tar.gz: c28776d4e3cd56a26cdd9cd9a30fe9b36079149e1614582c8a00f779109f0dfe
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 119bdb43c3f579398839a527253273a740aa7039f05b7b9918aefd3abda410fb51d44154d04349d4fa3a33860e84a66381506cb08cb1efdfe1e72388204db843
|
|
7
|
+
data.tar.gz: 3cf763350d1b8df9bb775609fb5be65d84c8014f2c161b23b765dc32faa4876636f37fe20b9bf94361789236763d7c3c41253d7d34cbfda11dea9f866a4d82a1
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Aditya-JOSHย
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Rails Trace Viewer
|
|
2
|
+
|
|
3
|
+
An educational and debugging tool for Ruby on Rails to visualize the request lifecycle in real-time.
|
|
4
|
+
|
|
5
|
+
Rails Trace Viewer provides a beautiful, interactive Call Stack Tree that visualizes how your Rails application processes requests. It traces the flow from the Controller through Models, Views, SQL Queries, and even across process boundaries into Sidekiq Background Jobs.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## ๐ฏ Purpose
|
|
10
|
+
|
|
11
|
+
This gem is designed for beginners and advanced developers alike to:
|
|
12
|
+
|
|
13
|
+
- Visualize the **"Magic"**: See exactly what happens when you hit a route.
|
|
14
|
+
- **Debug Distributed Traces**: Watch a request enqueue a Sidekiq job and follow that execution into the worker process in a single connected tree.
|
|
15
|
+
- **Spot Performance Issues**: Identify N+1 queries, slow renders, or redundant method calls.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## โจ Key Features
|
|
20
|
+
|
|
21
|
+
- ๐ **Real-time Visualization**: Traces appear instantly via WebSockets.
|
|
22
|
+
- ๐งฉ **Distributed Tracing**: Seamlessly links Controller actions to Sidekiq Jobs (enqueue & perform).
|
|
23
|
+
- ๐ **Deep Inspection**: Click any node to see arguments, SQL binds, file paths, and line numbers.
|
|
24
|
+
- ๐จ **Beautiful UI**: Interactive infinite canvas with panning, zooming, and auto-centering.
|
|
25
|
+
- ๐ **Zero Production Impact**: Designed to run safely in development mode.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## ๐ฆ Installation
|
|
30
|
+
|
|
31
|
+
Add this line to your application's Gemfile:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
gem 'rails_trace_viewer', group: :development
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then execute:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bundle install
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## ๐ง Configuration (Crucial)
|
|
46
|
+
|
|
47
|
+
To enable real-time tracing, you must ensure ActionCable is correctly configured and the engine is mounted.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
### 1. Setup ActionCable Connection
|
|
52
|
+
|
|
53
|
+
Create or update `app/channels/application_cable/connection.rb`:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
module ApplicationCable
|
|
57
|
+
class Connection < ActionCable::Connection::Base
|
|
58
|
+
identified_by :current_user
|
|
59
|
+
|
|
60
|
+
def connect
|
|
61
|
+
self.current_user = find_verified_user
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def find_verified_user
|
|
67
|
+
if current_user = env['warden'].user
|
|
68
|
+
current_user
|
|
69
|
+
else
|
|
70
|
+
reject_unauthorized_connection
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
### 2. Mount Routes
|
|
80
|
+
|
|
81
|
+
Update `config/routes.rb`:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
Rails.application.routes.draw do
|
|
85
|
+
# Mount Trace Viewer (Development Only)
|
|
86
|
+
if Rails.env.development?
|
|
87
|
+
mount RailsTraceViewer::Engine => '/rails_trace_viewer'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Mount ActionCable
|
|
91
|
+
mount ActionCable.server => '/cable'
|
|
92
|
+
|
|
93
|
+
# Optional: Mount Sidekiq Web
|
|
94
|
+
mount Sidekiq::Web => '/sidekiq'
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
### 3. Configure Action Cable (Redis)
|
|
101
|
+
|
|
102
|
+
Update `config/cable.yml`:
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
development:
|
|
106
|
+
adapter: redis
|
|
107
|
+
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### 4. Environment Configuration
|
|
113
|
+
|
|
114
|
+
In `config/environments/development.rb`:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
Rails.application.configure do
|
|
118
|
+
config.log_level = :debug
|
|
119
|
+
|
|
120
|
+
# Suppress ActionCable broadcast logs
|
|
121
|
+
config.action_cable.logger = Logger.new(STDOUT)
|
|
122
|
+
config.action_cable.logger.level = Logger::WARN
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
### 5. Configure Sidekiq
|
|
129
|
+
|
|
130
|
+
Add to `config/initializers/sidekiq.rb`:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
Sidekiq.configure_server do |config|
|
|
134
|
+
config.redis = { url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
Sidekiq.configure_client do |config|
|
|
138
|
+
config.redis = { url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } }
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## ๐ Usage
|
|
145
|
+
|
|
146
|
+
To see the full power of Rails Trace Viewer:
|
|
147
|
+
|
|
148
|
+
### 1. Start the Web Server
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
rails s
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 2. Start Sidekiq (required for job tracing)
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
bundle exec sidekiq
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### 3. Open the Viewer
|
|
161
|
+
|
|
162
|
+
Visit:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
http://localhost:3000/rails_trace_viewer
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 4. Trigger a Trace
|
|
169
|
+
|
|
170
|
+
Perform any action in your app (load a page, submit a form, etc.).
|
|
171
|
+
|
|
172
|
+
If the action enqueues a Sidekiq job, wait for the worker to pick it up.
|
|
173
|
+
You'll see the trace tree expand in real-time.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## ๐ How to Read the Trace
|
|
178
|
+
|
|
179
|
+
The viewer uses specific colors to represent different parts of the call stack:
|
|
180
|
+
|
|
181
|
+
- ๐ฆ **Request** โ Incoming HTTP request
|
|
182
|
+
- ๐ฆ **Controller** โ Controller action
|
|
183
|
+
- โฌ **Method** โ Ruby method call (models, services, helpers)
|
|
184
|
+
- ๐จ **SQL** โ Database query
|
|
185
|
+
- ๐ฉ **View** โ Rails View or Partial rendering
|
|
186
|
+
- ๐ช **Job Enqueue** โ When a background job is scheduled
|
|
187
|
+
- ๐ช **Job Perform** โ When Sidekiq executes the job
|
|
188
|
+
|
|
189
|
+
๐ก **Tip:** Click any node to open the details panel showing:
|
|
190
|
+
- File path
|
|
191
|
+
- Line number
|
|
192
|
+
- Method arguments
|
|
193
|
+
- SQL binds
|
|
194
|
+
- And more
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## ๐ ๏ธ Troubleshooting
|
|
199
|
+
|
|
200
|
+
### **"I see the Enqueue node, but the trace stops there."**
|
|
201
|
+
- Ensure **Sidekiq is running**.
|
|
202
|
+
- Ensure `config/cable.yml` uses **Redis**, not the async adapter.
|
|
203
|
+
|
|
204
|
+
### **"I see duplicate nodes."**
|
|
205
|
+
- Restart the Rails server.
|
|
206
|
+
This can happen if reloader attaches subscribers twice.
|
|
207
|
+
|
|
208
|
+
### **"The graph feels jittery."**
|
|
209
|
+
- Normal during heavy trace activity.
|
|
210
|
+
- The UI buffers updates every **100ms** to improve smoothness.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## ๐ค Contributing
|
|
215
|
+
|
|
216
|
+
Bug reports and pull requests are welcome at:
|
|
217
|
+
|
|
218
|
+
https://github.com/Aditya-JOSH/rails_trace_viewer
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## ๐ License
|
|
223
|
+
|
|
224
|
+
This gem is available as open source under the terms of the **MIT License**.
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Rails Trace Viewer</title>
|
|
5
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
6
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
7
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/@rails/actioncable@7.1.3/app/assets/javascripts/actioncable.js"></script>
|
|
9
|
+
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
|
11
|
+
|
|
12
|
+
<style>
|
|
13
|
+
body { font-family: 'Inter', sans-serif; background: #f8fafc; overflow: hidden; }
|
|
14
|
+
|
|
15
|
+
/* Graph Container - Infinite Canvas Style */
|
|
16
|
+
.trace-container {
|
|
17
|
+
height: calc(100vh - 64px);
|
|
18
|
+
width: 100%;
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
cursor: grab;
|
|
21
|
+
background: #f8fafc;
|
|
22
|
+
}
|
|
23
|
+
.trace-container:active { cursor: grabbing; }
|
|
24
|
+
|
|
25
|
+
/* Nodes */
|
|
26
|
+
.node rect { rx: 6; ry: 6; stroke-width: 1px; transition: all 0.2s; filter: drop-shadow(0 2px 4px rgb(0 0 0 / 0.05)); }
|
|
27
|
+
.node:hover rect { stroke-width: 2px; stroke: #6366f1; cursor: pointer; transform: translateY(-1px); }
|
|
28
|
+
.node text { font-family: 'Inter', sans-serif; pointer-events: none; }
|
|
29
|
+
.node .label-title { font-weight: 600; font-size: 12px; }
|
|
30
|
+
.node .label-type { font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.6; }
|
|
31
|
+
|
|
32
|
+
.link { fill: none; stroke: #cbd5e1; stroke-width: 1.5px; marker-end: url(#arrow); transition: stroke 0.2s; }
|
|
33
|
+
|
|
34
|
+
/* Node Colors */
|
|
35
|
+
.node-type-request rect, .node-type-controller_action rect { fill: #eff6ff; stroke: #bfdbfe; }
|
|
36
|
+
.node-type-request text { fill: #1e40af; }
|
|
37
|
+
|
|
38
|
+
.node-type-method rect { fill: #ffffff; stroke: #cbd5e1; }
|
|
39
|
+
.node-type-method text { fill: #334155; }
|
|
40
|
+
|
|
41
|
+
.node-type-sql rect { fill: #fffbeb; stroke: #fde68a; }
|
|
42
|
+
.node-type-sql text { fill: #92400e; }
|
|
43
|
+
|
|
44
|
+
.node-type-job_enqueue rect { fill: #faf5ff; stroke: #d8b4fe; }
|
|
45
|
+
.node-type-job_enqueue text { fill: #6b21a8; }
|
|
46
|
+
|
|
47
|
+
.node-type-job_perform rect { fill: #fdf2f8; stroke: #f9a8d4; }
|
|
48
|
+
.node-type-job_perform text { fill: #9d174d; }
|
|
49
|
+
|
|
50
|
+
.node-type-view rect { fill: #f0fdf4; stroke: #86efac; }
|
|
51
|
+
.node-type-view text { fill: #166534; }
|
|
52
|
+
|
|
53
|
+
.node-type-route rect { fill: #ecfeff; stroke: #a5f3fc; }
|
|
54
|
+
.node-type-route text { fill: #0e7490; }
|
|
55
|
+
|
|
56
|
+
/* Drawer */
|
|
57
|
+
.drawer {
|
|
58
|
+
position: fixed; top: 64px; right: -500px; width: 450px; height: calc(100vh - 64px);
|
|
59
|
+
background: white; border-left: 1px solid #e2e8f0; box-shadow: -4px 0 25px rgba(0,0,0,0.05);
|
|
60
|
+
transition: right 0.3s cubic-bezier(0.16, 1, 0.3, 1); z-index: 50; display: flex; flex-direction: column;
|
|
61
|
+
}
|
|
62
|
+
.drawer.open { right: 0; }
|
|
63
|
+
|
|
64
|
+
.code-block {
|
|
65
|
+
background: #f8fafc; padding: 10px; border-radius: 6px; font-family: 'JetBrains Mono', monospace;
|
|
66
|
+
font-size: 11px; color: #334155; overflow-x: auto; white-space: pre-wrap; border: 1px solid #e2e8f0; margin-top: 4px;
|
|
67
|
+
}
|
|
68
|
+
.kv-row { margin-bottom: 12px; }
|
|
69
|
+
.kv-label { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; margin-bottom: 2px; }
|
|
70
|
+
.kv-value { font-size: 13px; color: #1e293b; word-break: break-all; line-height: 1.4; }
|
|
71
|
+
|
|
72
|
+
/* Help/Learn Styles */
|
|
73
|
+
.help-section { margin-bottom: 24px; }
|
|
74
|
+
.help-title { font-weight: 600; color: #1e293b; display: flex; items-center; gap: 8px; margin-bottom: 8px; }
|
|
75
|
+
.help-text { font-size: 13px; color: #475569; line-height: 1.5; }
|
|
76
|
+
.help-list { list-style: disc; padding-left: 20px; margin-top: 4px; }
|
|
77
|
+
.help-list li { margin-bottom: 4px; }
|
|
78
|
+
.kbd { background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: 4px; padding: 2px 4px; font-family: 'JetBrains Mono', monospace; font-size: 11px; }
|
|
79
|
+
</style>
|
|
80
|
+
</head>
|
|
81
|
+
|
|
82
|
+
<body>
|
|
83
|
+
|
|
84
|
+
<div class="h-16 bg-white border-b border-slate-200 flex justify-between items-center px-6 shadow-sm z-50 relative">
|
|
85
|
+
<div class="flex items-center gap-3">
|
|
86
|
+
<div class="bg-indigo-600 text-white p-1.5 rounded-lg">
|
|
87
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
|
|
88
|
+
</div>
|
|
89
|
+
<h1 class="text-lg font-bold text-slate-800">Rails Trace Viewer</h1>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="flex gap-3">
|
|
92
|
+
<button id="center-btn" class="px-3 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-300 rounded-md hover:bg-slate-50 flex items-center gap-2 transition-all active:scale-95">
|
|
93
|
+
<span>๐ฏ</span> Latest Trace
|
|
94
|
+
</button>
|
|
95
|
+
<button id="clear-btn" class="px-3 py-2 text-sm font-medium text-red-600 bg-white border border-slate-300 rounded-md hover:bg-red-50 flex items-center gap-2 transition-all active:scale-95">
|
|
96
|
+
<span>๐</span> Clear
|
|
97
|
+
</button>
|
|
98
|
+
<button id="learn-btn" class="px-3 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 flex items-center gap-2 transition-all active:scale-95">
|
|
99
|
+
<span>๐</span> Learn
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div class="flex h-full">
|
|
105
|
+
<div id="trace-visualization" class="trace-container"></div>
|
|
106
|
+
<div id="detail-drawer" class="drawer">
|
|
107
|
+
<div class="p-5 border-b border-slate-100 flex justify-between items-center bg-slate-50">
|
|
108
|
+
<h2 class="font-bold text-slate-800 text-lg" id="drawer-title">Details</h2>
|
|
109
|
+
<button id="close-drawer" class="text-slate-400 hover:text-slate-700">โ</button>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="p-6 overflow-y-auto flex-1" id="drawer-content"></div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<script>
|
|
116
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
117
|
+
const container = document.getElementById("trace-visualization");
|
|
118
|
+
const drawer = document.getElementById("detail-drawer");
|
|
119
|
+
let graphs = {};
|
|
120
|
+
let nodeBuffer = {};
|
|
121
|
+
let verticalOffset = 60;
|
|
122
|
+
const DAG_SPACING = 200;
|
|
123
|
+
|
|
124
|
+
// Node Dimensions
|
|
125
|
+
const NODE_WIDTH = 250;
|
|
126
|
+
const NODE_HEIGHT = 46;
|
|
127
|
+
|
|
128
|
+
const svg = d3.select("#trace-visualization").append("svg").attr("width", "100%").attr("height", "100%");
|
|
129
|
+
const g = svg.append("g");
|
|
130
|
+
|
|
131
|
+
svg.append("defs").append("marker")
|
|
132
|
+
.attr("id", "arrow").attr("viewBox", "0 -5 10 10").attr("refX", 9).attr("refY", 0)
|
|
133
|
+
.attr("markerWidth", 5).attr("markerHeight", 5).attr("orient", "auto")
|
|
134
|
+
.append("path").attr("d", "M0,-5L10,0L0,5").attr("fill", "#94a3b8");
|
|
135
|
+
|
|
136
|
+
const zoom = d3.zoom().scaleExtent([0.05, 3]).on("zoom", (e) => g.attr("transform", e.transform));
|
|
137
|
+
svg.call(zoom);
|
|
138
|
+
|
|
139
|
+
// --- ActionCable Management ---
|
|
140
|
+
let cable = null;
|
|
141
|
+
let subscription = null;
|
|
142
|
+
|
|
143
|
+
function setupSubscription() {
|
|
144
|
+
if (cable) cable.disconnect();
|
|
145
|
+
cable = ActionCable.createConsumer();
|
|
146
|
+
|
|
147
|
+
subscription = cable.subscriptions.create({ channel: "RailsTraceViewer::TraceChannel" }, {
|
|
148
|
+
connected() { console.log("โ
TraceViewer: Connected"); },
|
|
149
|
+
disconnected() { console.log("โ TraceViewer: Disconnected"); },
|
|
150
|
+
received(data) {
|
|
151
|
+
if (!data || !data.node) return;
|
|
152
|
+
if ((data.node.name || "").includes("RailsTraceViewer")) return;
|
|
153
|
+
addNodeToGraph(data.trace_id, data.node);
|
|
154
|
+
|
|
155
|
+
if (this.redrawTimer) clearTimeout(this.redrawTimer);
|
|
156
|
+
this.redrawTimer = setTimeout(redrawAllGraphs, 100);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
setupSubscription();
|
|
162
|
+
|
|
163
|
+
// --- Buffer Logic ---
|
|
164
|
+
setInterval(() => {
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
let changed = false;
|
|
167
|
+
Object.keys(nodeBuffer).forEach(pid => {
|
|
168
|
+
const list = nodeBuffer[pid];
|
|
169
|
+
const kept = [];
|
|
170
|
+
list.forEach(item => {
|
|
171
|
+
if (now - item.receivedAt > 3000) {
|
|
172
|
+
addNodeToGraph(item.traceId, { ...item.node, parent_id: null });
|
|
173
|
+
changed = true;
|
|
174
|
+
} else kept.push(item);
|
|
175
|
+
});
|
|
176
|
+
if (kept.length === 0) delete nodeBuffer[pid];
|
|
177
|
+
else nodeBuffer[pid] = kept;
|
|
178
|
+
});
|
|
179
|
+
if (changed) redrawAllGraphs();
|
|
180
|
+
}, 1000);
|
|
181
|
+
|
|
182
|
+
function addNodeToGraph(trace_id, node) {
|
|
183
|
+
if (!graphs[trace_id]) graphs[trace_id] = { nodes: {}, edges: [], height: 0 };
|
|
184
|
+
const G = graphs[trace_id];
|
|
185
|
+
if (G.nodes[node.id]) return;
|
|
186
|
+
|
|
187
|
+
if (node.parent_id && !G.nodes[node.parent_id]) {
|
|
188
|
+
nodeBuffer[node.parent_id] ||= [];
|
|
189
|
+
nodeBuffer[node.parent_id].push({ node, traceId: trace_id, receivedAt: Date.now() });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
G.nodes[node.id] = { ...node, label: node.name };
|
|
194
|
+
if (node.parent_id && !G.edges.some(e => e.source === node.parent_id && e.target === node.id)) {
|
|
195
|
+
G.edges.push({ source: node.parent_id, target: node.id });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (nodeBuffer[node.id]) {
|
|
199
|
+
const children = nodeBuffer[node.id];
|
|
200
|
+
delete nodeBuffer[node.id];
|
|
201
|
+
children.forEach(c => addNodeToGraph(trace_id, c.node));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Rendering ---
|
|
206
|
+
function redrawAllGraphs() {
|
|
207
|
+
g.selectAll("*").remove();
|
|
208
|
+
verticalOffset = 60;
|
|
209
|
+
|
|
210
|
+
Object.keys(graphs).forEach(tid => {
|
|
211
|
+
renderSingleDAG(graphs[tid], verticalOffset);
|
|
212
|
+
verticalOffset += graphs[tid].height + DAG_SPACING;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function renderSingleDAG(G, yOffset) {
|
|
217
|
+
const dag = new dagre.graphlib.Graph();
|
|
218
|
+
dag.setGraph({ rankdir: "LR", nodesep: 25, ranksep: 60 });
|
|
219
|
+
dag.setDefaultEdgeLabel(() => ({}));
|
|
220
|
+
|
|
221
|
+
Object.values(G.nodes).forEach(n => {
|
|
222
|
+
let lbl = n.name || "Unknown";
|
|
223
|
+
if (lbl.length > 38) lbl = lbl.substring(0, 35) + "...";
|
|
224
|
+
dag.setNode(n.id, { label: lbl, width: NODE_WIDTH, height: NODE_HEIGHT, type: n.type, fullData: n });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
G.edges.forEach(e => dag.setEdge(e.source, e.target));
|
|
228
|
+
dagre.layout(dag);
|
|
229
|
+
|
|
230
|
+
if (dag.graph().height) G.height = dag.graph().height;
|
|
231
|
+
|
|
232
|
+
let minX = Infinity, maxX = -Infinity;
|
|
233
|
+
let minY = Infinity, maxY = -Infinity;
|
|
234
|
+
|
|
235
|
+
Object.values(G.nodes).forEach(n => {
|
|
236
|
+
const nodeInfo = dag.node(n.id);
|
|
237
|
+
const nx1 = nodeInfo.x - (nodeInfo.width / 2);
|
|
238
|
+
const nx2 = nodeInfo.x + (nodeInfo.width / 2);
|
|
239
|
+
const ny1 = nodeInfo.y - (nodeInfo.height / 2);
|
|
240
|
+
const ny2 = nodeInfo.y + (nodeInfo.height / 2);
|
|
241
|
+
|
|
242
|
+
if (nx1 < minX) minX = nx1;
|
|
243
|
+
if (nx2 > maxX) maxX = nx2;
|
|
244
|
+
if (ny1 < minY) minY = ny1;
|
|
245
|
+
if (ny2 > maxY) maxY = ny2;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (minX === Infinity) { minX = 0; maxX = 0; minY = 0; maxY = 0; }
|
|
249
|
+
|
|
250
|
+
G.bounds = {
|
|
251
|
+
minX: minX,
|
|
252
|
+
maxX: maxX,
|
|
253
|
+
minY: minY + yOffset,
|
|
254
|
+
maxY: maxY + yOffset,
|
|
255
|
+
width: maxX - minX,
|
|
256
|
+
height: maxY - minY,
|
|
257
|
+
centerX: minX + (maxX - minX) / 2,
|
|
258
|
+
centerY: (minY + yOffset) + (maxY - minY) / 2
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
g.selectAll(`.link-${G.id}`).data(dag.edges()).enter().append("path")
|
|
262
|
+
.attr("class", "link").attr("d", e => {
|
|
263
|
+
const pts = dag.edge(e).points;
|
|
264
|
+
return d3.line().x(d => d.x).y(d => d.y + yOffset).curve(d3.curveBasis)(pts);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const nodeGroup = g.selectAll(`.node-${G.id}`).data(dag.nodes()).enter().append("g")
|
|
268
|
+
.attr("class", d => `node node-type-${dag.node(d).type}`)
|
|
269
|
+
.attr("transform", d => `translate(${dag.node(d).x}, ${dag.node(d).y + yOffset})`)
|
|
270
|
+
.on("click", (e, d) => { e.stopPropagation(); showDrawer(dag.node(d).fullData); });
|
|
271
|
+
|
|
272
|
+
nodeGroup.append("rect")
|
|
273
|
+
.attr("width", NODE_WIDTH).attr("height", NODE_HEIGHT).attr("x", -NODE_WIDTH / 2).attr("y", -NODE_HEIGHT / 2);
|
|
274
|
+
|
|
275
|
+
nodeGroup.append("text")
|
|
276
|
+
.attr("x", 0).attr("y", 0).attr("dy", "0.35em")
|
|
277
|
+
.text(d => dag.node(d).label).classed("label-title", true).style("text-anchor", "middle");
|
|
278
|
+
|
|
279
|
+
nodeGroup.append("text")
|
|
280
|
+
.attr("x", (NODE_WIDTH / 2) - 10).attr("y", (NODE_HEIGHT / 2) - 6)
|
|
281
|
+
.text(d => dag.node(d).type.replace(/_/g, " ")).classed("label-type", true).style("text-anchor", "end");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- Drawer & Learn Logic ---
|
|
285
|
+
|
|
286
|
+
function openDrawerWithContent(titleText, htmlContent) {
|
|
287
|
+
const title = document.getElementById("drawer-title");
|
|
288
|
+
const content = document.getElementById("drawer-content");
|
|
289
|
+
|
|
290
|
+
title.innerText = titleText;
|
|
291
|
+
content.innerHTML = htmlContent;
|
|
292
|
+
drawer.classList.add("open");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function showDrawer(node) {
|
|
296
|
+
const ignore = ["id", "parent_id", "children", "type", "name", "label", "width", "height", "x", "y"];
|
|
297
|
+
let html = `<div class="kv-row"><div class="kv-label">Name</div><div class="kv-value font-medium">${node.name}</div></div>`;
|
|
298
|
+
|
|
299
|
+
Object.entries(node).forEach(([key, val]) => {
|
|
300
|
+
if (ignore.includes(key) || val === null || val === "") return;
|
|
301
|
+
const isObj = typeof val === 'object';
|
|
302
|
+
const display = isObj ? JSON.stringify(val, null, 2) : val;
|
|
303
|
+
html += `<div class="kv-row"><div class="kv-label">${key.replace(/_/g, " ")}</div>${isObj ? `<div class="code-block">${display}</div>` : `<div class="kv-value">${display}</div>`}</div>`;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
openDrawerWithContent((node.type || "Node").toUpperCase().replace(/_/g, " "), html);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function showLearn() {
|
|
310
|
+
const content = `
|
|
311
|
+
<div class="help-section">
|
|
312
|
+
<div class="help-title">
|
|
313
|
+
<span>๐น๏ธ</span> Navigation
|
|
314
|
+
</div>
|
|
315
|
+
<div class="help-text">
|
|
316
|
+
<ul class="help-list">
|
|
317
|
+
<li><strong>Pan:</strong> Click and drag anywhere on the background.</li>
|
|
318
|
+
<li><strong>Zoom:</strong> Use your mouse wheel or trackpad pinch.</li>
|
|
319
|
+
<li><strong>Details:</strong> Click any node to see arguments, SQL binds, and file paths.</li>
|
|
320
|
+
<li><strong>Center:</strong> Click <span class="kbd">Latest Trace</span> to snap the camera to the newest activity.</li>
|
|
321
|
+
</ul>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<div class="help-section">
|
|
326
|
+
<div class="help-title">
|
|
327
|
+
<span>โ๏ธ</span> Development Workflow
|
|
328
|
+
</div>
|
|
329
|
+
<div class="help-text">
|
|
330
|
+
<p class="mb-2">To see full traces including Background Jobs, ensure you are running:</p>
|
|
331
|
+
<div class="code-block">bundle exec sidekiq</div>
|
|
332
|
+
<p class="mt-2">Sidekiq runs in a separate process. If you change code in your app, <strong>Sidekiq does NOT reload automatically</strong>.</p>
|
|
333
|
+
<p class="mt-2 font-semibold text-indigo-700">๐ก If you change code, restart the Sidekiq process and restart rails server using </p>
|
|
334
|
+
<div class="code-block">bundle exec sidekiq
|
|
335
|
+
rails s</div>
|
|
336
|
+
<p class="mt-2 font-semibold text-indigo-700"> Please refresh this viewer page after making changes to ensure the latest code is reflected.</p>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<div class="help-section">
|
|
341
|
+
<div class="help-title">
|
|
342
|
+
<span>๐จ</span> Legend
|
|
343
|
+
</div>
|
|
344
|
+
<div class="help-text grid grid-cols-2 gap-2 mt-2">
|
|
345
|
+
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-cyan-100 border border-cyan-300 rounded"></span> Route</div>
|
|
346
|
+
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-blue-100 border border-blue-300 rounded"></span> Request</div>
|
|
347
|
+
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-purple-100 border border-purple-300 rounded"></span> Job Enqueue</div>
|
|
348
|
+
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-pink-100 border border-pink-300 rounded"></span> Job Perform</div>
|
|
349
|
+
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-amber-100 border border-amber-300 rounded"></span> SQL Query</div>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<div class="mt-12 pt-6 border-t border-slate-200">
|
|
354
|
+
<div class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3">Created By</div>
|
|
355
|
+
<a href="https://www.linkedin.com/in/aditya-kolekar-5a54611b5/" target="_blank" class="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl shadow-sm hover:shadow-md hover:border-blue-200 transition-all group text-decoration-none">
|
|
356
|
+
<div class="bg-[#0077b5] text-white p-2.5 rounded-full shrink-0">
|
|
357
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>
|
|
358
|
+
</div>
|
|
359
|
+
<div>
|
|
360
|
+
<div class="font-bold text-slate-800 text-sm group-hover:text-[#0077b5] transition-colors">Aditya Kolekar</div>
|
|
361
|
+
<div class="text-xs text-slate-500 mt-0.5">Connect on LinkedIn</div>
|
|
362
|
+
</div>
|
|
363
|
+
</a>
|
|
364
|
+
</div>
|
|
365
|
+
`;
|
|
366
|
+
openDrawerWithContent("How to Use", content);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// --- UI Controls ---
|
|
370
|
+
|
|
371
|
+
document.getElementById("clear-btn").onclick = () => {
|
|
372
|
+
graphs = {};
|
|
373
|
+
redrawAllGraphs();
|
|
374
|
+
drawer.classList.remove("open");
|
|
375
|
+
console.log("๐งน Clearing traces & reconnecting...");
|
|
376
|
+
setupSubscription();
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
document.getElementById("center-btn").onclick = () => {
|
|
380
|
+
const ids = Object.keys(graphs);
|
|
381
|
+
if (ids.length === 0) return;
|
|
382
|
+
const G = graphs[ids[ids.length - 1]];
|
|
383
|
+
if (!G || !G.bounds || G.bounds.width === 0) return;
|
|
384
|
+
|
|
385
|
+
const padding = 80;
|
|
386
|
+
const viewW = container.clientWidth;
|
|
387
|
+
const viewH = container.clientHeight;
|
|
388
|
+
const scaleX = (viewW - padding) / G.bounds.width;
|
|
389
|
+
const scaleY = (viewH - padding) / G.bounds.height;
|
|
390
|
+
let scale = Math.min(scaleX, scaleY);
|
|
391
|
+
scale = Math.min(scale, 1);
|
|
392
|
+
|
|
393
|
+
const tX = (viewW / 2) - (G.bounds.centerX * scale);
|
|
394
|
+
const tY = (viewH / 2) - (G.bounds.centerY * scale);
|
|
395
|
+
|
|
396
|
+
svg.transition().duration(800).call(zoom.transform, d3.zoomIdentity.translate(tX, tY).scale(scale));
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
document.getElementById("learn-btn").onclick = showLearn;
|
|
400
|
+
document.getElementById("close-drawer").onclick = () => drawer.classList.remove("open");
|
|
401
|
+
document.querySelector("svg").onclick = () => drawer.classList.remove("open");
|
|
402
|
+
|
|
403
|
+
window.addEventListener("resize", () => {
|
|
404
|
+
svg.attr("width", container.clientWidth).attr("height", container.clientHeight);
|
|
405
|
+
redrawAllGraphs();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
</script>
|
|
409
|
+
</body>
|
|
410
|
+
</html>
|
data/config/routes.rb
ADDED