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,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "subscribers/base_subscriber"
|
|
4
|
+
require_relative "subscribers/request_subscriber"
|
|
5
|
+
require_relative "subscribers/query_subscriber"
|
|
6
|
+
require_relative "subscribers/exception_subscriber"
|
|
7
|
+
require_relative "subscribers/job_subscriber"
|
|
8
|
+
require_relative "subscribers/command_subscriber"
|
|
9
|
+
require_relative "subscribers/view_subscriber"
|
|
10
|
+
|
|
11
|
+
module Railscope
|
|
12
|
+
class Engine < ::Rails::Engine
|
|
13
|
+
isolate_namespace Railscope
|
|
14
|
+
|
|
15
|
+
initializer "railscope.static_assets" do |app|
|
|
16
|
+
# Serve static assets from public/railscope
|
|
17
|
+
app.middleware.insert_before(
|
|
18
|
+
ActionDispatch::Static,
|
|
19
|
+
Rack::Static,
|
|
20
|
+
urls: ["/railscope/assets"],
|
|
21
|
+
root: Railscope::Engine.root.join("public").to_s
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
initializer "railscope.middleware" do |app|
|
|
26
|
+
app.middleware.insert_before(0, Railscope::Middleware)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
initializer "railscope.migrations" do |app|
|
|
30
|
+
unless app.root.to_s.match?(root.to_s)
|
|
31
|
+
config.paths["db/migrate"].expanded.each do |expanded_path|
|
|
32
|
+
app.config.paths["db/migrate"] << expanded_path
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
initializer "railscope.subscribers", after: :load_config_initializers do
|
|
38
|
+
# ActionController subscribers
|
|
39
|
+
if defined?(ActionController::Base)
|
|
40
|
+
Railscope::Subscribers::RequestSubscriber.subscribe
|
|
41
|
+
Railscope::Subscribers::ExceptionSubscriber.subscribe
|
|
42
|
+
else
|
|
43
|
+
ActiveSupport.on_load(:action_controller) do
|
|
44
|
+
Railscope::Subscribers::RequestSubscriber.subscribe
|
|
45
|
+
Railscope::Subscribers::ExceptionSubscriber.subscribe
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ActionView subscribers
|
|
50
|
+
if defined?(ActionView::Base)
|
|
51
|
+
Railscope::Subscribers::ViewSubscriber.subscribe
|
|
52
|
+
else
|
|
53
|
+
ActiveSupport.on_load(:action_view) do
|
|
54
|
+
Railscope::Subscribers::ViewSubscriber.subscribe
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ActiveRecord subscribers
|
|
59
|
+
if defined?(ActiveRecord::Base)
|
|
60
|
+
Railscope::Subscribers::QuerySubscriber.subscribe
|
|
61
|
+
else
|
|
62
|
+
ActiveSupport.on_load(:active_record) do
|
|
63
|
+
Railscope::Subscribers::QuerySubscriber.subscribe
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ActiveJob subscribers
|
|
68
|
+
if defined?(ActiveJob::Base)
|
|
69
|
+
Railscope::Subscribers::JobSubscriber.subscribe
|
|
70
|
+
else
|
|
71
|
+
ActiveSupport.on_load(:active_job) do
|
|
72
|
+
Railscope::Subscribers::JobSubscriber.subscribe
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
rake_tasks do
|
|
78
|
+
# Load sample tasks for testing
|
|
79
|
+
load Railscope::Engine.root.join("lib/tasks/railscope_sample.rake")
|
|
80
|
+
|
|
81
|
+
# Subscribe to rake tasks after they're loaded
|
|
82
|
+
Railscope::Subscribers::CommandSubscriber.subscribe
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
# Plain Ruby object representing an entry, used by both storage adapters
|
|
5
|
+
# This provides a consistent interface regardless of storage backend
|
|
6
|
+
class EntryData
|
|
7
|
+
ATTRIBUTES = %i[
|
|
8
|
+
uuid
|
|
9
|
+
batch_id
|
|
10
|
+
family_hash
|
|
11
|
+
entry_type
|
|
12
|
+
payload
|
|
13
|
+
tags
|
|
14
|
+
should_display_on_index
|
|
15
|
+
occurred_at
|
|
16
|
+
created_at
|
|
17
|
+
updated_at
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
attr_accessor(*ATTRIBUTES)
|
|
21
|
+
|
|
22
|
+
# Alias id to uuid for compatibility with existing code
|
|
23
|
+
alias id uuid
|
|
24
|
+
|
|
25
|
+
def initialize(attributes = {})
|
|
26
|
+
attributes = attributes.symbolize_keys if attributes.respond_to?(:symbolize_keys)
|
|
27
|
+
|
|
28
|
+
ATTRIBUTES.each do |attr|
|
|
29
|
+
value = attributes[attr]
|
|
30
|
+
# Handle time parsing for string values
|
|
31
|
+
if %i[occurred_at created_at updated_at].include?(attr) && value.is_a?(String)
|
|
32
|
+
value = begin
|
|
33
|
+
Time.zone.parse(value)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
send("#{attr}=", value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Set defaults
|
|
42
|
+
self.tags ||= []
|
|
43
|
+
self.payload ||= {}
|
|
44
|
+
self.should_display_on_index = true if should_display_on_index.nil?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
ATTRIBUTES.index_with do |attr|
|
|
49
|
+
send(attr)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_json(*args)
|
|
54
|
+
serializable_hash.to_json(*args)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def serializable_hash
|
|
58
|
+
to_h.transform_values do |value|
|
|
59
|
+
case value
|
|
60
|
+
when Time, DateTime, ActiveSupport::TimeWithZone
|
|
61
|
+
value.iso8601(6)
|
|
62
|
+
else
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def displayable?
|
|
69
|
+
should_display_on_index
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# For compatibility with ActiveRecord-style access
|
|
73
|
+
def [](key)
|
|
74
|
+
send(key) if respond_to?(key)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def []=(key, value)
|
|
78
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Comparison for sorting
|
|
82
|
+
def <=>(other)
|
|
83
|
+
return nil unless other.is_a?(EntryData)
|
|
84
|
+
|
|
85
|
+
other.occurred_at <=> occurred_at # desc by default
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class << self
|
|
89
|
+
# Create from ActiveRecord Entry model
|
|
90
|
+
def from_active_record(record)
|
|
91
|
+
new(
|
|
92
|
+
uuid: record.uuid,
|
|
93
|
+
batch_id: record.batch_id,
|
|
94
|
+
family_hash: record.family_hash,
|
|
95
|
+
entry_type: record.entry_type,
|
|
96
|
+
payload: record.payload,
|
|
97
|
+
tags: record.tags,
|
|
98
|
+
should_display_on_index: record.should_display_on_index,
|
|
99
|
+
occurred_at: record.occurred_at,
|
|
100
|
+
created_at: record.created_at,
|
|
101
|
+
updated_at: record.updated_at
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Create from JSON hash (Redis)
|
|
106
|
+
def from_json(json_string)
|
|
107
|
+
data = JSON.parse(json_string, symbolize_names: true)
|
|
108
|
+
new(data)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
class Filter
|
|
5
|
+
MASK = "[FILTERED]"
|
|
6
|
+
|
|
7
|
+
DEFAULT_SENSITIVE_KEYS = %w[
|
|
8
|
+
password
|
|
9
|
+
password_confirmation
|
|
10
|
+
secret
|
|
11
|
+
token
|
|
12
|
+
api_key
|
|
13
|
+
apikey
|
|
14
|
+
access_token
|
|
15
|
+
refresh_token
|
|
16
|
+
authorization
|
|
17
|
+
auth
|
|
18
|
+
credential
|
|
19
|
+
private_key
|
|
20
|
+
secret_key
|
|
21
|
+
credit_card
|
|
22
|
+
card_number
|
|
23
|
+
cvv
|
|
24
|
+
ssn
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def filter(payload)
|
|
29
|
+
return payload unless payload.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
filter_hash(payload.deep_dup)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sensitive_keys
|
|
35
|
+
@sensitive_keys ||= build_sensitive_keys
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_sensitive_keys(*keys)
|
|
39
|
+
@sensitive_keys = nil
|
|
40
|
+
@custom_keys ||= []
|
|
41
|
+
@custom_keys.concat(keys.map(&:to_s).map(&:downcase))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset_sensitive_keys!
|
|
45
|
+
@sensitive_keys = nil
|
|
46
|
+
@custom_keys = []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_sensitive_keys
|
|
52
|
+
keys = DEFAULT_SENSITIVE_KEYS.dup
|
|
53
|
+
keys.concat(rails_filter_parameters)
|
|
54
|
+
keys.concat(@custom_keys || [])
|
|
55
|
+
keys.uniq
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def rails_filter_parameters
|
|
59
|
+
return [] unless defined?(Rails) && Rails.application
|
|
60
|
+
|
|
61
|
+
Rails.application.config.filter_parameters.map do |param|
|
|
62
|
+
param.is_a?(Regexp) ? nil : param.to_s.downcase
|
|
63
|
+
end.compact
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def filter_hash(hash)
|
|
67
|
+
hash.each do |key, value|
|
|
68
|
+
if sensitive_key?(key)
|
|
69
|
+
hash[key] = MASK
|
|
70
|
+
elsif value.is_a?(Hash)
|
|
71
|
+
hash[key] = filter_hash(value)
|
|
72
|
+
elsif value.is_a?(Array)
|
|
73
|
+
hash[key] = filter_array(value)
|
|
74
|
+
elsif value.is_a?(String) && looks_like_secret?(value)
|
|
75
|
+
hash[key] = MASK
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
hash
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def filter_array(array)
|
|
82
|
+
array.map do |item|
|
|
83
|
+
case item
|
|
84
|
+
when Hash
|
|
85
|
+
filter_hash(item)
|
|
86
|
+
when Array
|
|
87
|
+
filter_array(item)
|
|
88
|
+
else
|
|
89
|
+
item
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def sensitive_key?(key)
|
|
95
|
+
key_str = key.to_s.downcase
|
|
96
|
+
sensitive_keys.any? { |sensitive| key_str.include?(sensitive) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def looks_like_secret?(value)
|
|
100
|
+
return false if value.length < 20
|
|
101
|
+
|
|
102
|
+
# Bearer tokens
|
|
103
|
+
return true if value.start_with?("Bearer ")
|
|
104
|
+
# Base64 encoded secrets (common pattern)
|
|
105
|
+
return true if value.match?(%r{\A[A-Za-z0-9+/=]{40,}\z})
|
|
106
|
+
# JWT tokens
|
|
107
|
+
return true if value.match?(/\Aey[A-Za-z0-9_-]+\.ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\z/)
|
|
108
|
+
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
class Middleware
|
|
5
|
+
# Maximum size for response body capture (64KB like Telescope)
|
|
6
|
+
RESPONSE_SIZE_LIMIT = 64 * 1024
|
|
7
|
+
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
return @app.call(env) unless Railscope.enabled?
|
|
14
|
+
|
|
15
|
+
setup_context(env)
|
|
16
|
+
status, headers, response = @app.call(env)
|
|
17
|
+
|
|
18
|
+
# Capture response body for recording
|
|
19
|
+
context = Context.current
|
|
20
|
+
if context[:recording]
|
|
21
|
+
# Read body from env (where Rails stores the response)
|
|
22
|
+
body_content = extract_body_from_env(env)
|
|
23
|
+
|
|
24
|
+
context_data = {
|
|
25
|
+
batch_id: context.batch_id,
|
|
26
|
+
env: env,
|
|
27
|
+
headers: headers
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Update entry with response data
|
|
31
|
+
update_entry_async(context_data, body_content)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
[status, headers, response]
|
|
35
|
+
ensure
|
|
36
|
+
Context.clear!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def extract_body_from_env(env)
|
|
40
|
+
body = ""
|
|
41
|
+
|
|
42
|
+
# Try to get from ActionDispatch::Response stored in env
|
|
43
|
+
if env["action_dispatch.response"]
|
|
44
|
+
response = env["action_dispatch.response"]
|
|
45
|
+
body = response.body.to_s if response.respond_to?(:body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Try action_controller.instance
|
|
49
|
+
if body.empty? && env["action_controller.instance"]
|
|
50
|
+
controller = env["action_controller.instance"]
|
|
51
|
+
if controller.respond_to?(:response) && controller.response.respond_to?(:body)
|
|
52
|
+
body = controller.response.body.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Truncate if too large
|
|
57
|
+
body = body.byteslice(0, RESPONSE_SIZE_LIMIT) if body.bytesize > RESPONSE_SIZE_LIMIT
|
|
58
|
+
|
|
59
|
+
body
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
Rails.logger.debug("[Railscope] Failed to extract body: #{e.message}")
|
|
62
|
+
""
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def update_entry_async(context_data, body_content)
|
|
66
|
+
# Update synchronously for now (could be made async later)
|
|
67
|
+
self.class.update_entry_with_response(context_data, body_content)
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Rails.logger.debug("[Railscope] Failed to update entry: #{e.message}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.update_entry_with_response(context_data, response_body)
|
|
73
|
+
return unless Railscope.ready?
|
|
74
|
+
|
|
75
|
+
headers = context_data[:headers]
|
|
76
|
+
response_headers = begin
|
|
77
|
+
headers.respond_to?(:to_h) ? headers.to_h : headers.to_hash
|
|
78
|
+
rescue StandardError
|
|
79
|
+
{}
|
|
80
|
+
end
|
|
81
|
+
session_data = extract_session_from_env(context_data[:env])
|
|
82
|
+
|
|
83
|
+
# Parse JSON if applicable
|
|
84
|
+
content_type = response_headers["Content-Type"] || response_headers["content-type"] || ""
|
|
85
|
+
looks_like_json = response_body.to_s.start_with?("{", "[")
|
|
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
|
|
97
|
+
|
|
98
|
+
payload_updates = {
|
|
99
|
+
"response" => parsed_body.presence,
|
|
100
|
+
"response_headers" => response_headers,
|
|
101
|
+
"session" => session_data
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
storage = Railscope.storage
|
|
105
|
+
|
|
106
|
+
if storage.is_a?(Railscope::Storage::Database)
|
|
107
|
+
entry = Entry.where(batch_id: context_data[:batch_id], entry_type: "request").order(created_at: :desc).first
|
|
108
|
+
return unless entry
|
|
109
|
+
|
|
110
|
+
entry.payload = entry.payload.merge(payload_updates)
|
|
111
|
+
entry.save!
|
|
112
|
+
|
|
113
|
+
elsif storage.respond_to?(:update_by_batch)
|
|
114
|
+
storage.update_by_batch(
|
|
115
|
+
batch_id: context_data[:batch_id],
|
|
116
|
+
entry_type: "request",
|
|
117
|
+
payload_updates: payload_updates
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
Rails.logger.debug("[Railscope] Failed to update entry with response: #{e.message}")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.extract_session_from_env(env)
|
|
125
|
+
return {} unless env
|
|
126
|
+
|
|
127
|
+
session = env["rack.session"] || env["action_dispatch.request.session"]
|
|
128
|
+
return {} unless session
|
|
129
|
+
|
|
130
|
+
session.to_h.transform_keys(&:to_s).except("_csrf_token", "session_id")
|
|
131
|
+
rescue StandardError
|
|
132
|
+
{}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def setup_context(env)
|
|
138
|
+
path = env["PATH_INFO"]
|
|
139
|
+
context = Context.current
|
|
140
|
+
|
|
141
|
+
# Generate a new batch_id for this request
|
|
142
|
+
context.batch_id = SecureRandom.uuid
|
|
143
|
+
|
|
144
|
+
# Use Rails request_id if available, otherwise use batch_id
|
|
145
|
+
context.request_id = env["action_dispatch.request_id"] || context.batch_id
|
|
146
|
+
|
|
147
|
+
context[:path] = path
|
|
148
|
+
context[:method] = env["REQUEST_METHOD"]
|
|
149
|
+
context[:ip_address] = extract_ip(env)
|
|
150
|
+
context[:recording] = Railscope.should_record?(path: path)
|
|
151
|
+
|
|
152
|
+
# Store env reference to capture session later
|
|
153
|
+
context[:env] = env
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def extract_ip(env)
|
|
157
|
+
env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first&.strip ||
|
|
158
|
+
env["HTTP_X_REAL_IP"] ||
|
|
159
|
+
env["REMOTE_ADDR"]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
module Storage
|
|
5
|
+
class Base
|
|
6
|
+
# Write a new entry
|
|
7
|
+
# @param attributes [Hash] entry attributes
|
|
8
|
+
# @return [EntryData] the created entry
|
|
9
|
+
def write(attributes)
|
|
10
|
+
raise NotImplementedError, "#{self.class}#write must be implemented"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Find an entry by UUID
|
|
14
|
+
# @param uuid [String] the entry UUID
|
|
15
|
+
# @return [EntryData, nil] the entry or nil if not found
|
|
16
|
+
def find(uuid)
|
|
17
|
+
raise NotImplementedError, "#{self.class}#find must be implemented"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Find an entry by UUID, raising if not found
|
|
21
|
+
# @param uuid [String] the entry UUID
|
|
22
|
+
# @return [EntryData] the entry
|
|
23
|
+
# @raise [RecordNotFound] if entry not found
|
|
24
|
+
def find!(uuid)
|
|
25
|
+
find(uuid) || raise(RecordNotFound, "Entry not found: #{uuid}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# List entries with optional filters
|
|
29
|
+
# @param filters [Hash] optional filters (:type, :tag, :batch_id, :family_hash)
|
|
30
|
+
# @param page [Integer] page number (1-indexed)
|
|
31
|
+
# @param per_page [Integer] entries per page
|
|
32
|
+
# @param displayable_only [Boolean] only return displayable entries
|
|
33
|
+
# @return [Array<EntryData>] list of entries
|
|
34
|
+
def all(filters: {}, page: 1, per_page: 25, displayable_only: true)
|
|
35
|
+
raise NotImplementedError, "#{self.class}#all must be implemented"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Count entries with optional filters
|
|
39
|
+
# @param filters [Hash] optional filters
|
|
40
|
+
# @param displayable_only [Boolean] only count displayable entries
|
|
41
|
+
# @return [Integer] count of entries
|
|
42
|
+
def count(filters: {}, displayable_only: true)
|
|
43
|
+
raise NotImplementedError, "#{self.class}#count must be implemented"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Get all entries in a batch
|
|
47
|
+
# @param batch_id [String] the batch UUID
|
|
48
|
+
# @return [Array<EntryData>] list of entries in the batch
|
|
49
|
+
def for_batch(batch_id)
|
|
50
|
+
raise NotImplementedError, "#{self.class}#for_batch must be implemented"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Get all entries with the same family hash
|
|
54
|
+
# @param family_hash [String] the family hash
|
|
55
|
+
# @param page [Integer] page number
|
|
56
|
+
# @param per_page [Integer] entries per page
|
|
57
|
+
# @return [Array<EntryData>] list of entries with same family
|
|
58
|
+
def for_family(family_hash, page: 1, per_page: 25)
|
|
59
|
+
raise NotImplementedError, "#{self.class}#for_family must be implemented"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Count entries with the same family hash
|
|
63
|
+
# @param family_hash [String] the family hash
|
|
64
|
+
# @return [Integer] count of entries
|
|
65
|
+
def family_count(family_hash)
|
|
66
|
+
raise NotImplementedError, "#{self.class}#family_count must be implemented"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Delete all entries
|
|
70
|
+
# @return [Integer] number of deleted entries
|
|
71
|
+
def destroy_all!
|
|
72
|
+
raise NotImplementedError, "#{self.class}#destroy_all! must be implemented"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Delete expired entries (older than retention_days)
|
|
76
|
+
# @return [Integer] number of deleted entries
|
|
77
|
+
def destroy_expired!
|
|
78
|
+
raise NotImplementedError, "#{self.class}#destroy_expired! must be implemented"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check if storage is available and ready
|
|
82
|
+
# @return [Boolean] true if ready
|
|
83
|
+
def ready?
|
|
84
|
+
raise NotImplementedError, "#{self.class}#ready? must be implemented"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class RecordNotFound < StandardError; end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
module Storage
|
|
5
|
+
class Database < Base
|
|
6
|
+
def write(attributes)
|
|
7
|
+
# Remove uuid if present - let the database generate it
|
|
8
|
+
attrs = attributes.except(:uuid, :created_at, :updated_at)
|
|
9
|
+
record = Entry.create!(attrs)
|
|
10
|
+
EntryData.from_active_record(record)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def find(uuid)
|
|
14
|
+
record = Entry.find_by(uuid: uuid)
|
|
15
|
+
return nil unless record
|
|
16
|
+
|
|
17
|
+
EntryData.from_active_record(record)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def all(filters: {}, page: 1, per_page: 25, displayable_only: true)
|
|
21
|
+
scope = build_scope(filters, displayable_only)
|
|
22
|
+
records = scope.recent.limit(per_page).offset((page - 1) * per_page)
|
|
23
|
+
records.map { |r| EntryData.from_active_record(r) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def count(filters: {}, displayable_only: true)
|
|
27
|
+
build_scope(filters, displayable_only).count
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def for_batch(batch_id)
|
|
31
|
+
records = Entry.for_batch(batch_id).order(:occurred_at)
|
|
32
|
+
records.map { |r| EntryData.from_active_record(r) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def for_family(family_hash, page: 1, per_page: 25)
|
|
36
|
+
records = Entry.for_family(family_hash).recent.limit(per_page).offset((page - 1) * per_page)
|
|
37
|
+
records.map { |r| EntryData.from_active_record(r) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def family_count(family_hash)
|
|
41
|
+
Entry.for_family(family_hash).count
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def destroy_all!
|
|
45
|
+
Entry.delete_all
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def destroy_expired!
|
|
49
|
+
total_deleted = 0
|
|
50
|
+
batch_size = 1000
|
|
51
|
+
|
|
52
|
+
loop do
|
|
53
|
+
expired_ids = Entry.expired.limit(batch_size).pluck(:uuid)
|
|
54
|
+
break if expired_ids.empty?
|
|
55
|
+
|
|
56
|
+
deleted = Entry.where(uuid: expired_ids).delete_all
|
|
57
|
+
total_deleted += deleted
|
|
58
|
+
break if deleted < batch_size
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
total_deleted
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def ready?
|
|
65
|
+
Entry.table_exists?
|
|
66
|
+
rescue StandardError
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def build_scope(filters, displayable_only)
|
|
73
|
+
scope = Entry.all
|
|
74
|
+
scope = scope.displayable if displayable_only
|
|
75
|
+
scope = scope.by_type(filters[:type]) if filters[:type].present?
|
|
76
|
+
scope = scope.with_tag(filters[:tag]) if filters[:tag].present?
|
|
77
|
+
scope = scope.for_batch(filters[:batch_id]) if filters[:batch_id].present?
|
|
78
|
+
scope = scope.for_family(filters[:family_hash]) if filters[:family_hash].present?
|
|
79
|
+
scope
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|