railscope 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 +227 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/railscope/application.css +504 -0
- data/app/controllers/railscope/api/entries_controller.rb +103 -0
- data/app/controllers/railscope/application_controller.rb +12 -0
- data/app/controllers/railscope/dashboard_controller.rb +33 -0
- data/app/controllers/railscope/entries_controller.rb +29 -0
- data/app/helpers/railscope/dashboard_helper.rb +157 -0
- data/app/jobs/railscope/application_job.rb +6 -0
- data/app/jobs/railscope/purge_job.rb +15 -0
- data/app/models/railscope/application_record.rb +12 -0
- data/app/models/railscope/entry.rb +51 -0
- data/app/views/layouts/railscope/application.html.erb +14 -0
- data/app/views/railscope/application/index.html.erb +1 -0
- data/app/views/railscope/dashboard/index.html.erb +70 -0
- data/app/views/railscope/entries/show.html.erb +93 -0
- data/client/.gitignore +1 -0
- data/client/index.html +12 -0
- data/client/package-lock.json +2735 -0
- data/client/package.json +28 -0
- data/client/postcss.config.js +6 -0
- data/client/src/App.tsx +60 -0
- data/client/src/api/client.ts +25 -0
- data/client/src/api/entries.ts +36 -0
- data/client/src/components/Layout.tsx +17 -0
- data/client/src/components/PlaceholderPage.tsx +32 -0
- data/client/src/components/Sidebar.tsx +198 -0
- data/client/src/components/ui/Badge.tsx +67 -0
- data/client/src/components/ui/Card.tsx +38 -0
- data/client/src/components/ui/JsonViewer.tsx +80 -0
- data/client/src/components/ui/Pagination.tsx +45 -0
- data/client/src/components/ui/SearchInput.tsx +70 -0
- data/client/src/components/ui/Table.tsx +68 -0
- data/client/src/index.css +28 -0
- data/client/src/lib/hooks.ts +37 -0
- data/client/src/lib/types.ts +61 -0
- data/client/src/lib/utils.ts +38 -0
- data/client/src/main.tsx +13 -0
- data/client/src/screens/cache/Index.tsx +15 -0
- data/client/src/screens/client-requests/Index.tsx +15 -0
- data/client/src/screens/commands/Index.tsx +133 -0
- data/client/src/screens/commands/Show.tsx +395 -0
- data/client/src/screens/dumps/Index.tsx +15 -0
- data/client/src/screens/events/Index.tsx +15 -0
- data/client/src/screens/exceptions/Index.tsx +155 -0
- data/client/src/screens/exceptions/Show.tsx +480 -0
- data/client/src/screens/gates/Index.tsx +15 -0
- data/client/src/screens/jobs/Index.tsx +153 -0
- data/client/src/screens/jobs/Show.tsx +529 -0
- data/client/src/screens/logs/Index.tsx +15 -0
- data/client/src/screens/mail/Index.tsx +15 -0
- data/client/src/screens/models/Index.tsx +15 -0
- data/client/src/screens/notifications/Index.tsx +15 -0
- data/client/src/screens/queries/Index.tsx +159 -0
- data/client/src/screens/queries/Show.tsx +346 -0
- data/client/src/screens/redis/Index.tsx +15 -0
- data/client/src/screens/requests/Index.tsx +123 -0
- data/client/src/screens/requests/Show.tsx +395 -0
- data/client/src/screens/schedule/Index.tsx +15 -0
- data/client/src/screens/views/Index.tsx +141 -0
- data/client/src/screens/views/Show.tsx +337 -0
- data/client/tailwind.config.js +22 -0
- data/client/tsconfig.json +25 -0
- data/client/tsconfig.node.json +10 -0
- data/client/vite.config.ts +37 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260131023242_create_railscope_entries.rb +41 -0
- data/lib/generators/railscope/install_generator.rb +33 -0
- data/lib/generators/railscope/templates/initializer.rb +34 -0
- data/lib/railscope/context.rb +91 -0
- data/lib/railscope/engine.rb +85 -0
- data/lib/railscope/entry_data.rb +112 -0
- data/lib/railscope/filter.rb +113 -0
- data/lib/railscope/middleware.rb +162 -0
- data/lib/railscope/storage/base.rb +90 -0
- data/lib/railscope/storage/database.rb +83 -0
- data/lib/railscope/storage/redis_storage.rb +314 -0
- data/lib/railscope/subscribers/base_subscriber.rb +52 -0
- data/lib/railscope/subscribers/command_subscriber.rb +237 -0
- data/lib/railscope/subscribers/exception_subscriber.rb +113 -0
- data/lib/railscope/subscribers/job_subscriber.rb +249 -0
- data/lib/railscope/subscribers/query_subscriber.rb +130 -0
- data/lib/railscope/subscribers/request_subscriber.rb +121 -0
- data/lib/railscope/subscribers/view_subscriber.rb +201 -0
- data/lib/railscope/version.rb +5 -0
- data/lib/railscope.rb +145 -0
- data/lib/tasks/railscope_sample.rake +30 -0
- data/public/railscope/assets/app.css +1 -0
- data/public/railscope/assets/app.js +70 -0
- data/public/railscope/assets/index.html +13 -0
- data/sig/railscope.rbs +4 -0
- metadata +157 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
module Subscribers
|
|
5
|
+
class JobSubscriber < BaseSubscriber
|
|
6
|
+
ENQUEUE_EVENT = "enqueue.active_job"
|
|
7
|
+
PERFORM_START_EVENT = "perform_start.active_job"
|
|
8
|
+
PERFORM_EVENT = "perform.active_job"
|
|
9
|
+
|
|
10
|
+
def self.subscribe
|
|
11
|
+
return if @subscribed
|
|
12
|
+
|
|
13
|
+
@subscribed = true
|
|
14
|
+
|
|
15
|
+
ActiveSupport::Notifications.subscribe(ENQUEUE_EVENT) do |*args|
|
|
16
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
17
|
+
new.record_enqueue(event)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Setup context BEFORE job runs (so queries are linked)
|
|
21
|
+
ActiveSupport::Notifications.subscribe(PERFORM_START_EVENT) do |*args|
|
|
22
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
23
|
+
new.setup_perform_context(event)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Record result AFTER job completes
|
|
27
|
+
ActiveSupport::Notifications.subscribe(PERFORM_EVENT) do |*args|
|
|
28
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
29
|
+
new.record_perform(event)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def setup_perform_context(event)
|
|
34
|
+
return unless Railscope.enabled?
|
|
35
|
+
return if ignore_job?(event.payload[:job])
|
|
36
|
+
|
|
37
|
+
job = event.payload[:job]
|
|
38
|
+
setup_job_context(job)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def record_enqueue(event)
|
|
42
|
+
return unless Railscope.enabled?
|
|
43
|
+
return unless Railscope.ready?
|
|
44
|
+
return if ignore_job?(event.payload[:job])
|
|
45
|
+
|
|
46
|
+
job = event.payload[:job]
|
|
47
|
+
|
|
48
|
+
create_entry!(
|
|
49
|
+
entry_type: "job_enqueue",
|
|
50
|
+
payload: build_enqueue_payload(event),
|
|
51
|
+
tags: build_tags(event, "enqueue"),
|
|
52
|
+
family_hash: build_family_hash(job),
|
|
53
|
+
should_display_on_index: true
|
|
54
|
+
)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
Rails.logger.error("[Railscope] Failed to record job enqueue: #{e.message}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def record_perform(event)
|
|
60
|
+
return unless Railscope.enabled?
|
|
61
|
+
return unless Railscope.ready?
|
|
62
|
+
return if ignore_job?(event.payload[:job])
|
|
63
|
+
|
|
64
|
+
job = event.payload[:job]
|
|
65
|
+
exception_object = event.payload[:exception_object]
|
|
66
|
+
|
|
67
|
+
# Create the job perform entry
|
|
68
|
+
create_entry!(
|
|
69
|
+
entry_type: "job_perform",
|
|
70
|
+
payload: build_perform_payload(event),
|
|
71
|
+
tags: build_tags(event, "perform"),
|
|
72
|
+
family_hash: build_family_hash(job),
|
|
73
|
+
should_display_on_index: true
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Also create a separate exception entry if job failed
|
|
77
|
+
create_exception_entry!(job, exception_object) if exception_object
|
|
78
|
+
|
|
79
|
+
# Clear context after job completes
|
|
80
|
+
Railscope::Context.clear!
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
Rails.logger.error("[Railscope] Failed to record job perform: #{e.message}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def build_enqueue_payload(event)
|
|
88
|
+
job = event.payload[:job]
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
job_id: job.job_id,
|
|
92
|
+
job_class: job.class.name,
|
|
93
|
+
queue_name: job.queue_name,
|
|
94
|
+
connection: job.queue_adapter&.class&.name&.demodulize&.sub("Adapter", ""),
|
|
95
|
+
hostname: Socket.gethostname,
|
|
96
|
+
arguments: safe_arguments(job.arguments),
|
|
97
|
+
scheduled_at: job.scheduled_at,
|
|
98
|
+
priority: job.priority
|
|
99
|
+
}.compact
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_perform_payload(event)
|
|
103
|
+
job = event.payload[:job]
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
job_id: job.job_id,
|
|
107
|
+
job_class: job.class.name,
|
|
108
|
+
queue_name: job.queue_name,
|
|
109
|
+
connection: job.queue_adapter&.class&.name&.demodulize&.sub("Adapter", ""),
|
|
110
|
+
hostname: Socket.gethostname,
|
|
111
|
+
arguments: safe_arguments(job.arguments),
|
|
112
|
+
duration: event.duration.round(2),
|
|
113
|
+
executions: job.executions,
|
|
114
|
+
exception: extract_exception(event)
|
|
115
|
+
}.compact
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_tags(event, action)
|
|
119
|
+
job = event.payload[:job]
|
|
120
|
+
tags = ["job", action, job.queue_name]
|
|
121
|
+
tags << "failed" if event.payload[:exception_object].present?
|
|
122
|
+
tags << job.class.name.underscore.tr("/", "_")
|
|
123
|
+
tags.compact
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Group jobs by class name
|
|
127
|
+
def build_family_hash(job)
|
|
128
|
+
generate_family_hash("job", job.class.name)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def safe_arguments(arguments)
|
|
132
|
+
arguments.map do |arg|
|
|
133
|
+
case arg
|
|
134
|
+
when String, Numeric, TrueClass, FalseClass, NilClass
|
|
135
|
+
arg
|
|
136
|
+
when Hash
|
|
137
|
+
arg.transform_values { |v| safe_value(v) }
|
|
138
|
+
when Array
|
|
139
|
+
arg.map { |v| safe_value(v) }
|
|
140
|
+
else
|
|
141
|
+
arg.to_s
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
rescue StandardError
|
|
145
|
+
["[unserializable]"]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def safe_value(value)
|
|
149
|
+
case value
|
|
150
|
+
when String, Numeric, TrueClass, FalseClass, NilClass
|
|
151
|
+
value
|
|
152
|
+
else
|
|
153
|
+
value.to_s
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def extract_exception(event)
|
|
158
|
+
return nil unless event.payload[:exception_object]
|
|
159
|
+
|
|
160
|
+
exception = event.payload[:exception_object]
|
|
161
|
+
file, line = extract_file_and_line(exception)
|
|
162
|
+
line_preview = extract_line_preview(file, line)
|
|
163
|
+
|
|
164
|
+
{
|
|
165
|
+
class: exception.class.name,
|
|
166
|
+
message: exception.message,
|
|
167
|
+
file: file,
|
|
168
|
+
line: line,
|
|
169
|
+
line_preview: line_preview,
|
|
170
|
+
backtrace: exception.backtrace&.first(20)
|
|
171
|
+
}.compact
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def extract_file_and_line(exception)
|
|
175
|
+
return [nil, nil] unless exception&.backtrace&.any?
|
|
176
|
+
|
|
177
|
+
first_line = exception.backtrace.first
|
|
178
|
+
if first_line =~ /\A(.+):(\d+)/
|
|
179
|
+
[Regexp.last_match(1), Regexp.last_match(2).to_i]
|
|
180
|
+
else
|
|
181
|
+
[nil, nil]
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def extract_line_preview(file, line)
|
|
186
|
+
return nil unless file && line && File.exist?(file)
|
|
187
|
+
|
|
188
|
+
lines = File.readlines(file)
|
|
189
|
+
start_line = [line - 10, 1].max
|
|
190
|
+
end_line = [line + 9, lines.length].min
|
|
191
|
+
|
|
192
|
+
result = {}
|
|
193
|
+
(start_line..end_line).each do |line_num|
|
|
194
|
+
result[line_num] = lines[line_num - 1]&.chomp || ""
|
|
195
|
+
end
|
|
196
|
+
result
|
|
197
|
+
rescue StandardError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def ignore_job?(job)
|
|
202
|
+
job.class.name.start_with?("Railscope::")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def setup_job_context(job)
|
|
206
|
+
# Clear any existing context to ensure a fresh batch_id
|
|
207
|
+
Railscope::Context.clear!
|
|
208
|
+
|
|
209
|
+
ctx = Railscope::Context.current
|
|
210
|
+
new_batch_id = SecureRandom.uuid
|
|
211
|
+
ctx.batch_id = new_batch_id
|
|
212
|
+
ctx[:recording] = true
|
|
213
|
+
ctx[:job_class] = job.class.name
|
|
214
|
+
ctx[:job_id] = job.job_id
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def create_exception_entry!(job, exception)
|
|
218
|
+
file, line = extract_file_and_line(exception)
|
|
219
|
+
line_preview = extract_line_preview(file, line)
|
|
220
|
+
|
|
221
|
+
create_entry!(
|
|
222
|
+
entry_type: "exception",
|
|
223
|
+
payload: {
|
|
224
|
+
class: exception.class.name,
|
|
225
|
+
message: exception.message,
|
|
226
|
+
file: file,
|
|
227
|
+
line: line,
|
|
228
|
+
line_preview: line_preview,
|
|
229
|
+
backtrace: exception.backtrace&.first(20),
|
|
230
|
+
source: "job",
|
|
231
|
+
job_class: job.class.name,
|
|
232
|
+
job_id: job.job_id,
|
|
233
|
+
queue_name: job.queue_name
|
|
234
|
+
}.compact,
|
|
235
|
+
tags: build_exception_tags(exception, job),
|
|
236
|
+
family_hash: generate_family_hash("exception", exception.class.name, "job", job.class.name),
|
|
237
|
+
should_display_on_index: true
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def build_exception_tags(exception, job)
|
|
242
|
+
tags = %w[exception job]
|
|
243
|
+
tags << exception.class.name.underscore.tr("/", "_") if exception.class.name
|
|
244
|
+
tags << job.class.name.underscore.tr("/", "_")
|
|
245
|
+
tags
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
module Subscribers
|
|
5
|
+
class QuerySubscriber < BaseSubscriber
|
|
6
|
+
EVENT_NAME = "sql.active_record"
|
|
7
|
+
|
|
8
|
+
IGNORED_NAMES = %w[SCHEMA TRANSACTION].freeze
|
|
9
|
+
IGNORED_SQL_PATTERNS = [
|
|
10
|
+
/\A\s*SELECT.*sqlite_master/i,
|
|
11
|
+
/\A\s*SELECT.*pg_catalog/i,
|
|
12
|
+
/\A\s*SELECT.*information_schema/i,
|
|
13
|
+
/\A\s*SHOW/i,
|
|
14
|
+
/\A\s*SET/i
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def self.subscribe
|
|
18
|
+
return if @subscribed
|
|
19
|
+
|
|
20
|
+
@subscribed = true
|
|
21
|
+
|
|
22
|
+
ActiveSupport::Notifications.subscribe(EVENT_NAME) do |*args|
|
|
23
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
24
|
+
new.record(event)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def record(event)
|
|
29
|
+
return unless Railscope.enabled?
|
|
30
|
+
return unless Railscope.ready?
|
|
31
|
+
return if ignore_query?(event)
|
|
32
|
+
|
|
33
|
+
# Debug: log the batch_id being used
|
|
34
|
+
if context[:job_class].present?
|
|
35
|
+
Rails.logger.debug("[Railscope] Query in job context - batch_id: #{context.batch_id}, job: #{context[:job_class]}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
create_entry!(
|
|
39
|
+
entry_type: "query",
|
|
40
|
+
payload: build_payload(event),
|
|
41
|
+
tags: build_tags(event),
|
|
42
|
+
family_hash: build_family_hash(event),
|
|
43
|
+
should_display_on_index: true
|
|
44
|
+
)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Rails.logger.error("[Railscope] Failed to record query: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_payload(event)
|
|
52
|
+
file, line = extract_caller_location
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
sql: event.payload[:sql],
|
|
56
|
+
name: event.payload[:name],
|
|
57
|
+
duration: event.duration.round(2),
|
|
58
|
+
cached: event.payload[:cached] || false,
|
|
59
|
+
async: event.payload[:async] || false,
|
|
60
|
+
row_count: event.payload[:row_count],
|
|
61
|
+
connection: event.payload[:connection]&.adapter_name,
|
|
62
|
+
file: file,
|
|
63
|
+
line: line
|
|
64
|
+
}.compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Extract the location where the query was called from (application code)
|
|
68
|
+
def extract_caller_location
|
|
69
|
+
# Filter out Rails internals, gems, and Railscope itself
|
|
70
|
+
app_line = caller.find do |line|
|
|
71
|
+
line.include?(Rails.root.to_s) &&
|
|
72
|
+
!line.include?("/vendor/") &&
|
|
73
|
+
!line.include?("/railscope/")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
return [nil, nil] unless app_line
|
|
77
|
+
|
|
78
|
+
if app_line =~ /\A(.+):(\d+)/
|
|
79
|
+
[Regexp.last_match(1), Regexp.last_match(2).to_i]
|
|
80
|
+
else
|
|
81
|
+
[nil, nil]
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError
|
|
84
|
+
[nil, nil]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_tags(event)
|
|
88
|
+
tags = ["query"]
|
|
89
|
+
tags << query_type(event.payload[:sql])
|
|
90
|
+
tags << "cached" if event.payload[:cached]
|
|
91
|
+
tags << "slow" if event.duration > 100
|
|
92
|
+
tags.compact
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Generate family hash based on normalized SQL (without literal values)
|
|
96
|
+
def build_family_hash(event)
|
|
97
|
+
normalized_sql = normalize_sql(event.payload[:sql])
|
|
98
|
+
generate_family_hash("query", normalized_sql)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Normalize SQL by replacing literal values with placeholders
|
|
102
|
+
def normalize_sql(sql)
|
|
103
|
+
sql.to_s
|
|
104
|
+
.gsub(/\d+/, "?") # Replace numbers
|
|
105
|
+
.gsub(/'[^']*'/, "?") # Replace single-quoted strings
|
|
106
|
+
.gsub(/"[^"]*"/, "?") # Replace double-quoted strings
|
|
107
|
+
.gsub(/\$\d+/, "?") # Replace PostgreSQL positional params
|
|
108
|
+
.gsub(/\s+/, " ") # Normalize whitespace
|
|
109
|
+
.strip
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def query_type(sql)
|
|
113
|
+
case sql.to_s.strip
|
|
114
|
+
when /\A\s*SELECT/i then "select"
|
|
115
|
+
when /\A\s*INSERT/i then "insert"
|
|
116
|
+
when /\A\s*UPDATE/i then "update"
|
|
117
|
+
when /\A\s*DELETE/i then "delete"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def ignore_query?(event)
|
|
122
|
+
return true if event.payload[:name].to_s.in?(IGNORED_NAMES)
|
|
123
|
+
return true if IGNORED_SQL_PATTERNS.any? { |pattern| event.payload[:sql].to_s.match?(pattern) }
|
|
124
|
+
return true if event.payload[:sql].to_s.include?("railscope_entries")
|
|
125
|
+
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
module Subscribers
|
|
5
|
+
class RequestSubscriber < BaseSubscriber
|
|
6
|
+
EVENT_NAME = "process_action.action_controller"
|
|
7
|
+
|
|
8
|
+
def self.subscribe
|
|
9
|
+
return if @subscribed
|
|
10
|
+
|
|
11
|
+
@subscribed = true
|
|
12
|
+
|
|
13
|
+
ActiveSupport::Notifications.subscribe(EVENT_NAME) do |*args|
|
|
14
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
15
|
+
new.record(event)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def record(event)
|
|
20
|
+
return unless Railscope.enabled?
|
|
21
|
+
return unless Railscope.ready?
|
|
22
|
+
|
|
23
|
+
create_entry!(
|
|
24
|
+
entry_type: "request",
|
|
25
|
+
payload: build_payload(event),
|
|
26
|
+
tags: build_tags(event),
|
|
27
|
+
family_hash: build_family_hash(event),
|
|
28
|
+
should_display_on_index: true
|
|
29
|
+
)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
Rails.logger.error("[Railscope] Failed to record request: #{e.message}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_payload(event)
|
|
37
|
+
request = event.payload[:request]
|
|
38
|
+
headers = extract_headers(event.payload[:headers] || request&.headers)
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
path: event.payload[:path],
|
|
42
|
+
method: event.payload[:method],
|
|
43
|
+
status: event.payload[:status],
|
|
44
|
+
duration: event.duration.round(2),
|
|
45
|
+
controller: event.payload[:controller],
|
|
46
|
+
action: event.payload[:action],
|
|
47
|
+
controller_action: "#{event.payload[:controller]}@#{event.payload[:action]}",
|
|
48
|
+
format: event.payload[:format],
|
|
49
|
+
view_runtime: event.payload[:view_runtime]&.round(2),
|
|
50
|
+
db_runtime: event.payload[:db_runtime]&.round(2),
|
|
51
|
+
ip_address: context[:ip_address] || extract_ip(request),
|
|
52
|
+
hostname: Socket.gethostname,
|
|
53
|
+
# Request data
|
|
54
|
+
payload: filter_params(event.payload[:params]),
|
|
55
|
+
headers: headers
|
|
56
|
+
}.compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def extract_headers(headers)
|
|
60
|
+
return {} unless headers
|
|
61
|
+
|
|
62
|
+
result = {}
|
|
63
|
+
headers.each do |key, value|
|
|
64
|
+
# Only include HTTP headers, skip internal Rails headers
|
|
65
|
+
if key.start_with?("HTTP_") || %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
|
|
66
|
+
header_name = key.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-")
|
|
67
|
+
result[header_name] = value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
result
|
|
71
|
+
rescue StandardError
|
|
72
|
+
{}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def extract_ip(request)
|
|
76
|
+
return nil unless request
|
|
77
|
+
|
|
78
|
+
request.try(:remote_ip) || request.try(:ip)
|
|
79
|
+
rescue StandardError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def filter_params(params)
|
|
84
|
+
return nil if params.blank?
|
|
85
|
+
|
|
86
|
+
# Convert ActionController::Parameters to hash
|
|
87
|
+
params_hash = if params.respond_to?(:to_unsafe_h)
|
|
88
|
+
params.to_unsafe_h
|
|
89
|
+
elsif params.respond_to?(:to_hash)
|
|
90
|
+
params.to_hash
|
|
91
|
+
else
|
|
92
|
+
params.to_h
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Remove controller/action/format (both string and symbol keys)
|
|
96
|
+
filtered = params_hash.reject { |k, _| %w[controller action format].include?(k.to_s) }
|
|
97
|
+
|
|
98
|
+
return nil if filtered.empty?
|
|
99
|
+
|
|
100
|
+
Railscope.filter(filtered)
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
Rails.logger.error("[Railscope] filter_params error: #{e.message}")
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_tags(event)
|
|
107
|
+
tags = ["request", event.payload[:method]&.downcase].compact
|
|
108
|
+
tags << "error" if event.payload[:status].to_i >= 400
|
|
109
|
+
tags << "slow" if event.duration > 1000
|
|
110
|
+
tags
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Group requests by controller#action
|
|
114
|
+
def build_family_hash(event)
|
|
115
|
+
controller = event.payload[:controller]
|
|
116
|
+
action = event.payload[:action]
|
|
117
|
+
generate_family_hash("request", controller, action)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
module Subscribers
|
|
5
|
+
class ViewSubscriber < BaseSubscriber
|
|
6
|
+
TEMPLATE_EVENT = "render_template.action_view"
|
|
7
|
+
PARTIAL_EVENT = "render_partial.action_view"
|
|
8
|
+
LAYOUT_EVENT = "render_layout.action_view"
|
|
9
|
+
|
|
10
|
+
def self.subscribe
|
|
11
|
+
return if @subscribed
|
|
12
|
+
|
|
13
|
+
@subscribed = true
|
|
14
|
+
|
|
15
|
+
# Include controller tracking in ApplicationController
|
|
16
|
+
setup_controller_tracking
|
|
17
|
+
|
|
18
|
+
ActiveSupport::Notifications.subscribe(TEMPLATE_EVENT) do |*args|
|
|
19
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
20
|
+
new.record_template(event)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ActiveSupport::Notifications.subscribe(PARTIAL_EVENT) do |*args|
|
|
24
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
25
|
+
new.record_partial(event)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.setup_controller_tracking
|
|
30
|
+
return if @controller_tracking_setup
|
|
31
|
+
|
|
32
|
+
@controller_tracking_setup = true
|
|
33
|
+
|
|
34
|
+
controller_module = Module.new do
|
|
35
|
+
extend ActiveSupport::Concern
|
|
36
|
+
|
|
37
|
+
included do
|
|
38
|
+
around_action :railscope_track_controller
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def railscope_track_controller
|
|
44
|
+
Thread.current[:railscope_current_controller] = self
|
|
45
|
+
yield
|
|
46
|
+
ensure
|
|
47
|
+
Thread.current[:railscope_current_controller] = nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if defined?(ActionController::Base)
|
|
52
|
+
ActionController::Base.include(controller_module)
|
|
53
|
+
else
|
|
54
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
55
|
+
include controller_module
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def record_template(event)
|
|
61
|
+
return unless Railscope.enabled?
|
|
62
|
+
return unless Railscope.ready?
|
|
63
|
+
return if ignore_view?(event)
|
|
64
|
+
|
|
65
|
+
create_entry!(
|
|
66
|
+
entry_type: "view",
|
|
67
|
+
payload: build_payload(event, "template"),
|
|
68
|
+
tags: build_tags(event, "template"),
|
|
69
|
+
family_hash: build_family_hash(event),
|
|
70
|
+
should_display_on_index: true
|
|
71
|
+
)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Rails.logger.error("[Railscope] Failed to record view template: #{e.message}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def record_partial(event)
|
|
77
|
+
return unless Railscope.enabled?
|
|
78
|
+
return unless Railscope.ready?
|
|
79
|
+
return if ignore_view?(event)
|
|
80
|
+
|
|
81
|
+
create_entry!(
|
|
82
|
+
entry_type: "view",
|
|
83
|
+
payload: build_payload(event, "partial"),
|
|
84
|
+
tags: build_tags(event, "partial"),
|
|
85
|
+
family_hash: build_family_hash(event),
|
|
86
|
+
should_display_on_index: false # Partials don't show in index, only in related entries
|
|
87
|
+
)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
Rails.logger.error("[Railscope] Failed to record view partial: #{e.message}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def build_payload(event, view_type)
|
|
95
|
+
identifier = event.payload[:identifier] || ""
|
|
96
|
+
layout = event.payload[:layout]
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
name: extract_view_name(identifier),
|
|
100
|
+
path: extract_relative_path(identifier),
|
|
101
|
+
full_path: identifier,
|
|
102
|
+
view_type: view_type,
|
|
103
|
+
layout: layout,
|
|
104
|
+
duration: event.duration.round(2),
|
|
105
|
+
# View data (instance variables) - extracted from controller if available
|
|
106
|
+
data: extract_view_data
|
|
107
|
+
}.compact
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def extract_view_name(identifier)
|
|
111
|
+
return "" if identifier.blank?
|
|
112
|
+
|
|
113
|
+
# Extract view name from path like:
|
|
114
|
+
# /app/views/posts/index.html.erb -> posts/index
|
|
115
|
+
if identifier.include?("/app/views/")
|
|
116
|
+
identifier.split("/app/views/").last&.sub(/\.\w+\.\w+$/, "") || identifier
|
|
117
|
+
elsif identifier.include?("/views/")
|
|
118
|
+
identifier.split("/views/").last&.sub(/\.\w+\.\w+$/, "") || identifier
|
|
119
|
+
else
|
|
120
|
+
File.basename(identifier, ".*").sub(/\.\w+$/, "")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def extract_relative_path(identifier)
|
|
125
|
+
return "" if identifier.blank?
|
|
126
|
+
|
|
127
|
+
if identifier.include?(Rails.root.to_s)
|
|
128
|
+
identifier.sub(Rails.root.join("").to_s, "")
|
|
129
|
+
else
|
|
130
|
+
identifier
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def extract_view_data
|
|
135
|
+
# Try to get instance variables from the current controller
|
|
136
|
+
controller = Thread.current[:railscope_current_controller]
|
|
137
|
+
return nil unless controller
|
|
138
|
+
|
|
139
|
+
# Get instance variables that were set in the controller
|
|
140
|
+
ivars = {}
|
|
141
|
+
controller.instance_variables.each do |ivar|
|
|
142
|
+
name = ivar.to_s.sub("@", "")
|
|
143
|
+
# Skip internal Rails variables
|
|
144
|
+
next if name.start_with?("_")
|
|
145
|
+
next if %w[request response performed_redirect marked_for_same_origin_verification].include?(name)
|
|
146
|
+
|
|
147
|
+
value = controller.instance_variable_get(ivar)
|
|
148
|
+
ivars[name] = safe_serialize(value)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
ivars.presence
|
|
152
|
+
rescue StandardError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def safe_serialize(value)
|
|
157
|
+
case value
|
|
158
|
+
when String, Numeric, TrueClass, FalseClass, NilClass
|
|
159
|
+
value
|
|
160
|
+
when Array
|
|
161
|
+
value.first(10).map { |v| safe_serialize(v) }
|
|
162
|
+
when Hash
|
|
163
|
+
value.transform_values { |v| safe_serialize(v) }
|
|
164
|
+
when ActiveRecord::Base
|
|
165
|
+
{ class: value.class.name, id: value.try(:id) }
|
|
166
|
+
when ActiveRecord::Relation
|
|
167
|
+
{ class: value.klass.name, count: value.size, sql: value.to_sql[0..200] }
|
|
168
|
+
else
|
|
169
|
+
value.class.name
|
|
170
|
+
end
|
|
171
|
+
rescue StandardError
|
|
172
|
+
value.class.name
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_tags(event, view_type)
|
|
176
|
+
tags = ["view", view_type]
|
|
177
|
+
tags << "slow" if event.duration > 100
|
|
178
|
+
tags
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def build_family_hash(event)
|
|
182
|
+
identifier = event.payload[:identifier] || ""
|
|
183
|
+
view_name = extract_view_name(identifier)
|
|
184
|
+
generate_family_hash("view", view_name)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def ignore_view?(event)
|
|
188
|
+
identifier = event.payload[:identifier].to_s
|
|
189
|
+
|
|
190
|
+
# Ignore Railscope's own views
|
|
191
|
+
return true if identifier.include?("railscope")
|
|
192
|
+
|
|
193
|
+
# Ignore Rails internal views
|
|
194
|
+
return true if identifier.include?("actionmailer")
|
|
195
|
+
return true if identifier.include?("action_mailbox")
|
|
196
|
+
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|