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.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +227 -0
  5. data/Rakefile +12 -0
  6. data/app/assets/stylesheets/railscope/application.css +504 -0
  7. data/app/controllers/railscope/api/entries_controller.rb +103 -0
  8. data/app/controllers/railscope/application_controller.rb +12 -0
  9. data/app/controllers/railscope/dashboard_controller.rb +33 -0
  10. data/app/controllers/railscope/entries_controller.rb +29 -0
  11. data/app/helpers/railscope/dashboard_helper.rb +157 -0
  12. data/app/jobs/railscope/application_job.rb +6 -0
  13. data/app/jobs/railscope/purge_job.rb +15 -0
  14. data/app/models/railscope/application_record.rb +12 -0
  15. data/app/models/railscope/entry.rb +51 -0
  16. data/app/views/layouts/railscope/application.html.erb +14 -0
  17. data/app/views/railscope/application/index.html.erb +1 -0
  18. data/app/views/railscope/dashboard/index.html.erb +70 -0
  19. data/app/views/railscope/entries/show.html.erb +93 -0
  20. data/client/.gitignore +1 -0
  21. data/client/index.html +12 -0
  22. data/client/package-lock.json +2735 -0
  23. data/client/package.json +28 -0
  24. data/client/postcss.config.js +6 -0
  25. data/client/src/App.tsx +60 -0
  26. data/client/src/api/client.ts +25 -0
  27. data/client/src/api/entries.ts +36 -0
  28. data/client/src/components/Layout.tsx +17 -0
  29. data/client/src/components/PlaceholderPage.tsx +32 -0
  30. data/client/src/components/Sidebar.tsx +198 -0
  31. data/client/src/components/ui/Badge.tsx +67 -0
  32. data/client/src/components/ui/Card.tsx +38 -0
  33. data/client/src/components/ui/JsonViewer.tsx +80 -0
  34. data/client/src/components/ui/Pagination.tsx +45 -0
  35. data/client/src/components/ui/SearchInput.tsx +70 -0
  36. data/client/src/components/ui/Table.tsx +68 -0
  37. data/client/src/index.css +28 -0
  38. data/client/src/lib/hooks.ts +37 -0
  39. data/client/src/lib/types.ts +61 -0
  40. data/client/src/lib/utils.ts +38 -0
  41. data/client/src/main.tsx +13 -0
  42. data/client/src/screens/cache/Index.tsx +15 -0
  43. data/client/src/screens/client-requests/Index.tsx +15 -0
  44. data/client/src/screens/commands/Index.tsx +133 -0
  45. data/client/src/screens/commands/Show.tsx +395 -0
  46. data/client/src/screens/dumps/Index.tsx +15 -0
  47. data/client/src/screens/events/Index.tsx +15 -0
  48. data/client/src/screens/exceptions/Index.tsx +155 -0
  49. data/client/src/screens/exceptions/Show.tsx +480 -0
  50. data/client/src/screens/gates/Index.tsx +15 -0
  51. data/client/src/screens/jobs/Index.tsx +153 -0
  52. data/client/src/screens/jobs/Show.tsx +529 -0
  53. data/client/src/screens/logs/Index.tsx +15 -0
  54. data/client/src/screens/mail/Index.tsx +15 -0
  55. data/client/src/screens/models/Index.tsx +15 -0
  56. data/client/src/screens/notifications/Index.tsx +15 -0
  57. data/client/src/screens/queries/Index.tsx +159 -0
  58. data/client/src/screens/queries/Show.tsx +346 -0
  59. data/client/src/screens/redis/Index.tsx +15 -0
  60. data/client/src/screens/requests/Index.tsx +123 -0
  61. data/client/src/screens/requests/Show.tsx +395 -0
  62. data/client/src/screens/schedule/Index.tsx +15 -0
  63. data/client/src/screens/views/Index.tsx +141 -0
  64. data/client/src/screens/views/Show.tsx +337 -0
  65. data/client/tailwind.config.js +22 -0
  66. data/client/tsconfig.json +25 -0
  67. data/client/tsconfig.node.json +10 -0
  68. data/client/vite.config.ts +37 -0
  69. data/config/routes.rb +17 -0
  70. data/db/migrate/20260131023242_create_railscope_entries.rb +41 -0
  71. data/lib/generators/railscope/install_generator.rb +33 -0
  72. data/lib/generators/railscope/templates/initializer.rb +34 -0
  73. data/lib/railscope/context.rb +91 -0
  74. data/lib/railscope/engine.rb +85 -0
  75. data/lib/railscope/entry_data.rb +112 -0
  76. data/lib/railscope/filter.rb +113 -0
  77. data/lib/railscope/middleware.rb +162 -0
  78. data/lib/railscope/storage/base.rb +90 -0
  79. data/lib/railscope/storage/database.rb +83 -0
  80. data/lib/railscope/storage/redis_storage.rb +314 -0
  81. data/lib/railscope/subscribers/base_subscriber.rb +52 -0
  82. data/lib/railscope/subscribers/command_subscriber.rb +237 -0
  83. data/lib/railscope/subscribers/exception_subscriber.rb +113 -0
  84. data/lib/railscope/subscribers/job_subscriber.rb +249 -0
  85. data/lib/railscope/subscribers/query_subscriber.rb +130 -0
  86. data/lib/railscope/subscribers/request_subscriber.rb +121 -0
  87. data/lib/railscope/subscribers/view_subscriber.rb +201 -0
  88. data/lib/railscope/version.rb +5 -0
  89. data/lib/railscope.rb +145 -0
  90. data/lib/tasks/railscope_sample.rake +30 -0
  91. data/public/railscope/assets/app.css +1 -0
  92. data/public/railscope/assets/app.js +70 -0
  93. data/public/railscope/assets/index.html +13 -0
  94. data/sig/railscope.rbs +4 -0
  95. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ VERSION = "0.1.0"
5
+ end