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,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
|