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,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ module Storage
5
+ class RedisStorage < Base
6
+ # Key prefixes
7
+ PREFIX = "railscope"
8
+ ENTRY_KEY = "#{PREFIX}:entry:%s".freeze # Hash with entry data
9
+ ALL_ENTRIES_KEY = "#{PREFIX}:entries".freeze # Sorted set (score=timestamp)
10
+ DISPLAYABLE_KEY = "#{PREFIX}:displayable".freeze # Sorted set of displayable entries
11
+ BATCH_KEY = "#{PREFIX}:batch:%s".freeze # Sorted set per batch
12
+ FAMILY_KEY = "#{PREFIX}:family:%s".freeze # Sorted set per family
13
+ TYPE_KEY = "#{PREFIX}:type:%s".freeze # Sorted set per entry type
14
+ TAG_KEY = "#{PREFIX}:tag:%s".freeze # Set per tag
15
+
16
+ def write(attributes)
17
+ entry = build_entry(attributes)
18
+ store_entry(entry)
19
+ entry
20
+ end
21
+
22
+ def update_by_batch(batch_id:, entry_type:, payload_updates:)
23
+ # Find entries in this batch with the given type
24
+ uuids = redis.zrevrange(batch_key(batch_id), 0, -1)
25
+ return nil if uuids.empty?
26
+
27
+ # Find the entry of the specified type
28
+ uuids.each do |uuid|
29
+ entry = find(uuid)
30
+ next unless entry && entry.entry_type == entry_type
31
+
32
+ # Merge payload updates
33
+ updated_payload = entry.payload.merge(payload_updates)
34
+ updated_entry = EntryData.new(
35
+ uuid: entry.uuid,
36
+ batch_id: entry.batch_id,
37
+ family_hash: entry.family_hash,
38
+ entry_type: entry.entry_type,
39
+ payload: updated_payload,
40
+ tags: entry.tags,
41
+ should_display_on_index: entry.displayable?,
42
+ occurred_at: entry.occurred_at,
43
+ created_at: entry.created_at,
44
+ updated_at: Time.current
45
+ )
46
+
47
+ # Re-store the entry (overwrites the existing one)
48
+ ttl = Railscope.retention_days.days.to_i
49
+ redis.set(entry_key(uuid), updated_entry.to_json, ex: ttl)
50
+
51
+ return updated_entry
52
+ end
53
+
54
+ nil
55
+ end
56
+
57
+ def find(uuid)
58
+ json = redis.get(entry_key(uuid))
59
+ return nil unless json
60
+
61
+ EntryData.from_json(json)
62
+ end
63
+
64
+ def all(filters: {}, page: 1, per_page: 25, displayable_only: true)
65
+ uuids = fetch_uuids(filters, displayable_only, page, per_page)
66
+ fetch_entries(uuids)
67
+ end
68
+
69
+ def count(filters: {}, displayable_only: true)
70
+ if filters.empty?
71
+ key = displayable_only ? DISPLAYABLE_KEY : ALL_ENTRIES_KEY
72
+ redis.zcard(key)
73
+ elsif filters[:type].present? && filters.keys == [:type]
74
+ # Simple type filter - use zcard directly
75
+ key = type_key(filters[:type])
76
+ if displayable_only
77
+ # Intersection count
78
+ count_intersection(key, DISPLAYABLE_KEY)
79
+ else
80
+ redis.zcard(key)
81
+ end
82
+ elsif filters[:batch_id].present? && filters.keys == [:batch_id]
83
+ redis.zcard(batch_key(filters[:batch_id]))
84
+ elsif filters[:family_hash].present? && filters.keys == [:family_hash]
85
+ redis.zcard(family_key(filters[:family_hash]))
86
+ else
87
+ # Complex filter - fetch all matching UUIDs and count
88
+ count_filtered(filters, displayable_only)
89
+ end
90
+ end
91
+
92
+ def for_batch(batch_id)
93
+ uuids = redis.zrevrange(batch_key(batch_id), 0, -1)
94
+ fetch_entries(uuids)
95
+ end
96
+
97
+ def for_family(family_hash, page: 1, per_page: 25)
98
+ start_idx = (page - 1) * per_page
99
+ end_idx = start_idx + per_page - 1
100
+ uuids = redis.zrevrange(family_key(family_hash), start_idx, end_idx)
101
+ fetch_entries(uuids)
102
+ end
103
+
104
+ def family_count(family_hash)
105
+ redis.zcard(family_key(family_hash))
106
+ end
107
+
108
+ def destroy_all!
109
+ keys = redis.keys("#{PREFIX}:*")
110
+ return 0 if keys.empty?
111
+
112
+ count = redis.zcard(ALL_ENTRIES_KEY)
113
+ redis.del(*keys)
114
+ count
115
+ end
116
+
117
+ def destroy_expired!
118
+ cutoff = Railscope.retention_days.days.ago.to_f
119
+
120
+ # Get expired entry UUIDs
121
+ expired_uuids = redis.zrangebyscore(ALL_ENTRIES_KEY, "-inf", cutoff)
122
+ return 0 if expired_uuids.empty?
123
+
124
+ # Delete each entry and its index references
125
+ expired_uuids.each { |uuid| delete_entry(uuid) }
126
+
127
+ expired_uuids.size
128
+ end
129
+
130
+ def ready?
131
+ Railscope.redis_available?
132
+ end
133
+
134
+ private
135
+
136
+ def redis
137
+ Railscope.redis
138
+ end
139
+
140
+ def build_entry(attributes)
141
+ now = Time.current
142
+ EntryData.new(
143
+ uuid: attributes[:uuid] || SecureRandom.uuid,
144
+ batch_id: attributes[:batch_id],
145
+ family_hash: attributes[:family_hash],
146
+ entry_type: attributes[:entry_type],
147
+ payload: attributes[:payload] || {},
148
+ tags: attributes[:tags] || [],
149
+ should_display_on_index: attributes.fetch(:should_display_on_index, true),
150
+ occurred_at: attributes[:occurred_at] || now,
151
+ created_at: attributes[:created_at] || now,
152
+ updated_at: attributes[:updated_at] || now
153
+ )
154
+ end
155
+
156
+ def store_entry(entry)
157
+ uuid = entry.uuid
158
+ score = entry.occurred_at.to_f
159
+ ttl = Railscope.retention_days.days.to_i
160
+
161
+ redis.multi do |multi|
162
+ # Store entry data
163
+ multi.set(entry_key(uuid), entry.to_json, ex: ttl)
164
+
165
+ # Add to main sorted set
166
+ multi.zadd(ALL_ENTRIES_KEY, score, uuid)
167
+
168
+ # Add to displayable set if applicable
169
+ multi.zadd(DISPLAYABLE_KEY, score, uuid) if entry.displayable?
170
+
171
+ # Add to batch set
172
+ multi.zadd(batch_key(entry.batch_id), score, uuid) if entry.batch_id
173
+
174
+ # Add to family set
175
+ multi.zadd(family_key(entry.family_hash), score, uuid) if entry.family_hash
176
+
177
+ # Add to type set
178
+ multi.zadd(type_key(entry.entry_type), score, uuid) if entry.entry_type
179
+
180
+ # Add to tag sets
181
+ entry.tags.each do |tag|
182
+ multi.sadd(tag_key(tag), uuid)
183
+ end
184
+
185
+ # Set TTL on index keys
186
+ multi.expire(batch_key(entry.batch_id), ttl) if entry.batch_id
187
+ multi.expire(family_key(entry.family_hash), ttl) if entry.family_hash
188
+ end
189
+ end
190
+
191
+ def delete_entry(uuid)
192
+ entry = find(uuid)
193
+ return unless entry
194
+
195
+ redis.multi do |multi|
196
+ # Remove from all indexes
197
+ multi.del(entry_key(uuid))
198
+ multi.zrem(ALL_ENTRIES_KEY, uuid)
199
+ multi.zrem(DISPLAYABLE_KEY, uuid)
200
+ multi.zrem(batch_key(entry.batch_id), uuid) if entry.batch_id
201
+ multi.zrem(family_key(entry.family_hash), uuid) if entry.family_hash
202
+ multi.zrem(type_key(entry.entry_type), uuid) if entry.entry_type
203
+ entry.tags.each { |tag| multi.srem(tag_key(tag), uuid) }
204
+ end
205
+ end
206
+
207
+ def fetch_uuids(filters, displayable_only, page, per_page)
208
+ # Determine which set to query
209
+ base_key = if filters[:type].present?
210
+ type_key(filters[:type])
211
+ elsif filters[:batch_id].present?
212
+ batch_key(filters[:batch_id])
213
+ elsif filters[:family_hash].present?
214
+ family_key(filters[:family_hash])
215
+ elsif displayable_only
216
+ DISPLAYABLE_KEY
217
+ else
218
+ ALL_ENTRIES_KEY
219
+ end
220
+
221
+ # Calculate pagination
222
+ start_idx = (page - 1) * per_page
223
+ end_idx = per_page == Float::INFINITY ? -1 : start_idx + per_page - 1
224
+
225
+ uuids = redis.zrevrange(base_key, start_idx, end_idx)
226
+
227
+ # Apply additional filters if needed
228
+ if filters[:tag].present?
229
+ tag_members = redis.smembers(tag_key(filters[:tag]))
230
+ uuids &= tag_members
231
+ end
232
+
233
+ # If displayable filter and we're using a type/batch/family key
234
+ if displayable_only && !%W[#{DISPLAYABLE_KEY} #{ALL_ENTRIES_KEY}].include?(base_key)
235
+ displayable_members = redis.zrange(DISPLAYABLE_KEY, 0, -1)
236
+ uuids &= displayable_members
237
+ end
238
+
239
+ uuids
240
+ end
241
+
242
+ def fetch_entries(uuids)
243
+ return [] if uuids.empty?
244
+
245
+ # Fetch all entries in a single pipeline
246
+ jsons = redis.pipelined do |pipeline|
247
+ uuids.each { |uuid| pipeline.get(entry_key(uuid)) }
248
+ end
249
+
250
+ # Parse and filter out nil results (expired entries)
251
+ jsons.compact.map { |json| EntryData.from_json(json) }
252
+ end
253
+
254
+ # Key generators
255
+ def entry_key(uuid)
256
+ format(ENTRY_KEY, uuid)
257
+ end
258
+
259
+ def batch_key(batch_id)
260
+ format(BATCH_KEY, batch_id)
261
+ end
262
+
263
+ def family_key(family_hash)
264
+ format(FAMILY_KEY, family_hash)
265
+ end
266
+
267
+ def type_key(entry_type)
268
+ format(TYPE_KEY, entry_type)
269
+ end
270
+
271
+ def tag_key(tag)
272
+ format(TAG_KEY, tag)
273
+ end
274
+
275
+ def count_intersection(key1, key2)
276
+ # Get members from both sets and count intersection
277
+ members1 = redis.zrange(key1, 0, -1)
278
+ members2 = redis.zrange(key2, 0, -1)
279
+ (members1 & members2).size
280
+ end
281
+
282
+ def count_filtered(filters, displayable_only)
283
+ # Get all UUIDs matching the primary filter
284
+ base_key = if filters[:type].present?
285
+ type_key(filters[:type])
286
+ elsif filters[:batch_id].present?
287
+ batch_key(filters[:batch_id])
288
+ elsif filters[:family_hash].present?
289
+ family_key(filters[:family_hash])
290
+ elsif displayable_only
291
+ DISPLAYABLE_KEY
292
+ else
293
+ ALL_ENTRIES_KEY
294
+ end
295
+
296
+ uuids = redis.zrange(base_key, 0, -1)
297
+
298
+ # Apply tag filter
299
+ if filters[:tag].present?
300
+ tag_members = redis.smembers(tag_key(filters[:tag]))
301
+ uuids &= tag_members
302
+ end
303
+
304
+ # Apply displayable filter
305
+ if displayable_only && base_key != DISPLAYABLE_KEY
306
+ displayable_members = redis.zrange(DISPLAYABLE_KEY, 0, -1)
307
+ uuids &= displayable_members
308
+ end
309
+
310
+ uuids.size
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ module Subscribers
5
+ class BaseSubscriber
6
+ private
7
+
8
+ def context
9
+ Railscope::Context.current
10
+ end
11
+
12
+ def recording?
13
+ context[:recording] != false
14
+ end
15
+
16
+ def context_payload
17
+ {
18
+ request_id: context.request_id,
19
+ user_id: context.user_id
20
+ }.compact
21
+ end
22
+
23
+ def context_tags
24
+ context.tags.dup
25
+ end
26
+
27
+ def create_entry!(entry_type:, payload:, tags:, family_hash: nil, should_display_on_index: true)
28
+ return unless recording?
29
+
30
+ filtered_payload = Railscope.filter(payload.merge(context_payload))
31
+
32
+ Railscope.storage.write(
33
+ entry_type: entry_type,
34
+ batch_id: context.batch_id,
35
+ family_hash: family_hash,
36
+ should_display_on_index: should_display_on_index,
37
+ payload: filtered_payload,
38
+ tags: (tags + context_tags).uniq,
39
+ occurred_at: Time.current
40
+ )
41
+ end
42
+
43
+ # Generate a family hash for grouping similar entries
44
+ # Override in subclasses for specific hashing logic
45
+ def generate_family_hash(*components)
46
+ return nil if components.compact.empty?
47
+
48
+ Digest::SHA256.hexdigest(components.compact.join("::"))[0, 16]
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ module Subscribers
5
+ class CommandSubscriber < BaseSubscriber
6
+ class << self
7
+ def subscribe
8
+ return unless defined?(Rake::Task)
9
+
10
+ instrument_rake_tasks
11
+ end
12
+
13
+ private
14
+
15
+ def instrument_rake_tasks
16
+ Rake::Task.tasks.each do |task|
17
+ instrument_task(task)
18
+ end
19
+
20
+ # Also instrument tasks defined after initial load
21
+ Rake::Task.singleton_class.prepend(TaskInstrumentation)
22
+ end
23
+
24
+ def instrument_task(task)
25
+ return if task.name.start_with?("railscope:")
26
+ return if instrumented_tasks.include?(task.name)
27
+
28
+ instrumented_tasks << task.name
29
+
30
+ original_execute = task.method(:execute)
31
+
32
+ task.define_singleton_method(:execute) do |args = nil|
33
+ CommandSubscriber.new.record(task, args) do
34
+ original_execute.call(args)
35
+ end
36
+ end
37
+ end
38
+
39
+ def instrumented_tasks
40
+ @instrumented_tasks ||= Set.new
41
+ end
42
+ end
43
+
44
+ # Module to intercept new task definitions
45
+ module TaskInstrumentation
46
+ def define_task(*args, &block)
47
+ task = super
48
+ CommandSubscriber.send(:instrument_task, task) if task.is_a?(Rake::Task)
49
+ task
50
+ end
51
+ end
52
+
53
+ def record(task, args)
54
+ return yield unless Railscope.enabled?
55
+ return yield unless Railscope.ready?
56
+
57
+ # Setup context for this command (similar to middleware for HTTP requests)
58
+ setup_command_context(task)
59
+
60
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ exit_code = 0
62
+ exception_info = nil
63
+ caught_exception = nil
64
+
65
+ begin
66
+ result = yield
67
+ result
68
+ rescue SystemExit => e
69
+ exit_code = e.status
70
+ raise
71
+ rescue Exception => e
72
+ exit_code = 1
73
+ caught_exception = e
74
+ exception_info = {
75
+ class: e.class.name,
76
+ message: e.message,
77
+ backtrace: e.backtrace&.first(20)
78
+ }
79
+ raise
80
+ ensure
81
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
82
+
83
+ # Create the command entry
84
+ create_entry!(
85
+ entry_type: "command",
86
+ payload: build_payload(task, args, duration, exit_code, exception_info),
87
+ tags: build_tags(task, exit_code),
88
+ family_hash: generate_family_hash("command", task.name),
89
+ should_display_on_index: true
90
+ )
91
+
92
+ # Also create a separate exception entry (appears in exceptions list)
93
+ create_exception_entry!(task, caught_exception) if caught_exception
94
+
95
+ # Clear context after command completes
96
+ Railscope::Context.clear!
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def setup_command_context(task)
103
+ ctx = Railscope::Context.current
104
+ ctx.batch_id = SecureRandom.uuid
105
+ ctx[:recording] = true
106
+ ctx[:command] = task.name
107
+ end
108
+
109
+ def build_payload(task, args, duration, exit_code, exception_info)
110
+ {
111
+ command: task.name,
112
+ arguments: serialize_args(args),
113
+ options: extract_options,
114
+ description: task.comment,
115
+ hostname: Socket.gethostname,
116
+ duration: duration,
117
+ exit_code: exit_code,
118
+ exception: exception_info
119
+ }.compact
120
+ end
121
+
122
+ def build_tags(task, exit_code)
123
+ tags = ["command"]
124
+ tags << "failed" if exit_code != 0
125
+ tags << task.name.split(":").first if task.name.include?(":")
126
+ tags
127
+ end
128
+
129
+ def create_exception_entry!(task, exception)
130
+ file, line = extract_file_and_line(exception)
131
+ line_preview = extract_line_preview(file, line)
132
+
133
+ create_entry!(
134
+ entry_type: "exception",
135
+ payload: {
136
+ class: exception.class.name,
137
+ message: exception.message,
138
+ file: file,
139
+ line: line,
140
+ line_preview: line_preview,
141
+ backtrace: exception.backtrace&.first(20),
142
+ source: "command",
143
+ command: task.name
144
+ },
145
+ tags: build_exception_tags(exception),
146
+ family_hash: generate_family_hash("exception", exception.class.name, "command", task.name),
147
+ should_display_on_index: true
148
+ )
149
+ end
150
+
151
+ def extract_file_and_line(exception)
152
+ return [nil, nil] unless exception&.backtrace&.any?
153
+
154
+ first_line = exception.backtrace.first
155
+ if first_line =~ /\A(.+):(\d+)/
156
+ [Regexp.last_match(1), Regexp.last_match(2).to_i]
157
+ else
158
+ [nil, nil]
159
+ end
160
+ end
161
+
162
+ # Extract code context around the exception line (like Telescope)
163
+ def extract_line_preview(file, line)
164
+ return nil unless file && line && File.exist?(file)
165
+
166
+ lines = File.readlines(file)
167
+ start_line = [line - 10, 1].max
168
+ end_line = [line + 9, lines.length].min
169
+
170
+ result = {}
171
+ (start_line..end_line).each do |line_num|
172
+ result[line_num] = lines[line_num - 1]&.chomp || ""
173
+ end
174
+ result
175
+ rescue StandardError
176
+ nil
177
+ end
178
+
179
+ def build_exception_tags(exception)
180
+ tags = %w[exception command]
181
+ tags << exception.class.name.underscore.tr("/", "_") if exception.class.name
182
+ tags
183
+ end
184
+
185
+ def serialize_args(args)
186
+ return {} if args.nil?
187
+
188
+ case args
189
+ when Rake::TaskArguments
190
+ args.to_hash.transform_keys(&:to_s)
191
+ when Hash
192
+ args.transform_keys(&:to_s)
193
+ else
194
+ { "value" => args.to_s }
195
+ end
196
+ rescue StandardError
197
+ {}
198
+ end
199
+
200
+ def extract_options
201
+ # Only capture options that are actually passed (unlike Telescope which shows all defaults)
202
+ options = {}
203
+
204
+ ARGV.each do |arg|
205
+ case arg
206
+ when "--trace", "-t"
207
+ options["trace"] = true
208
+ when "--dry-run", "-n"
209
+ options["dry-run"] = true
210
+ when "--verbose", "-v"
211
+ options["verbose"] = true
212
+ when "--silent", "-s"
213
+ options["silent"] = true
214
+ when "--quiet", "-q"
215
+ options["quiet"] = true
216
+ when "--help", "-h", "-H"
217
+ options["help"] = true
218
+ when "--version", "-V"
219
+ options["version"] = true
220
+ when "--prereqs", "-P"
221
+ options["prereqs"] = true
222
+ when /^--(\w[\w-]*)=(.*)$/
223
+ options[Regexp.last_match(1)] = Regexp.last_match(2)
224
+ when /^--(\w[\w-]*)$/
225
+ options[Regexp.last_match(1)] = true
226
+ when /^-(\w)$/
227
+ options[Regexp.last_match(1)] = true
228
+ end
229
+ end
230
+
231
+ options
232
+ rescue StandardError
233
+ {}
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ module Subscribers
5
+ class ExceptionSubscriber < 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
+ return if event.payload[:exception].blank?
23
+
24
+ create_entry!(
25
+ entry_type: "exception",
26
+ payload: build_payload(event),
27
+ tags: build_tags(event),
28
+ family_hash: build_family_hash(event),
29
+ should_display_on_index: true
30
+ )
31
+ rescue StandardError => e
32
+ Rails.logger.error("[Railscope] Failed to record exception: #{e.message}")
33
+ end
34
+
35
+ private
36
+
37
+ def build_payload(event)
38
+ exception_class, exception_message = event.payload[:exception]
39
+ exception_object = event.payload[:exception_object]
40
+ file, line = extract_file_and_line(exception_object)
41
+ line_preview = extract_line_preview(file, line)
42
+
43
+ {
44
+ class: exception_class,
45
+ message: exception_message,
46
+ file: file,
47
+ line: line,
48
+ line_preview: line_preview,
49
+ backtrace: exception_object&.backtrace&.first(20),
50
+ path: event.payload[:path],
51
+ method: event.payload[:method],
52
+ controller: event.payload[:controller],
53
+ action: event.payload[:action],
54
+ params: filtered_params(event.payload[:params]),
55
+ status: event.payload[:status]
56
+ }
57
+ end
58
+
59
+ def extract_file_and_line(exception)
60
+ return [nil, nil] unless exception&.backtrace&.any?
61
+
62
+ first_line = exception.backtrace.first
63
+ if first_line =~ /\A(.+):(\d+)/
64
+ [Regexp.last_match(1), Regexp.last_match(2).to_i]
65
+ else
66
+ [nil, nil]
67
+ end
68
+ end
69
+
70
+ # Extract code context around the exception line (like Telescope)
71
+ def extract_line_preview(file, line)
72
+ return nil unless file && line && File.exist?(file)
73
+
74
+ lines = File.readlines(file)
75
+ start_line = [line - 10, 1].max
76
+ end_line = [line + 9, lines.length].min
77
+
78
+ result = {}
79
+ (start_line..end_line).each do |line_num|
80
+ result[line_num] = lines[line_num - 1]&.chomp || ""
81
+ end
82
+ result
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ def build_tags(event)
88
+ exception_class = event.payload[:exception]&.first
89
+ tags = ["exception"]
90
+ tags << exception_class.underscore.tr("/", "_") if exception_class
91
+ tags
92
+ end
93
+
94
+ # Group exceptions by class and location (controller#action)
95
+ def build_family_hash(event)
96
+ exception_class = event.payload[:exception]&.first
97
+ controller = event.payload[:controller]
98
+ action = event.payload[:action]
99
+ generate_family_hash("exception", exception_class, controller, action)
100
+ end
101
+
102
+ def filtered_params(params)
103
+ return {} if params.nil?
104
+
105
+ params.except(:controller, :action, :format).to_h.deep_transform_values do |value|
106
+ value.is_a?(String) && value.length > 200 ? "#{value[0..200]}..." : value
107
+ end
108
+ rescue StandardError
109
+ {}
110
+ end
111
+ end
112
+ end
113
+ end