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.
@@ -0,0 +1,13 @@
1
+ module RailsTraceViewer
2
+ class Broadcaster
3
+ def self.emit(trace_id, node)
4
+ ActionCable.server.broadcast(
5
+ "rails_trace_viewer",
6
+ {
7
+ trace_id: trace_id,
8
+ node: node
9
+ }
10
+ )
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,151 @@
1
+ module RailsTraceViewer
2
+ class Collector
3
+ TRACE_TREES = {}
4
+
5
+ @sweeper_started = false
6
+ @mutex = Mutex.new
7
+
8
+ class << self
9
+ def start_sweeper!
10
+ return if @sweeper_started
11
+
12
+ @sweeper_started = true
13
+
14
+ Thread.new do
15
+ Thread.current.name = "RailsTraceViewer-OrphanSweeper"
16
+ loop do
17
+ sleep 3
18
+ sweep_all_orphans
19
+ end
20
+ end
21
+ end
22
+
23
+ def start_trace(trace_id)
24
+ @mutex.synchronize do
25
+ TRACE_TREES[trace_id] ||= {
26
+ root: nil,
27
+ index: {},
28
+ orphans: {}
29
+ }
30
+ end
31
+ end
32
+
33
+ def add_node(trace_id, node)
34
+ after_broadcasts = []
35
+
36
+ @mutex.synchronize do
37
+ after_broadcasts = process_node(trace_id, node)
38
+ end
39
+
40
+ after_broadcasts.each(&:call)
41
+ end
42
+
43
+ def process_node(trace_id, node)
44
+ after = []
45
+ tree = TRACE_TREES[trace_id]
46
+
47
+ unless tree
48
+ tree = { root: nil, index: {}, orphans: {} }
49
+ TRACE_TREES[trace_id] = tree
50
+ end
51
+
52
+ id = node[:id]
53
+ parent_id = node[:parent_id]
54
+
55
+ tree[:index][id] = node
56
+
57
+ if parent_id.nil?
58
+ tree[:root] ||= node
59
+ after << -> { Broadcaster.emit(trace_id, node) }
60
+ after.concat attach_waiting_children(tree, trace_id, id, node)
61
+
62
+ else
63
+ parent = tree[:index][parent_id]
64
+
65
+ if parent
66
+ parent[:children] ||= []
67
+ parent[:children] << node
68
+
69
+ after << -> { Broadcaster.emit(trace_id, node) }
70
+ after.concat attach_waiting_children(tree, trace_id, id, node)
71
+ elsif node[:type] == "job_perform"
72
+ after << -> { Broadcaster.emit(trace_id, node) }
73
+
74
+ after.concat attach_waiting_children(tree, trace_id, id, node)
75
+ else
76
+ tree[:orphans][parent_id] ||= []
77
+ tree[:orphans][parent_id] << {
78
+ node: node,
79
+ time: Process.clock_gettime(Process::CLOCK_MONOTONIC)
80
+ }
81
+ end
82
+ end
83
+
84
+ after
85
+ end
86
+
87
+ def attach_waiting_children(tree, trace_id, parent_id, parent_node)
88
+ return [] unless tree[:orphans][parent_id]
89
+
90
+ after = []
91
+
92
+ tree[:orphans][parent_id].each do |entry|
93
+ child = entry[:node]
94
+ parent_node[:children] ||= []
95
+ parent_node[:children] << child
96
+
97
+ after << -> { Broadcaster.emit(trace_id, child) }
98
+
99
+ after.concat attach_waiting_children(tree, trace_id, child[:id], child)
100
+ end
101
+
102
+ tree[:orphans].delete(parent_id)
103
+ after
104
+ end
105
+
106
+ def sweep_all_orphans
107
+ after = []
108
+
109
+ @mutex.synchronize do
110
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
111
+
112
+ TRACE_TREES.each do |trace_id, tree|
113
+ next if tree[:orphans].empty?
114
+
115
+ tree[:orphans].each do |parent_id, entries|
116
+ entries.reject! do |entry|
117
+ if now - entry[:time] > 3
118
+ orphan = entry[:node]
119
+
120
+ after << -> { Broadcaster.emit(trace_id, orphan) }
121
+ true
122
+ else
123
+ false
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ after.each(&:call)
131
+ end
132
+
133
+ def finalize_trace(trace_id)
134
+ trace = nil
135
+
136
+ @mutex.synchronize do
137
+ tree = TRACE_TREES[trace_id]
138
+ return unless tree
139
+ trace = tree[:root]
140
+ TRACE_TREES.delete(trace_id)
141
+ end
142
+
143
+ ActionCable.server.broadcast(
144
+ "rails_trace_viewer",
145
+ event: "trace_completed",
146
+ trace: trace
147
+ )
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,32 @@
1
+ module RailsTraceViewer
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace RailsTraceViewer
4
+
5
+ initializer "rails_trace_viewer.subscribe", after: :load_config_initializers do
6
+ ActiveSupport.on_load(:action_controller) do
7
+ RailsTraceViewer::Subscribers::ControllerSubscriber.attach
8
+ end
9
+
10
+ ActiveSupport.on_load(:active_record) do
11
+ RailsTraceViewer::Subscribers::SqlSubscriber.attach
12
+ end
13
+
14
+ ActiveSupport.on_load(:action_view) do
15
+ RailsTraceViewer::Subscribers::ViewSubscriber.attach
16
+ end
17
+
18
+ ActiveSupport.on_load(:active_job) do
19
+ RailsTraceViewer::Subscribers::ActiveJobSubscriber.attach
20
+ end
21
+
22
+ Rails.application.reloader.to_prepare do
23
+ RailsTraceViewer::Subscribers::SidekiqSubscriber.attach
24
+ RailsTraceViewer::Subscribers::MethodSubscriber.attach
25
+ end
26
+ end
27
+
28
+ initializer "rails_trace_viewer.start_sweeper" do
29
+ RailsTraceViewer::Collector.start_sweeper!
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,42 @@
1
+ require "concurrent/map"
2
+
3
+ module RailsTraceViewer
4
+ class JobLinkRegistry
5
+ @store = Concurrent::Map.new
6
+ @pending = Concurrent::Map.new
7
+
8
+ class << self
9
+ def register(job, trace_id:, enqueue_node_id:)
10
+ job_id = job.job_id
11
+
12
+ @store[job_id] = {
13
+ trace_id: trace_id,
14
+ enqueue_node_id: enqueue_node_id
15
+ }
16
+
17
+ if @pending[job_id]
18
+ @pending[job_id].each do |callback|
19
+ callback.call(trace_id, enqueue_node_id)
20
+ end
21
+ @pending.delete(job_id)
22
+ end
23
+ end
24
+
25
+ def on_perform(job, &block)
26
+ job_id = job.job_id
27
+
28
+ if @store[job_id]
29
+ data = @store[job_id]
30
+ block.call(data[:trace_id], data[:enqueue_node_id])
31
+ else
32
+ @pending[job_id] ||= []
33
+ @pending[job_id] << block
34
+ end
35
+ end
36
+
37
+ def delete(job)
38
+ @store.delete(job.job_id)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ module RailsTraceViewer
2
+ class RouteResolver
3
+ def self.resolve(path, method)
4
+ recognized = Rails.application.routes.recognize_path(path, method: method.downcase.to_sym)
5
+
6
+ route = Rails.application.routes.routes.find do |r|
7
+ r.defaults[:controller] == recognized[:controller] &&
8
+ r.defaults[:action] == recognized[:action]
9
+ end
10
+
11
+ {
12
+ name: route&.name || "(unnamed)",
13
+ verb: method,
14
+ path: path
15
+ }
16
+ rescue
17
+ nil
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,72 @@
1
+ module RailsTraceViewer
2
+ module Subscribers
3
+ class ActiveJobSubscriber
4
+ def self.attach
5
+ return if @attached
6
+ @attached = true
7
+
8
+ safe_serialize = ->(data) { JSON.parse(data.to_json) rescue data.to_s }
9
+
10
+ ActiveSupport::Notifications.subscribe("enqueue.active_job") do |*_args, payload|
11
+ job = payload[:job]
12
+ next if job.class.include?(Sidekiq::Worker)
13
+
14
+ if RailsTraceViewer::TraceContext.active?
15
+ trace_id = RailsTraceViewer::TraceContext.trace_id
16
+ parent_id = RailsTraceViewer::TraceContext.parent_id
17
+ else
18
+ trace_id = SecureRandom.uuid
19
+ parent_id = nil
20
+ RailsTraceViewer::TraceContext.start!(trace_id)
21
+ RailsTraceViewer::Collector.start_trace(trace_id)
22
+ end
23
+
24
+ enqueue_node_id = SecureRandom.uuid
25
+
26
+ node = {
27
+ id: enqueue_node_id,
28
+ parent_id: parent_id,
29
+ type: "job_enqueue",
30
+ name: job.class.name,
31
+ source: "ActiveJob Enqueue",
32
+ queue: job.queue_name,
33
+ job_id: job.job_id,
34
+ arguments: safe_serialize.call(job.arguments),
35
+ scheduled_at: job.scheduled_at,
36
+ priority: job.priority,
37
+ children: []
38
+ }
39
+
40
+ RailsTraceViewer::JobLinkRegistry.register(job, trace_id: trace_id, enqueue_node_id: enqueue_node_id)
41
+ RailsTraceViewer::Collector.add_node(trace_id, node)
42
+ end
43
+
44
+ ActiveSupport::Notifications.subscribe("perform_start.active_job") do |*_args, payload|
45
+ job = payload[:job]
46
+
47
+ RailsTraceViewer::JobLinkRegistry.on_perform(job) do |trace_id, enqueue_node_id|
48
+ RailsTraceViewer::TraceContext.start_job!(trace_id)
49
+ RailsTraceViewer::Collector.start_trace(trace_id)
50
+
51
+ node_id = SecureRandom.uuid
52
+ node = {
53
+ id: node_id,
54
+ parent_id: enqueue_node_id,
55
+ type: "job_perform",
56
+ name: job.class.name,
57
+ source: "ActiveJob Perform",
58
+ job_id: job.job_id,
59
+ arguments: safe_serialize.call(job.arguments),
60
+ executions: job.executions,
61
+ children: []
62
+ }
63
+
64
+ RailsTraceViewer::Collector.add_node(trace_id, node)
65
+ RailsTraceViewer::TraceContext.push(node_id)
66
+ RailsTraceViewer::JobLinkRegistry.delete(job)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,51 @@
1
+ module RailsTraceViewer
2
+ module Subscribers
3
+ class ControllerSubscriber
4
+ def self.attach
5
+ return if @attached
6
+ @attached = true
7
+
8
+ ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |*, payload|
9
+ controller_name = payload[:controller].to_s
10
+ next if controller_name.start_with?("RailsTraceViewer::")
11
+
12
+ trace_id = SecureRandom.uuid
13
+ RailsTraceViewer::TraceContext.start!(trace_id)
14
+ RailsTraceViewer::Collector.start_trace(trace_id)
15
+
16
+ safe_params = payload[:params].to_unsafe_h rescue payload[:params]
17
+ safe_params = safe_params.except("controller", "action")
18
+
19
+ route = RailsTraceViewer::RouteResolver.resolve(payload[:path], payload[:method])
20
+ route_node_id = SecureRandom.uuid
21
+
22
+ route_node = {
23
+ id: route_node_id,
24
+ parent_id: nil,
25
+ type: "route",
26
+ name: "#{route[:verb]} #{route[:path]}",
27
+ source: payload[:path],
28
+ verb: route[:verb],
29
+ url_pattern: route[:path],
30
+ route_name: route[:name],
31
+ children: []
32
+ }
33
+ RailsTraceViewer::Collector.add_node(trace_id, route_node)
34
+
35
+ request_node = {
36
+ id: trace_id,
37
+ parent_id: route_node_id,
38
+ type: "request",
39
+ name: "#{payload[:controller]}##{payload[:action]}",
40
+ source: "#{payload[:controller]}.rb",
41
+ format: payload[:format],
42
+ params: safe_params,
43
+ children: []
44
+ }
45
+ RailsTraceViewer::Collector.add_node(trace_id, request_node)
46
+ RailsTraceViewer::TraceContext.push(trace_id)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,59 @@
1
+ module RailsTraceViewer
2
+ module Subscribers
3
+ class MethodSubscriber
4
+ @trace_point = nil
5
+
6
+ def self.attach
7
+ return if @trace_point
8
+
9
+ @trace_point = TracePoint.trace(:call, :return) do |tp|
10
+ path = tp.path.to_s
11
+
12
+ is_app_path = path.start_with?(Rails.root.to_s) &&
13
+ !path.include?("/vendor/") &&
14
+ !path.include?("rails_trace_viewer")
15
+ is_active_job = (tp.defined_class < ApplicationJob) rescue false
16
+ is_sidekiq_worker = (tp.defined_class.include?(Sidekiq::Worker)) rescue false
17
+
18
+ next unless is_app_path || is_active_job || is_sidekiq_worker
19
+
20
+ unless RailsTraceViewer::TraceContext.active?
21
+ trace_id = SecureRandom.uuid
22
+ RailsTraceViewer::TraceContext.start!(trace_id)
23
+ RailsTraceViewer::Collector.start_trace(trace_id)
24
+ parent_id = nil
25
+ else
26
+ trace_id = RailsTraceViewer::TraceContext.trace_id
27
+ parent_id = RailsTraceViewer::TraceContext.parent_id
28
+ end
29
+
30
+ if tp.event == :call
31
+ node_id = SecureRandom.uuid
32
+
33
+ class_name = tp.defined_class.name rescue "Anonymous"
34
+ is_singleton = tp.defined_class.singleton_class? rescue false
35
+ method_sig = "#{class_name}#{is_singleton ? '.' : '#'}#{tp.method_id}"
36
+
37
+ node = {
38
+ id: node_id,
39
+ parent_id: parent_id,
40
+ type: "method",
41
+ name: method_sig,
42
+ source: "#{path.sub(Rails.root.to_s, '')}:#{tp.lineno}",
43
+ file_path: path,
44
+ line_number: tp.lineno,
45
+ class_name: class_name,
46
+ method_name: tp.method_id,
47
+ children: []
48
+ }
49
+ RailsTraceViewer::Collector.add_node(trace_id, node)
50
+ RailsTraceViewer::TraceContext.push(node_id)
51
+
52
+ elsif tp.event == :return
53
+ RailsTraceViewer::TraceContext.pop
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,111 @@
1
+ module RailsTraceViewer
2
+ module Subscribers
3
+ class SidekiqSubscriber
4
+ def self.attach
5
+ return unless defined?(Sidekiq)
6
+ return if @attached
7
+ @attached = true
8
+
9
+ Sidekiq.configure_server do |config|
10
+ config.server_middleware { |chain| chain.add ServerMiddleware }
11
+ config.client_middleware { |chain| chain.add ClientMiddleware }
12
+ end
13
+ Sidekiq.configure_client do |config|
14
+ config.client_middleware { |chain| chain.add ClientMiddleware }
15
+ end
16
+ end
17
+
18
+ module SafeSerializer
19
+ def self.serialize(args)
20
+ return args unless args.is_a?(Array) || args.is_a?(Hash)
21
+ JSON.parse(args.to_json) rescue ["(Unserializable)"]
22
+ end
23
+ end
24
+
25
+ class ClientMiddleware
26
+ def call(worker_class, job, queue, redis_pool)
27
+ trace_id = nil
28
+ parent_id = nil
29
+ started_new_trace = false
30
+
31
+ if RailsTraceViewer::TraceContext.active?
32
+ trace_id = RailsTraceViewer::TraceContext.trace_id
33
+ parent_id = RailsTraceViewer::TraceContext.parent_id
34
+ else
35
+ trace_id = SecureRandom.uuid
36
+ RailsTraceViewer::TraceContext.start!(trace_id)
37
+ RailsTraceViewer::Collector.start_trace(trace_id)
38
+ started_new_trace = true
39
+ end
40
+
41
+ enqueue_node_id = SecureRandom.uuid
42
+ job["_trace_id"] = trace_id
43
+ job["_enqueue_node_id"] = enqueue_node_id
44
+
45
+ safe_args = SafeSerializer.serialize(job["args"])
46
+
47
+ node = {
48
+ id: enqueue_node_id,
49
+ parent_id: parent_id,
50
+ type: "job_enqueue",
51
+ name: "#{worker_class} (Enqueue)",
52
+ source: "Sidekiq Client",
53
+ worker_class: worker_class.to_s,
54
+ queue: queue,
55
+ job_arguments: safe_args,
56
+ jid: job["jid"],
57
+ children: []
58
+ }
59
+
60
+ RailsTraceViewer::Collector.add_node(trace_id, node)
61
+
62
+ yield
63
+ ensure
64
+ if started_new_trace
65
+ RailsTraceViewer::TraceContext.stop!
66
+ end
67
+ end
68
+ end
69
+
70
+ class ServerMiddleware
71
+ def call(worker, job, queue)
72
+ trace_id = job["_trace_id"]
73
+ parent_id = job["_enqueue_node_id"] || job["_parent_id"]
74
+ perform_node_id = nil
75
+
76
+ if trace_id
77
+ RailsTraceViewer::TraceContext.start_job!(trace_id)
78
+ RailsTraceViewer::Collector.start_trace(trace_id)
79
+
80
+ perform_node_id = SecureRandom.uuid
81
+ safe_args = SafeSerializer.serialize(job["args"])
82
+
83
+ node = {
84
+ id: perform_node_id,
85
+ parent_id: parent_id,
86
+ type: "job_perform",
87
+ name: "#{worker.class} (Perform)",
88
+ source: "Sidekiq Server",
89
+ worker_class: worker.class.to_s,
90
+ queue: queue,
91
+ job_arguments: safe_args,
92
+ jid: job["jid"],
93
+ retry_count: job["retry_count"],
94
+ children: []
95
+ }
96
+
97
+ RailsTraceViewer::Collector.add_node(trace_id, node)
98
+ RailsTraceViewer::TraceContext.push(perform_node_id)
99
+ end
100
+
101
+ yield
102
+ ensure
103
+ if trace_id
104
+ RailsTraceViewer::TraceContext.pop if perform_node_id
105
+ RailsTraceViewer::TraceContext.stop!
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,43 @@
1
+ module RailsTraceViewer
2
+ module Subscribers
3
+ class SqlSubscriber
4
+ def self.attach
5
+ return if @attached
6
+ @attached = true
7
+
8
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*_args, payload|
9
+ next unless RailsTraceViewer::TraceContext.active?
10
+ next if payload[:name] == "SCHEMA"
11
+
12
+ source = extract_app_source(caller)
13
+ next unless source
14
+
15
+ raw_binds = payload[:type_casted_binds]
16
+ if raw_binds.respond_to?(:call)
17
+ raw_binds = raw_binds.call
18
+ end
19
+
20
+ binds = (raw_binds || []).map { |val| val.to_s }
21
+
22
+ node = {
23
+ id: SecureRandom.uuid,
24
+ parent_id: RailsTraceViewer::TraceContext.parent_id,
25
+ type: "sql",
26
+ name: payload[:sql],
27
+ source: source,
28
+ full_sql: payload[:sql],
29
+ bind_values: binds,
30
+ connection_id: payload[:connection_id],
31
+ children: []
32
+ }
33
+
34
+ RailsTraceViewer::Collector.add_node(RailsTraceViewer::TraceContext.trace_id, node)
35
+ end
36
+ end
37
+
38
+ def self.extract_app_source(bt)
39
+ bt.find { |line| line.include?("/app/") }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,34 @@
1
+ module RailsTraceViewer
2
+ module Subscribers
3
+ class ViewSubscriber
4
+ EVENTS = ["render_template.action_view", "render_partial.action_view"]
5
+
6
+ def self.attach
7
+ return if @attached
8
+ @attached = true
9
+
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
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,56 @@
1
+ module RailsTraceViewer
2
+ class TraceContext
3
+ THREAD_KEY = :rails_trace_viewer_context
4
+
5
+ class << self
6
+ def context
7
+ Thread.current[THREAD_KEY] ||= {
8
+ active: false,
9
+ trace_id: nil,
10
+ stack: []
11
+ }
12
+ end
13
+
14
+ def start!(trace_id)
15
+ ctx = context
16
+ ctx[:active] = true
17
+ ctx[:trace_id] = trace_id
18
+ ctx[:stack] = []
19
+ end
20
+
21
+ def start_job!(trace_id)
22
+ ctx = context
23
+ ctx[:active] = true
24
+ ctx[:trace_id] = trace_id
25
+ ctx[:stack] ||= []
26
+ end
27
+
28
+ def stop!
29
+ ctx = context
30
+ ctx[:active] = false
31
+ ctx[:trace_id] = nil
32
+ ctx[:stack] = []
33
+ end
34
+
35
+ def active?
36
+ context[:active]
37
+ end
38
+
39
+ def trace_id
40
+ context[:trace_id]
41
+ end
42
+
43
+ def parent_id
44
+ context[:stack].last
45
+ end
46
+
47
+ def push(node_id)
48
+ context[:stack] << node_id
49
+ end
50
+
51
+ def pop
52
+ context[:stack].pop
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsTraceViewer
4
+ VERSION = "0.1.0"
5
+ end