railscope 0.1.2 → 0.1.4
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 +4 -4
- data/README.md +62 -2
- data/app/models/railscope/application_record.rb +25 -4
- data/client/src/components/ui/JsonViewer.tsx +21 -8
- data/lib/generators/railscope/templates/initializer.rb +15 -0
- data/lib/railscope/engine.rb +27 -3
- data/lib/railscope/flush_service.rb +92 -0
- data/lib/railscope/middleware.rb +121 -27
- data/lib/railscope/storage/database.rb +9 -0
- data/lib/railscope/storage/redis_buffer.rb +95 -0
- data/lib/railscope/version.rb +1 -1
- data/lib/railscope.rb +3 -2
- data/lib/tasks/railscope.rake +15 -0
- data/public/railscope/assets/app.css +1 -1
- data/public/railscope/assets/app.js +13 -13
- metadata +4 -2
- data/lib/railscope/storage/redis_storage.rb +0 -314
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 407d8ab344bde0dfb68984086462f3aef07114a6d372ce5f6a2a4fb4ca4c5fd1
|
|
4
|
+
data.tar.gz: 1dff09145783ff05482bf3d9d118401d325d2373e0bdc8fa3a1ae74885713623
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3283c7964a1b56b8b89ee354204394d8d5aff9b1d0d7c36d49e2ed45d1d3a0c0382da567e869b9b3b836fbf4a4d9c261beed24efee8931f96e529e8e04d329e0
|
|
7
|
+
data.tar.gz: 8ef7eaf49c58775642fa9c0cb8a05c451d7f9e07b87315dbdb5c5990eed590c2d66a8d889b40feef213c97cf0ebb6f18edd3606fcc9215dde30e5ae578a43905
|
data/README.md
CHANGED
|
@@ -13,7 +13,8 @@ Railscope provides insight into the requests, exceptions, database queries, jobs
|
|
|
13
13
|
- **Context Correlation** - Link all events from the same request via `request_id`
|
|
14
14
|
- **Sensitive Data Filtering** - Automatic masking of passwords, tokens, and secrets
|
|
15
15
|
- **Dark Mode UI** - Beautiful GitHub-inspired dark interface
|
|
16
|
-
- **
|
|
16
|
+
- **Storage Backends** - Direct database writes or Redis buffer with batch flush
|
|
17
|
+
- **Zero Dependencies** - Works with any Rails 7+ application (Redis optional)
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -55,6 +56,9 @@ Create `config/initializers/railscope.rb`:
|
|
|
55
56
|
|
|
56
57
|
```ruby
|
|
57
58
|
Railscope.configure do |config|
|
|
59
|
+
# Storage backend: :database (default) or :redis (buffer)
|
|
60
|
+
config.storage_backend = :database
|
|
61
|
+
|
|
58
62
|
# Data retention (default: 7 days)
|
|
59
63
|
config.retention_days = 30
|
|
60
64
|
|
|
@@ -66,11 +70,49 @@ Railscope.configure do |config|
|
|
|
66
70
|
end
|
|
67
71
|
```
|
|
68
72
|
|
|
73
|
+
### Storage Backends
|
|
74
|
+
|
|
75
|
+
Railscope supports two storage modes:
|
|
76
|
+
|
|
77
|
+
| Mode | Write | Read | Requires |
|
|
78
|
+
|------|-------|------|----------|
|
|
79
|
+
| `:database` | Direct INSERT (sync) | PostgreSQL | PostgreSQL |
|
|
80
|
+
| `:redis` | Redis buffer (async) | PostgreSQL | PostgreSQL + Redis |
|
|
81
|
+
|
|
82
|
+
**`:database`** (default) -- Entries are written directly to PostgreSQL during the request. Simplest setup, no Redis needed.
|
|
83
|
+
|
|
84
|
+
**`:redis`** -- Entries are buffered in Redis (~0.1ms per write) and batch-flushed to PostgreSQL periodically. Reduces request latency in high-throughput applications.
|
|
85
|
+
|
|
86
|
+
When using `:redis`, you need to flush the buffer periodically:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# From a background job (Sidekiq, GoodJob, SolidQueue, etc.)
|
|
90
|
+
class RailscopeFlushJob < ApplicationJob
|
|
91
|
+
queue_as :low
|
|
92
|
+
|
|
93
|
+
def perform
|
|
94
|
+
Railscope::FlushService.call
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# From a cron/scheduler
|
|
99
|
+
every 5.seconds do
|
|
100
|
+
runner "Railscope::FlushService.call"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Or via rake
|
|
104
|
+
# $ rake railscope:flush
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
> **Note:** Entries only appear in the dashboard after being flushed to PostgreSQL.
|
|
108
|
+
|
|
69
109
|
### Environment Variables
|
|
70
110
|
|
|
71
111
|
| Variable | Description | Default |
|
|
72
112
|
|----------|-------------|---------|
|
|
73
113
|
| `RAILSCOPE_ENABLED` | Enable/disable recording | `false` |
|
|
114
|
+
| `RAILSCOPE_STORAGE` | Storage backend (`database` or `redis`) | `database` |
|
|
115
|
+
| `RAILSCOPE_REDIS_URL` | Redis connection URL | Falls back to `REDIS_URL` |
|
|
74
116
|
| `RAILSCOPE_RETENTION_DAYS` | Days to keep entries | `7` |
|
|
75
117
|
|
|
76
118
|
## Authorization
|
|
@@ -144,6 +186,16 @@ Entries are automatically tagged:
|
|
|
144
186
|
- **Exceptions**: `exception`, exception class name
|
|
145
187
|
- **Jobs**: `job`, `enqueue`/`perform`, queue name, `failed`
|
|
146
188
|
|
|
189
|
+
### Rake Tasks
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
# Flush buffered entries from Redis to database (redis mode only)
|
|
193
|
+
rake railscope:flush
|
|
194
|
+
|
|
195
|
+
# Purge expired entries (older than retention_days)
|
|
196
|
+
rake railscope:purge
|
|
197
|
+
```
|
|
198
|
+
|
|
147
199
|
### Purging Old Entries
|
|
148
200
|
|
|
149
201
|
Run the purge job to remove entries older than `retention_days`:
|
|
@@ -212,12 +264,20 @@ Railscope::Entry.expired
|
|
|
212
264
|
|
|
213
265
|
Railscope is designed to have minimal impact:
|
|
214
266
|
|
|
215
|
-
- Events are recorded synchronously but quickly
|
|
216
267
|
- Ignored paths skip all processing
|
|
217
268
|
- Sensitive data filtering is done once before save
|
|
218
269
|
- Purge job removes old entries to control database size
|
|
219
270
|
|
|
271
|
+
**With `:database` backend:**
|
|
272
|
+
- Entries are written synchronously via INSERT during the request
|
|
273
|
+
|
|
274
|
+
**With `:redis` backend:**
|
|
275
|
+
- Writes go to Redis (~0.1ms per entry), near-zero impact on request latency
|
|
276
|
+
- `Entry.insert_all` batches up to 1000 records per flush for efficient persistence
|
|
277
|
+
- Flush is safe to run concurrently (Redis `LPOP` is atomic)
|
|
278
|
+
|
|
220
279
|
For high-traffic production environments, consider:
|
|
280
|
+
- Using `:redis` backend for lower request latency
|
|
221
281
|
- Shorter retention periods
|
|
222
282
|
- Adding high-traffic paths to ignore list
|
|
223
283
|
- Running purge job more frequently
|
|
@@ -4,9 +4,30 @@ module Railscope
|
|
|
4
4
|
class ApplicationRecord < ActiveRecord::Base
|
|
5
5
|
self.abstract_class = true
|
|
6
6
|
|
|
7
|
-
# Support for separate database connection
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
7
|
+
# Support for separate database connection.
|
|
8
|
+
# Activated when a "railscope" database is defined in database.yml
|
|
9
|
+
# or when RAILSCOPE_DATABASE_URL is set.
|
|
10
|
+
#
|
|
11
|
+
# Example database.yml:
|
|
12
|
+
# development:
|
|
13
|
+
# primary:
|
|
14
|
+
# <<: *default
|
|
15
|
+
# database: myapp_development
|
|
16
|
+
# migrations_paths: db/migrate
|
|
17
|
+
# railscope:
|
|
18
|
+
# <<: *default
|
|
19
|
+
# database: myapp_railscope_development
|
|
20
|
+
# migrations_paths: db/railscope_migrate
|
|
21
|
+
#
|
|
22
|
+
def self.railscope_separate_database?
|
|
23
|
+
return true if ENV["RAILSCOPE_DATABASE_URL"].present?
|
|
24
|
+
|
|
25
|
+
configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
|
|
26
|
+
configs.any? { |c| c.name == "railscope" }
|
|
27
|
+
rescue StandardError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
connects_to database: { writing: :railscope, reading: :railscope } if railscope_separate_database?
|
|
11
32
|
end
|
|
12
33
|
end
|
|
@@ -64,14 +64,27 @@ function JsonValue({ value, indent = 0 }: { value: unknown; indent?: number }) {
|
|
|
64
64
|
}
|
|
65
65
|
return (
|
|
66
66
|
<div>
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
67
|
+
<span className="text-dark-muted">{'{'}</span>
|
|
68
|
+
<div className="pl-4">
|
|
69
|
+
{entries.map(([key, val], i) => {
|
|
70
|
+
const isComplex = val !== null && typeof val === 'object'
|
|
71
|
+
return (
|
|
72
|
+
<div key={key}>
|
|
73
|
+
<span className="text-blue-400">"{key}"</span>
|
|
74
|
+
<span className="text-dark-muted mr-1">:</span>
|
|
75
|
+
{isComplex ? (
|
|
76
|
+
<JsonValue value={val} indent={indent + 1} />
|
|
77
|
+
) : (
|
|
78
|
+
<>
|
|
79
|
+
<JsonValue value={val} indent={indent + 1} />
|
|
80
|
+
</>
|
|
81
|
+
)}
|
|
82
|
+
{i < entries.length - 1 && <span className="text-dark-muted">,</span>}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
})}
|
|
86
|
+
</div>
|
|
87
|
+
<span className="text-dark-muted">{'}'}</span>
|
|
75
88
|
</div>
|
|
76
89
|
)
|
|
77
90
|
}
|
|
@@ -20,6 +20,21 @@ Railscope.configure do |config|
|
|
|
20
20
|
#
|
|
21
21
|
# config.enabled = true
|
|
22
22
|
|
|
23
|
+
# Storage Backend
|
|
24
|
+
# ---------------
|
|
25
|
+
# :database - Direct writes to PostgreSQL (simpler, no Redis needed)
|
|
26
|
+
# :redis - Buffer in Redis, batch flush to PostgreSQL (faster requests)
|
|
27
|
+
#
|
|
28
|
+
# When using :redis, entries are buffered in Redis during requests and
|
|
29
|
+
# flushed to PostgreSQL periodically via Railscope::FlushService.
|
|
30
|
+
# You can trigger the flush with:
|
|
31
|
+
# - Railscope::FlushService.call (from a job, cron, etc.)
|
|
32
|
+
# - rake railscope:flush
|
|
33
|
+
#
|
|
34
|
+
# Can also be set via RAILSCOPE_STORAGE env var.
|
|
35
|
+
#
|
|
36
|
+
# config.storage_backend = :database
|
|
37
|
+
|
|
23
38
|
# Retention Period
|
|
24
39
|
# ----------------
|
|
25
40
|
# Number of days to keep entries before purging.
|
data/lib/railscope/engine.rb
CHANGED
|
@@ -29,8 +29,23 @@ module Railscope
|
|
|
29
29
|
|
|
30
30
|
initializer "railscope.migrations" do |app|
|
|
31
31
|
unless app.root.to_s.match?(root.to_s)
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
if railscope_separate_database?
|
|
33
|
+
# Separate database: copy migrations to db/railscope_migrate/
|
|
34
|
+
# so they only run against the railscope database
|
|
35
|
+
target_dir = app.root.join("db", "railscope_migrate")
|
|
36
|
+
config.paths["db/migrate"].expanded.each do |source_dir|
|
|
37
|
+
Dir[File.join(source_dir, "*.rb")].each do |migration|
|
|
38
|
+
target = target_dir.join(File.basename(migration))
|
|
39
|
+
next if target.exist?
|
|
40
|
+
|
|
41
|
+
FileUtils.mkdir_p(target_dir)
|
|
42
|
+
FileUtils.cp(migration, target)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
config.paths["db/migrate"].expanded.each do |expanded_path|
|
|
47
|
+
app.config.paths["db/migrate"] << expanded_path
|
|
48
|
+
end
|
|
34
49
|
end
|
|
35
50
|
end
|
|
36
51
|
end
|
|
@@ -78,11 +93,20 @@ module Railscope
|
|
|
78
93
|
end
|
|
79
94
|
|
|
80
95
|
rake_tasks do
|
|
81
|
-
|
|
96
|
+
load Railscope::Engine.root.join("lib/tasks/railscope.rake")
|
|
82
97
|
load Railscope::Engine.root.join("lib/tasks/railscope_sample.rake")
|
|
83
98
|
|
|
84
99
|
# Subscribe to rake tasks after they're loaded
|
|
85
100
|
Railscope::Subscribers::CommandSubscriber.subscribe
|
|
86
101
|
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def railscope_separate_database?
|
|
106
|
+
configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
|
|
107
|
+
configs.any? { |c| c.name == "railscope" }
|
|
108
|
+
rescue StandardError
|
|
109
|
+
false
|
|
110
|
+
end
|
|
87
111
|
end
|
|
88
112
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
class FlushService
|
|
5
|
+
BUFFER_KEY = "railscope:buffer"
|
|
6
|
+
UPDATES_KEY = "railscope:buffer:updates"
|
|
7
|
+
BATCH_SIZE = 1000
|
|
8
|
+
|
|
9
|
+
def self.call
|
|
10
|
+
new.call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
total = flush_entries
|
|
15
|
+
apply_pending_updates
|
|
16
|
+
total
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def flush_entries
|
|
22
|
+
total = 0
|
|
23
|
+
|
|
24
|
+
loop do
|
|
25
|
+
batch = pop_batch(BUFFER_KEY)
|
|
26
|
+
break if batch.empty?
|
|
27
|
+
|
|
28
|
+
entries = batch.map { |json| JSON.parse(json, symbolize_names: true) }
|
|
29
|
+
batch_insert_to_database(entries)
|
|
30
|
+
total += entries.size
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
total
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def apply_pending_updates
|
|
37
|
+
loop do
|
|
38
|
+
batch = pop_batch(UPDATES_KEY)
|
|
39
|
+
break if batch.empty?
|
|
40
|
+
|
|
41
|
+
batch.each do |json|
|
|
42
|
+
update = JSON.parse(json, symbolize_names: true)
|
|
43
|
+
apply_update(update)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def apply_update(update)
|
|
49
|
+
entry = Entry.where(
|
|
50
|
+
batch_id: update[:batch_id],
|
|
51
|
+
entry_type: update[:entry_type].to_s
|
|
52
|
+
).order(created_at: :desc).first
|
|
53
|
+
return unless entry
|
|
54
|
+
|
|
55
|
+
entry.payload = entry.payload.merge(
|
|
56
|
+
update[:payload_updates].transform_keys(&:to_s)
|
|
57
|
+
)
|
|
58
|
+
entry.save!
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
Rails.logger.debug("[Railscope] Failed to apply buffered update: #{e.message}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def batch_insert_to_database(entries)
|
|
64
|
+
now = Time.current
|
|
65
|
+
|
|
66
|
+
records = entries.map do |entry|
|
|
67
|
+
{
|
|
68
|
+
uuid: entry[:uuid],
|
|
69
|
+
batch_id: entry[:batch_id],
|
|
70
|
+
family_hash: entry[:family_hash],
|
|
71
|
+
entry_type: entry[:entry_type],
|
|
72
|
+
payload: entry[:payload],
|
|
73
|
+
tags: entry[:tags] || [],
|
|
74
|
+
should_display_on_index: entry.fetch(:should_display_on_index, true),
|
|
75
|
+
occurred_at: entry[:occurred_at] || now,
|
|
76
|
+
created_at: entry[:created_at] || now,
|
|
77
|
+
updated_at: entry[:updated_at] || now
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Entry.insert_all(records)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pop_batch(key)
|
|
85
|
+
redis.lpop(key, BATCH_SIZE) || []
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def redis
|
|
89
|
+
Railscope.redis
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/railscope/middleware.rb
CHANGED
|
@@ -80,20 +80,9 @@ module Railscope
|
|
|
80
80
|
end
|
|
81
81
|
session_data = extract_session_from_env(context_data[:env])
|
|
82
82
|
|
|
83
|
-
#
|
|
83
|
+
# Determine response type and handle accordingly (like Telescope)
|
|
84
84
|
content_type = response_headers["Content-Type"] || response_headers["content-type"] || ""
|
|
85
|
-
|
|
86
|
-
is_json = content_type.include?("application/json") || looks_like_json
|
|
87
|
-
|
|
88
|
-
parsed_body = if is_json
|
|
89
|
-
begin
|
|
90
|
-
JSON.parse(response_body)
|
|
91
|
-
rescue StandardError
|
|
92
|
-
response_body
|
|
93
|
-
end
|
|
94
|
-
else
|
|
95
|
-
response_body
|
|
96
|
-
end
|
|
85
|
+
parsed_body = parse_response_body(response_body, content_type, context_data[:env])
|
|
97
86
|
|
|
98
87
|
payload_updates = {
|
|
99
88
|
"response" => parsed_body.presence,
|
|
@@ -101,24 +90,129 @@ module Railscope
|
|
|
101
90
|
"session" => session_data
|
|
102
91
|
}
|
|
103
92
|
|
|
104
|
-
|
|
93
|
+
Railscope.storage.update_by_batch(
|
|
94
|
+
batch_id: context_data[:batch_id],
|
|
95
|
+
entry_type: "request",
|
|
96
|
+
payload_updates: payload_updates
|
|
97
|
+
)
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
Rails.logger.debug("[Railscope] Failed to update entry with response: #{e.message}")
|
|
100
|
+
end
|
|
105
101
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return unless entry
|
|
102
|
+
def self.parse_response_body(response_body, content_type, env)
|
|
103
|
+
body_str = response_body.to_s
|
|
109
104
|
|
|
110
|
-
|
|
111
|
-
|
|
105
|
+
# Empty response
|
|
106
|
+
return "Empty Response" if body_str.blank?
|
|
112
107
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
entry_type: "request",
|
|
117
|
-
payload_updates: payload_updates
|
|
118
|
-
)
|
|
108
|
+
# Redirect
|
|
109
|
+
if env && (location = env["action_dispatch.redirect_url"])
|
|
110
|
+
return "Redirected to #{location}"
|
|
119
111
|
end
|
|
120
|
-
|
|
121
|
-
|
|
112
|
+
|
|
113
|
+
# JSON response
|
|
114
|
+
if content_type.include?("application/json") || body_str.match?(/\A\s*[\[{]/)
|
|
115
|
+
begin
|
|
116
|
+
return JSON.parse(body_str)
|
|
117
|
+
rescue StandardError
|
|
118
|
+
return body_str.truncate(2000)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Plain text response
|
|
123
|
+
if content_type.include?("text/plain")
|
|
124
|
+
return body_str.truncate(2000)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# HTML view response — extract template path and data like Telescope
|
|
128
|
+
extract_view_response(env) || "HTML Response"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.extract_view_response(env)
|
|
132
|
+
return nil unless env
|
|
133
|
+
|
|
134
|
+
controller = env["action_controller.instance"]
|
|
135
|
+
return nil unless controller
|
|
136
|
+
|
|
137
|
+
view_path = resolve_view_path(controller)
|
|
138
|
+
return nil unless view_path
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
"view" => view_path,
|
|
142
|
+
"data" => extract_controller_data(controller)
|
|
143
|
+
}
|
|
144
|
+
rescue StandardError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.resolve_view_path(controller)
|
|
149
|
+
# Try to get the actual rendered template from the controller
|
|
150
|
+
if controller.respond_to?(:rendered_format, true) || controller.respond_to?(:controller_path)
|
|
151
|
+
template_path = "app/views/#{controller.controller_path}/#{controller.action_name}"
|
|
152
|
+
|
|
153
|
+
# Try to find the actual file with extension
|
|
154
|
+
if defined?(Rails.root)
|
|
155
|
+
candidates = Dir.glob(Rails.root.join("#{template_path}.*"))
|
|
156
|
+
return candidates.first&.sub("#{Rails.root}/", "") if candidates.any?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
template_path
|
|
160
|
+
end
|
|
161
|
+
rescue StandardError
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.extract_controller_data(controller)
|
|
166
|
+
data = {}
|
|
167
|
+
|
|
168
|
+
controller.instance_variables.each do |ivar|
|
|
169
|
+
name = ivar.to_s.delete_prefix("@")
|
|
170
|
+
|
|
171
|
+
# Skip internal Rails/controller variables
|
|
172
|
+
next if name.start_with?("_")
|
|
173
|
+
next if IGNORED_INSTANCE_VARS.include?(name)
|
|
174
|
+
|
|
175
|
+
value = controller.instance_variable_get(ivar)
|
|
176
|
+
data[name] = safe_serialize(value)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
data.presence || {}
|
|
180
|
+
rescue StandardError
|
|
181
|
+
{}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
IGNORED_INSTANCE_VARS = %w[
|
|
185
|
+
request response marked_for_same_origin_verification
|
|
186
|
+
performed_redirect action_has_layout lookup_context
|
|
187
|
+
view_context_class current_renderer view_renderer
|
|
188
|
+
action_name pressed_key action_status response_body
|
|
189
|
+
].freeze
|
|
190
|
+
|
|
191
|
+
def self.safe_serialize(value, depth: 0)
|
|
192
|
+
return "..." if depth > 5
|
|
193
|
+
|
|
194
|
+
case value
|
|
195
|
+
when String, Numeric, TrueClass, FalseClass, NilClass
|
|
196
|
+
value
|
|
197
|
+
when Symbol
|
|
198
|
+
value.to_s
|
|
199
|
+
when Time, DateTime
|
|
200
|
+
value.iso8601
|
|
201
|
+
when Date
|
|
202
|
+
value.to_s
|
|
203
|
+
when ActiveRecord::Relation
|
|
204
|
+
value.limit(50).map { |record| safe_serialize(record, depth: depth + 1) }
|
|
205
|
+
when ActiveRecord::Base
|
|
206
|
+
Railscope.filter(value.attributes.transform_values { |v| safe_serialize(v, depth: depth + 1) })
|
|
207
|
+
when Array
|
|
208
|
+
value.first(50).map { |v| safe_serialize(v, depth: depth + 1) }
|
|
209
|
+
when Hash
|
|
210
|
+
value.transform_values { |v| safe_serialize(v, depth: depth + 1) }
|
|
211
|
+
else
|
|
212
|
+
value.to_s
|
|
213
|
+
end
|
|
214
|
+
rescue StandardError
|
|
215
|
+
value.class.name
|
|
122
216
|
end
|
|
123
217
|
|
|
124
218
|
def self.extract_session_from_env(env)
|
|
@@ -10,6 +10,15 @@ module Railscope
|
|
|
10
10
|
EntryData.from_active_record(record)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
def update_by_batch(batch_id:, entry_type:, payload_updates:)
|
|
14
|
+
entry = Entry.where(batch_id: batch_id, entry_type: entry_type).order(created_at: :desc).first
|
|
15
|
+
return nil unless entry
|
|
16
|
+
|
|
17
|
+
entry.payload = entry.payload.merge(payload_updates)
|
|
18
|
+
entry.save!
|
|
19
|
+
EntryData.from_active_record(entry)
|
|
20
|
+
end
|
|
21
|
+
|
|
13
22
|
def find(uuid)
|
|
14
23
|
record = Entry.find_by(uuid: uuid)
|
|
15
24
|
return nil unless record
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
module Storage
|
|
5
|
+
class RedisBuffer < Base
|
|
6
|
+
BUFFER_KEY = "railscope:buffer"
|
|
7
|
+
UPDATES_KEY = "railscope:buffer:updates"
|
|
8
|
+
BUFFER_TTL = 4.hours.to_i
|
|
9
|
+
|
|
10
|
+
# WRITE → Redis (fast, ~0.1ms)
|
|
11
|
+
def write(attributes)
|
|
12
|
+
entry = build_entry(attributes)
|
|
13
|
+
redis.rpush(BUFFER_KEY, entry.to_json)
|
|
14
|
+
redis.expire(BUFFER_KEY, BUFFER_TTL)
|
|
15
|
+
entry
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# UPDATE → Redis buffer (for response data from middleware)
|
|
19
|
+
def update_by_batch(batch_id:, entry_type:, payload_updates:)
|
|
20
|
+
update = {
|
|
21
|
+
batch_id: batch_id,
|
|
22
|
+
entry_type: entry_type,
|
|
23
|
+
payload_updates: payload_updates
|
|
24
|
+
}
|
|
25
|
+
redis.rpush(UPDATES_KEY, update.to_json)
|
|
26
|
+
redis.expire(UPDATES_KEY, BUFFER_TTL)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# READ → Database (source of truth)
|
|
30
|
+
def find(uuid)
|
|
31
|
+
database_adapter.find(uuid)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def all(filters: {}, page: 1, per_page: 25, displayable_only: true)
|
|
35
|
+
database_adapter.all(filters: filters, page: page, per_page: per_page, displayable_only: displayable_only)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def count(filters: {}, displayable_only: true)
|
|
39
|
+
database_adapter.count(filters: filters, displayable_only: displayable_only)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def for_batch(batch_id)
|
|
43
|
+
database_adapter.for_batch(batch_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def for_family(family_hash, page: 1, per_page: 25)
|
|
47
|
+
database_adapter.for_family(family_hash, page: page, per_page: per_page)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def family_count(family_hash)
|
|
51
|
+
database_adapter.family_count(family_hash)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def destroy_all!
|
|
55
|
+
redis.del(BUFFER_KEY, UPDATES_KEY)
|
|
56
|
+
database_adapter.destroy_all!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def destroy_expired!
|
|
60
|
+
database_adapter.destroy_expired!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# READY → needs both Redis AND database
|
|
64
|
+
def ready?
|
|
65
|
+
Railscope.redis_available? && database_adapter.ready?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def redis
|
|
71
|
+
Railscope.redis
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def database_adapter
|
|
75
|
+
@database_adapter ||= Database.new
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_entry(attributes)
|
|
79
|
+
now = Time.current
|
|
80
|
+
EntryData.new(
|
|
81
|
+
uuid: attributes[:uuid] || SecureRandom.uuid,
|
|
82
|
+
batch_id: attributes[:batch_id],
|
|
83
|
+
family_hash: attributes[:family_hash],
|
|
84
|
+
entry_type: attributes[:entry_type],
|
|
85
|
+
payload: attributes[:payload] || {},
|
|
86
|
+
tags: attributes[:tags] || [],
|
|
87
|
+
should_display_on_index: attributes.fetch(:should_display_on_index, true),
|
|
88
|
+
occurred_at: attributes[:occurred_at] || now,
|
|
89
|
+
created_at: attributes[:created_at] || now,
|
|
90
|
+
updated_at: attributes[:updated_at] || now
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/railscope/version.rb
CHANGED
data/lib/railscope.rb
CHANGED
|
@@ -6,7 +6,8 @@ require_relative "railscope/filter"
|
|
|
6
6
|
require_relative "railscope/entry_data"
|
|
7
7
|
require_relative "railscope/storage/base"
|
|
8
8
|
require_relative "railscope/storage/database"
|
|
9
|
-
require_relative "railscope/storage/
|
|
9
|
+
require_relative "railscope/storage/redis_buffer"
|
|
10
|
+
require_relative "railscope/flush_service"
|
|
10
11
|
require_relative "railscope/middleware"
|
|
11
12
|
require_relative "railscope/engine"
|
|
12
13
|
|
|
@@ -57,7 +58,7 @@ module Railscope
|
|
|
57
58
|
def storage
|
|
58
59
|
@storage ||= case storage_backend
|
|
59
60
|
when STORAGE_REDIS
|
|
60
|
-
Storage::
|
|
61
|
+
Storage::RedisBuffer.new
|
|
61
62
|
else
|
|
62
63
|
Storage::Database.new
|
|
63
64
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :railscope do
|
|
4
|
+
desc "Flush buffered entries from Redis to database"
|
|
5
|
+
task flush: :environment do
|
|
6
|
+
count = Railscope::FlushService.call
|
|
7
|
+
puts "Flushed #{count} entries"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
desc "Purge expired entries"
|
|
11
|
+
task purge: :environment do
|
|
12
|
+
count = Railscope.storage.destroy_expired!
|
|
13
|
+
puts "Purged #{count} entries"
|
|
14
|
+
end
|
|
15
|
+
end
|