rails_trace_viewer 0.1.1 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d0b03c526bd92657fe33c1eea68ad75bfdc73f4affd75305aa5001bd838f9487
4
- data.tar.gz: 722a25ff761e45a3d9dd461eb826af36b30ed691339170b5536f447f3aff2bec
3
+ metadata.gz: 5ff338c196864dc973331301d6fbdbbc50de7ab3940ae012458f73bdfe5b0183
4
+ data.tar.gz: b9b535f0575a9972380c9fca35faa82305298b23c64b524182c2ed01df92bc51
5
5
  SHA512:
6
- metadata.gz: 4d079e490077605afc632d410ba64fb1d6b1e02cec74c4b5018b11b0b01113aa5a0fb11340295ccca757639d61519f7bc09b58409089e2663566c36ee6967a1d
7
- data.tar.gz: 6617bd28c99294557feaa028b735e9dbdc7fc75ec0cd36300ef1c52cbf4a3eb8194eab9ad4f641153fb071d7b4fd7aac309a71b9458f23906c8f4164c5b6b45c
6
+ metadata.gz: 824699687247af12dc1abf2fd5be2c5bc45e12896606042c531fec5a6d6918a9b4ae06b7c8990b32ce3cbdb1cf77d5e7a6769e34261c633eef3eec8fb413ce22
7
+ data.tar.gz: b140ba6bc597e257e286ed3c1d092660041ca5003b5b278dee51d962436ab54a566e01b4d5d331c4c7a5d444024e66691833800b3589a0d6e861297fcdabc4c6
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  An educational and debugging tool for Ruby on Rails to visualize the request lifecycle in real-time.
4
4
 
5
+ [![Watch the Demo](https://img.youtube.com/vi/Zwg3rROyaH0/maxresdefault.jpg)](https://youtu.be/Zwg3rROyaH0)
6
+
5
7
  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
8
 
7
9
  ---
@@ -95,6 +97,7 @@ Rails.application.routes.draw do
95
97
  end
96
98
  ```
97
99
 
100
+ **🛑 Need to disable the gem?** Simply comment out the `mount RailsTraceViewer::Engine => '/rails_trace_viewer'` line above. The gem detects this and **shuts down completely** (Zero Overhead).
98
101
  ---
99
102
 
100
103
  ### 3. Configure Action Cable (Redis)
@@ -19,9 +19,27 @@
19
19
  overflow: hidden;
20
20
  cursor: grab;
21
21
  background: #f8fafc;
22
+ position: relative;
22
23
  }
23
24
  .trace-container:active { cursor: grabbing; }
24
25
 
26
+ /* Empty State Styling */
27
+ .empty-state {
28
+ position: absolute;
29
+ top: 50%;
30
+ left: 50%;
31
+ transform: translate(-50%, -50%);
32
+ text-align: center;
33
+ transition: opacity 0.5s ease-out, visibility 0.5s;
34
+ opacity: 1;
35
+ visibility: visible;
36
+ pointer-events: none;
37
+ }
38
+ .empty-state.hidden {
39
+ opacity: 0;
40
+ visibility: hidden;
41
+ }
42
+
25
43
  /* Nodes */
26
44
  .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
45
  .node:hover rect { stroke-width: 2px; stroke: #6366f1; cursor: pointer; transform: translateY(-1px); }
@@ -95,7 +113,7 @@
95
113
  <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
114
  <span>🗑</span> Clear
97
115
  </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">
116
+ <button id="learn-btn" class="px-3 py-2 text-sm font-medium text-indigo-600 bg-white border border-indigo-200 rounded-md hover:bg-indigo-100 flex items-center gap-2 transition-all active:scale-95">
99
117
  <span>📖</span> Learn
100
118
  </button>
101
119
  </div>
@@ -103,6 +121,13 @@
103
121
 
104
122
  <div class="flex h-full">
105
123
  <div id="trace-visualization" class="trace-container"></div>
124
+ <div id="empty-state" class="empty-state">
125
+ <div class="mb-4 inline-block p-4 bg-white rounded-full shadow-sm">
126
+ <svg class="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
127
+ </div>
128
+ <h3 class="text-lg font-medium text-slate-700">Waiting for Activity...</h3>
129
+ <p class="text-sm text-slate-400 mt-2">Trigger a web request, a job, a background worker, or a method to see the trace.</p>
130
+ </div>
106
131
  <div id="detail-drawer" class="drawer">
107
132
  <div class="p-5 border-b border-slate-100 flex justify-between items-center bg-slate-50">
108
133
  <h2 class="font-bold text-slate-800 text-lg" id="drawer-title">Details</h2>
@@ -116,6 +141,7 @@
116
141
  document.addEventListener("DOMContentLoaded", () => {
117
142
  const container = document.getElementById("trace-visualization");
118
143
  const drawer = document.getElementById("detail-drawer");
144
+ const emptyState = document.getElementById("empty-state");
119
145
  let graphs = {};
120
146
  let nodeBuffer = {};
121
147
  let verticalOffset = 60;
@@ -150,6 +176,9 @@ document.addEventListener("DOMContentLoaded", () => {
150
176
  received(data) {
151
177
  if (!data || !data.node) return;
152
178
  if ((data.node.name || "").includes("RailsTraceViewer")) return;
179
+
180
+ emptyState.classList.add("hidden");
181
+
153
182
  addNodeToGraph(data.trace_id, data.node);
154
183
 
155
184
  if (this.redrawTimer) clearTimeout(this.redrawTimer);
@@ -215,7 +244,17 @@ document.addEventListener("DOMContentLoaded", () => {
215
244
 
216
245
  function renderSingleDAG(G, yOffset) {
217
246
  const dag = new dagre.graphlib.Graph();
218
- dag.setGraph({ rankdir: "LR", nodesep: 25, ranksep: 60 });
247
+
248
+ const parentCounts = {};
249
+ G.edges.forEach(e => {
250
+ parentCounts[e.source] = (parentCounts[e.source] || 0) + 1;
251
+ });
252
+
253
+ const maxChildren = Math.max(...Object.values(parentCounts), 0);
254
+
255
+ const dynamicRankSep = Math.min(800, Math.max(60, 40 + (maxChildren * 18)));
256
+
257
+ dag.setGraph({ rankdir: "LR", nodesep: 25, ranksep: dynamicRankSep });
219
258
  dag.setDefaultEdgeLabel(() => ({}));
220
259
 
221
260
  Object.values(G.nodes).forEach(n => {
@@ -229,16 +268,13 @@ document.addEventListener("DOMContentLoaded", () => {
229
268
 
230
269
  if (dag.graph().height) G.height = dag.graph().height;
231
270
 
232
- let minX = Infinity, maxX = -Infinity;
233
- let minY = Infinity, maxY = -Infinity;
234
-
271
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
235
272
  Object.values(G.nodes).forEach(n => {
236
273
  const nodeInfo = dag.node(n.id);
237
274
  const nx1 = nodeInfo.x - (nodeInfo.width / 2);
238
275
  const nx2 = nodeInfo.x + (nodeInfo.width / 2);
239
276
  const ny1 = nodeInfo.y - (nodeInfo.height / 2);
240
277
  const ny2 = nodeInfo.y + (nodeInfo.height / 2);
241
-
242
278
  if (nx1 < minX) minX = nx1;
243
279
  if (nx2 > maxX) maxX = nx2;
244
280
  if (ny1 < minY) minY = ny1;
@@ -248,36 +284,28 @@ document.addEventListener("DOMContentLoaded", () => {
248
284
  if (minX === Infinity) { minX = 0; maxX = 0; minY = 0; maxY = 0; }
249
285
 
250
286
  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
287
+ minX: minX, maxX: maxX, minY: minY + yOffset, maxY: maxY + yOffset,
288
+ width: maxX - minX, height: maxY - minY,
289
+ centerX: minX + (maxX - minX) / 2, centerY: (minY + yOffset) + (maxY - minY) / 2
259
290
  };
260
291
 
261
292
  g.selectAll(`.link-${G.id}`).data(dag.edges()).enter().append("path")
262
- .attr("class", "link").attr("d", e => {
293
+ .attr("class", "link")
294
+ .attr("d", e => {
263
295
  const pts = dag.edge(e).points;
264
- return d3.line().x(d => d.x).y(d => d.y + yOffset).curve(d3.curveBasis)(pts);
296
+ return d3.line().x(d => d.x).y(d => d.y + yOffset).curve(d3.curveMonotoneX)(pts);
265
297
  });
266
298
 
267
299
  const nodeGroup = g.selectAll(`.node-${G.id}`).data(dag.nodes()).enter().append("g")
268
300
  .attr("class", d => `node node-type-${dag.node(d).type}`)
301
+ .attr("id", d => `node-${d}`)
269
302
  .attr("transform", d => `translate(${dag.node(d).x}, ${dag.node(d).y + yOffset})`)
270
303
  .on("click", (e, d) => { e.stopPropagation(); showDrawer(dag.node(d).fullData); });
271
304
 
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")
305
+ nodeGroup.append("rect").attr("width", NODE_WIDTH).attr("height", NODE_HEIGHT).attr("x", -NODE_WIDTH / 2).attr("y", -NODE_HEIGHT / 2);
306
+ nodeGroup.append("text").attr("x", 0).attr("y", 0).attr("dy", "0.35em")
277
307
  .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)
308
+ nodeGroup.append("text").attr("x", (NODE_WIDTH / 2) - 10).attr("y", (NODE_HEIGHT / 2) - 6)
281
309
  .text(d => dag.node(d).type.replace(/_/g, " ")).classed("label-type", true).style("text-anchor", "end");
282
310
  }
283
311
 
@@ -347,9 +375,12 @@ rails s</div>
347
375
  <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
376
  <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
377
  <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>
378
+ <div class="flex items-center gap-2"><span class="w-3 h-3 bg-emerald-100 border border-emerald-300 rounded"></span> View Render</div>
350
379
  </div>
351
380
  </div>
352
381
 
382
+ <div class="help-section"><div class="help-title"><span>📚</span> Resources</div><div class="help-text"><p class="mb-2">To learn more, read the <a href="https://github.com/Aditya-JOSH/rails_trace_viewer/blob/main/README.md" target="_blank" class="text-indigo-600 hover:underline font-medium">Project Documentation</a>.</p><p>Found an issue? Report it on <a href="https://github.com/Aditya-JOSH/rails_trace_viewer/issues" target="_blank" class="text-indigo-600 hover:underline font-medium">GitHub Issues</a>.</p></div></div>
383
+
353
384
  <div class="mt-12 pt-6 border-t border-slate-200">
354
385
  <div class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3">Created By</div>
355
386
  <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">
@@ -373,6 +404,7 @@ rails s</div>
373
404
  redrawAllGraphs();
374
405
  drawer.classList.remove("open");
375
406
  console.log("🧹 Clearing traces & reconnecting...");
407
+ emptyState.classList.remove("hidden");
376
408
  setupSubscription();
377
409
  };
378
410
 
@@ -393,7 +425,8 @@ rails s</div>
393
425
  const tX = (viewW / 2) - (G.bounds.centerX * scale);
394
426
  const tY = (viewH / 2) - (G.bounds.centerY * scale);
395
427
 
396
- svg.transition().duration(800).call(zoom.transform, d3.zoomIdentity.translate(tX, tY).scale(scale));
428
+ svg.transition().duration(2500).ease(d3.easeCubicOut)
429
+ .call(zoom.transform, d3.zoomIdentity.translate(tX, tY).scale(scale));
397
430
  };
398
431
 
399
432
  document.getElementById("learn-btn").onclick = showLearn;
@@ -407,4 +440,4 @@ rails s</div>
407
440
  });
408
441
  </script>
409
442
  </body>
410
- </html>
443
+ </html>
@@ -2,31 +2,48 @@ module RailsTraceViewer
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace RailsTraceViewer
4
4
 
5
- initializer "rails_trace_viewer.subscribe", after: :load_config_initializers do
6
- ActiveSupport.on_load(:action_controller) do
7
- RailsTraceViewer::Subscribers::ControllerSubscriber.attach
5
+ config.to_prepare do
6
+ if Rails.application.routes.routes.empty?
7
+ Rails.application.reload_routes!
8
8
  end
9
9
 
10
- ActiveSupport.on_load(:active_record) do
11
- RailsTraceViewer::Subscribers::SqlSubscriber.attach
12
- end
10
+ is_mounted = Rails.application.routes.routes.any? do |route|
11
+ app = route.app
12
+ while app.respond_to?(:app) && app != app.app
13
+ app = app.app
14
+ end
15
+ match_class = (app == RailsTraceViewer::Engine) || (app.to_s.include?("RailsTraceViewer::Engine"))
13
16
 
14
- ActiveSupport.on_load(:action_view) do
15
- RailsTraceViewer::Subscribers::ViewSubscriber.attach
16
- end
17
+ path_match = (route.path.spec.to_s rescue "").include?("/rails_trace_viewer")
17
18
 
18
- ActiveSupport.on_load(:active_job) do
19
- RailsTraceViewer::Subscribers::ActiveJobSubscriber.attach
19
+ match_class || path_match
20
20
  end
21
21
 
22
- Rails.application.reloader.to_prepare do
23
- RailsTraceViewer::Subscribers::SidekiqSubscriber.attach
22
+ if is_mounted
23
+ RailsTraceViewer.enabled = true
24
+
25
+ unless @booted_message_shown
26
+ puts "✅ [RailsTraceViewer] Engine mounted. Tracing is ACTIVE."
27
+ @booted_message_shown = true
28
+ end
29
+
30
+ RailsTraceViewer::Subscribers::ControllerSubscriber.attach
31
+ RailsTraceViewer::Subscribers::SqlSubscriber.attach
32
+ RailsTraceViewer::Subscribers::ViewSubscriber.attach
33
+ RailsTraceViewer::Subscribers::ActiveJobSubscriber.attach
34
+ RailsTraceViewer::Subscribers::SidekiqSubscriber.attach if defined?(Sidekiq)
24
35
  RailsTraceViewer::Subscribers::MethodSubscriber.attach
36
+
37
+ RailsTraceViewer::Collector.start_sweeper!
38
+
39
+ else
40
+ RailsTraceViewer.enabled = false
41
+
42
+ unless @booted_message_shown
43
+ puts "🚫 [RailsTraceViewer] Engine route not found. Tracing DISABLED (Zero Overhead)."
44
+ @booted_message_shown = true
45
+ end
25
46
  end
26
47
  end
27
-
28
- initializer "rails_trace_viewer.start_sweeper" do
29
- RailsTraceViewer::Collector.start_sweeper!
30
- end
31
48
  end
32
49
  end
@@ -12,6 +12,12 @@ module RailsTraceViewer
12
12
  is_app_path = path.start_with?(Rails.root.to_s) &&
13
13
  !path.include?("/vendor/") &&
14
14
  !path.include?("rails_trace_viewer")
15
+
16
+ is_view_related = path.include?("/app/views/")
17
+
18
+ # Only trace Controllers, Models, Jobs, Services. Skip Views.
19
+ next unless is_app_path && !is_view_related
20
+
15
21
  is_active_job = (tp.defined_class < ApplicationJob) rescue false
16
22
  is_sidekiq_worker = (tp.defined_class.include?(Sidekiq::Worker)) rescue false
17
23
 
@@ -1,32 +1,59 @@
1
1
  module RailsTraceViewer
2
2
  module Subscribers
3
3
  class ViewSubscriber
4
- EVENTS = ["render_template.action_view", "render_partial.action_view"]
5
-
6
4
  def self.attach
7
5
  return if @attached
8
6
  @attached = true
9
7
 
10
- EVENTS.each do |event|
11
- ActiveSupport::Notifications.subscribe(event) do |*_args, payload|
12
- next unless RailsTraceViewer::TraceContext.active?
13
-
14
- file = payload[:identifier]
15
- next unless file && file.include?(Rails.root.join("app").to_s)
16
-
17
- node = {
18
- id: SecureRandom.uuid,
19
- parent_id: RailsTraceViewer::TraceContext.parent_id,
20
- type: "view",
21
- name: file.split("/app/").last,
22
- source: file.sub(Rails.root.to_s, ''),
23
- layout: payload[:layout],
24
- full_path: file,
25
- children: []
26
- }
27
-
28
- RailsTraceViewer::Collector.add_node(RailsTraceViewer::TraceContext.trace_id, node)
29
- end
8
+ subscriber = new
9
+ ActiveSupport::Notifications.subscribe("render_template.action_view", subscriber)
10
+ ActiveSupport::Notifications.subscribe("render_partial.action_view", subscriber)
11
+ end
12
+
13
+ def start(name, id, payload)
14
+ file = payload[:identifier]
15
+
16
+ return unless file && file.start_with?(Rails.root.to_s)
17
+
18
+ if !RailsTraceViewer::TraceContext.active?
19
+ trace_id = SecureRandom.uuid
20
+ RailsTraceViewer::TraceContext.start!(trace_id)
21
+ RailsTraceViewer::Collector.start_trace(trace_id)
22
+ Thread.current["rtv_view_root_#{id}"] = true
23
+ end
24
+
25
+ trace_id = RailsTraceViewer::TraceContext.trace_id
26
+ parent_id = RailsTraceViewer::TraceContext.parent_id
27
+ node_id = SecureRandom.uuid
28
+
29
+ Thread.current["rtv_view_node_#{id}"] = node_id
30
+
31
+ relative_path = file.sub(Rails.root.to_s, '')
32
+
33
+ node = {
34
+ id: node_id,
35
+ parent_id: parent_id,
36
+ type: "view",
37
+ name: relative_path.split("/").last,
38
+ source: relative_path,
39
+ layout: payload[:layout],
40
+ full_path: file,
41
+ children: []
42
+ }
43
+
44
+ RailsTraceViewer::Collector.add_node(trace_id, node)
45
+ RailsTraceViewer::TraceContext.push(node_id)
46
+ end
47
+
48
+ def finish(name, id, payload)
49
+ if Thread.current["rtv_view_node_#{id}"]
50
+ RailsTraceViewer::TraceContext.pop
51
+ Thread.current["rtv_view_node_#{id}"] = nil
52
+ end
53
+
54
+ if Thread.current["rtv_view_root_#{id}"]
55
+ RailsTraceViewer::TraceContext.stop!
56
+ Thread.current["rtv_view_root_#{id}"] = nil
30
57
  end
31
58
  end
32
59
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsTraceViewer
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -18,5 +18,14 @@ require "rails_trace_viewer/subscribers/sidekiq_subscriber"
18
18
  require "rails_trace_viewer/subscribers/method_subscriber"
19
19
 
20
20
  module RailsTraceViewer
21
- # Public API can be added here later
21
+ mattr_accessor :enabled
22
+ self.enabled = true
23
+
24
+ def self.enabled?
25
+ !!self.enabled
26
+ end
27
+
28
+ def self.configure
29
+ yield self
30
+ end
22
31
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_trace_viewer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aditya-JOSHÂ