Class: Railscope::Middleware

Inherits:
Object
  • Object
show all
Defined in:
lib/railscope/middleware.rb

Constant Summary collapse

RESPONSE_SIZE_LIMIT =

Maximum size for response body capture (64KB like Telescope)

64 * 1024
IGNORED_INSTANCE_VARS =
%w[
  request response marked_for_same_origin_verification
  performed_redirect action_has_layout lookup_context
  view_context_class current_renderer view_renderer
  action_name pressed_key action_status response_body
].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app) ⇒ Middleware

Returns a new instance of Middleware.



8
9
10
# File 'lib/railscope/middleware.rb', line 8

def initialize(app)
  @app = app
end

Class Method Details

.extract_controller_data(controller) ⇒ Object



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/railscope/middleware.rb', line 168

def self.extract_controller_data(controller)
  data = {}

  controller.instance_variables.each do |ivar|
    name = ivar.to_s.delete_prefix("@")

    # Skip internal Rails/controller variables
    next if name.start_with?("_")
    next if IGNORED_INSTANCE_VARS.include?(name)

    value = controller.instance_variable_get(ivar)
    data[name] = safe_serialize(value)
  end

  data.presence || {}
rescue StandardError
  {}
end

.extract_session_from_env(env) ⇒ Object



221
222
223
224
225
226
227
228
229
230
# File 'lib/railscope/middleware.rb', line 221

def self.extract_session_from_env(env)
  return {} unless env

  session = env["rack.session"] || env["action_dispatch.request.session"]
  return {} unless session

  session.to_h.transform_keys(&:to_s).except("_csrf_token", "session_id")
rescue StandardError
  {}
end

.extract_view_response(env) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/railscope/middleware.rb', line 134

def self.extract_view_response(env)
  return nil unless env

  controller = env["action_controller.instance"]
  return nil unless controller

  view_path = resolve_view_path(controller)
  return nil unless view_path

  {
    "view" => view_path,
    "data" => extract_controller_data(controller)
  }
rescue StandardError
  nil
end

.parse_response_body(response_body, content_type, env) ⇒ Object



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/railscope/middleware.rb', line 105

def self.parse_response_body(response_body, content_type, env)
  body_str = response_body.to_s

  # Empty response
  return "Empty Response" if body_str.blank?

  # Redirect
  if env && (location = env["action_dispatch.redirect_url"])
    return "Redirected to #{location}"
  end

  # JSON response
  if content_type.include?("application/json") || body_str.match?(/\A\s*[\[{]/)
    begin
      return JSON.parse(body_str)
    rescue StandardError
      return body_str.truncate(2000)
    end
  end

  # Plain text response
  if content_type.include?("text/plain")
    return body_str.truncate(2000)
  end

  # HTML view response — extract template path and data like Telescope
  extract_view_response(env) || "HTML Response"
end

.resolve_view_path(controller) ⇒ Object



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/railscope/middleware.rb', line 151

def self.resolve_view_path(controller)
  # Try to get the actual rendered template from the controller
  if controller.respond_to?(:rendered_format, true) || controller.respond_to?(:controller_path)
    template_path = "app/views/#{controller.controller_path}/#{controller.action_name}"

    # Try to find the actual file with extension
    if defined?(Rails.root)
      candidates = Dir.glob(Rails.root.join("#{template_path}.*"))
      return candidates.first&.sub("#{Rails.root}/", "") if candidates.any?
    end

    template_path
  end
rescue StandardError
  nil
end

.safe_serialize(value, depth: 0) ⇒ Object



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/railscope/middleware.rb', line 194

def self.safe_serialize(value, depth: 0)
  return "..." if depth > 5

  case value
  when String, Numeric, TrueClass, FalseClass, NilClass
    value
  when Symbol
    value.to_s
  when Time, DateTime
    value.iso8601
  when Date
    value.to_s
  when ActiveRecord::Relation
    value.limit(50).map { |record| safe_serialize(record, depth: depth + 1) }
  when ActiveRecord::Base
    Railscope.filter(value.attributes.transform_values { |v| safe_serialize(v, depth: depth + 1) })
  when Array
    value.first(50).map { |v| safe_serialize(v, depth: depth + 1) }
  when Hash
    value.transform_values { |v| safe_serialize(v, depth: depth + 1) }
  else
    value.to_s
  end
rescue StandardError
  value.class.name
end

.update_entry_with_response(context_data, response_body) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/railscope/middleware.rb', line 75

def self.update_entry_with_response(context_data, response_body)
  return unless Railscope.ready?

  headers = context_data[:headers]
  response_headers = begin
    headers.respond_to?(:to_h) ? headers.to_h : headers.to_hash
  rescue StandardError
    {}
  end
  session_data = extract_session_from_env(context_data[:env])

  # Determine response type and handle accordingly (like Telescope)
  content_type = response_headers["Content-Type"] || response_headers["content-type"] || ""
  parsed_body = parse_response_body(response_body, content_type, context_data[:env])

  payload_updates = {
    "response" => parsed_body.presence,
    "response_headers" => response_headers,
    "session" => session_data
  }

  Railscope.storage.update_by_batch(
    batch_id: context_data[:batch_id],
    entry_type: "request",
    payload_updates: payload_updates
  )
rescue StandardError => e
  Rails.logger.debug("[Railscope] Failed to update entry with response: #{e.message}")
end

Instance Method Details

#call(env) ⇒ Object



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/railscope/middleware.rb', line 12

def call(env)
  return @app.call(env) unless Railscope.enabled?

  setup_context(env)
  status, headers, response = @app.call(env)

  # Capture response body for recording
  context = Context.current
  if context[:recording]
    # In conditional mode, skip persistence if trigger never fired
    unless Railscope.conditional_recording? && !context.triggered?
      # Read body from env (where Rails stores the response)
      body_content = extract_body_from_env(env)

      context_data = {
        batch_id: context.batch_id,
        env: env,
        headers: headers
      }

      # Update entry with response data
      update_entry_async(context_data, body_content)
    end
  end

  [status, headers, response]
ensure
  Context.clear!
end

#extract_body_from_env(env) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/railscope/middleware.rb', line 42

def extract_body_from_env(env)
  body = ""

  # Try to get from ActionDispatch::Response stored in env
  if env["action_dispatch.response"]
    response = env["action_dispatch.response"]
    body = response.body.to_s if response.respond_to?(:body)
  end

  # Try action_controller.instance
  if body.empty? && env["action_controller.instance"]
    controller = env["action_controller.instance"]
    if controller.respond_to?(:response) && controller.response.respond_to?(:body)
      body = controller.response.body.to_s
    end
  end

  # Truncate if too large
  body = body.byteslice(0, RESPONSE_SIZE_LIMIT) if body.bytesize > RESPONSE_SIZE_LIMIT

  body
rescue StandardError => e
  Rails.logger.debug("[Railscope] Failed to extract body: #{e.message}")
  ""
end

#update_entry_async(context_data, body_content) ⇒ Object



68
69
70
71
72
73
# File 'lib/railscope/middleware.rb', line 68

def update_entry_async(context_data, body_content)
  # Update synchronously for now (could be made async later)
  self.class.update_entry_with_response(context_data, body_content)
rescue StandardError => e
  Rails.logger.debug("[Railscope] Failed to update entry: #{e.message}")
end