grape_rails_logger 1.1.3
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 +32 -0
- data/LICENSE.md +23 -0
- data/README.md +108 -0
- data/lib/grape_rails_logger/debug_tracer.rb +87 -0
- data/lib/grape_rails_logger/endpoint_patch.rb +15 -0
- data/lib/grape_rails_logger/endpoint_wrapper.rb +99 -0
- data/lib/grape_rails_logger/railtie.rb +58 -0
- data/lib/grape_rails_logger/status_extractor.rb +56 -0
- data/lib/grape_rails_logger/subscriber.rb +790 -0
- data/lib/grape_rails_logger/timings.rb +36 -0
- data/lib/grape_rails_logger/version.rb +3 -0
- data/lib/grape_rails_logger.rb +78 -0
- data/sig/grape_rails_logger.rbs +47 -0
- metadata +257 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
module GrapeRailsLogger
|
|
2
|
+
# Subscriber that logs Grape API requests with structured data
|
|
3
|
+
#
|
|
4
|
+
# Automatically subscribed to "grape.request" notifications.
|
|
5
|
+
# Logs structured hash data compatible with JSON loggers.
|
|
6
|
+
#
|
|
7
|
+
# This class is designed to be exception-safe: any error in logging
|
|
8
|
+
# is caught and logged separately, never breaking the request flow.
|
|
9
|
+
class GrapeRequestLogSubscriber
|
|
10
|
+
# Custom error class to distinguish logging errors from other errors
|
|
11
|
+
class LoggingError < StandardError
|
|
12
|
+
attr_reader :original_error
|
|
13
|
+
|
|
14
|
+
def initialize(original_error)
|
|
15
|
+
@original_error = original_error
|
|
16
|
+
super("Logging failed: #{original_error.class}: #{original_error.message}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
FILTERED_PARAMS = %w[password secret token key].freeze
|
|
21
|
+
PARAM_EXCEPTIONS = %w[controller action format].freeze
|
|
22
|
+
AD_PARAMS = "action_dispatch.request.parameters"
|
|
23
|
+
|
|
24
|
+
def grape_request(event)
|
|
25
|
+
return unless event.is_a?(ActiveSupport::Notifications::Event)
|
|
26
|
+
|
|
27
|
+
env = event.payload[:env]
|
|
28
|
+
return unless env.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
# Get logger from event payload (passed from middleware)
|
|
31
|
+
logger = event.payload[:logger]
|
|
32
|
+
|
|
33
|
+
logged_successfully = false
|
|
34
|
+
begin
|
|
35
|
+
endpoint = env[Grape::Env::API_ENDPOINT] if env.is_a?(Hash)
|
|
36
|
+
request = (endpoint&.respond_to?(:request) && endpoint.request) ? endpoint.request : nil
|
|
37
|
+
|
|
38
|
+
data = build_log_data(event, request, env)
|
|
39
|
+
logged_successfully = log_data(data, event.payload[:exception_object], logger)
|
|
40
|
+
event.payload[:logged_successfully] = true if logged_successfully
|
|
41
|
+
rescue LoggingError
|
|
42
|
+
# If logging itself failed, don't try to log again (would cause infinite loop or duplicates)
|
|
43
|
+
# The error was already logged in safe_log's rescue block
|
|
44
|
+
# Silently skip to avoid duplicate logs
|
|
45
|
+
rescue => e
|
|
46
|
+
# Only call fallback if we haven't successfully logged yet
|
|
47
|
+
# This prevents duplicate logs when logging succeeds but something else fails
|
|
48
|
+
unless logged_successfully
|
|
49
|
+
log_fallback_subscriber_error(event, e, logger)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build_request(env)
|
|
57
|
+
::Grape::Request.new(env)
|
|
58
|
+
rescue
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_log_data(event, request, env)
|
|
63
|
+
db_runtime = event.payload[:db_runtime] || 0
|
|
64
|
+
db_calls = event.payload[:db_calls] || 0
|
|
65
|
+
total_runtime = begin
|
|
66
|
+
event.duration || 0
|
|
67
|
+
rescue
|
|
68
|
+
0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
status = extract_status(event) || (event.payload[:exception_object] ? 500 : 200)
|
|
72
|
+
rails_request = rails_request_for(env)
|
|
73
|
+
method = request ? safe_string(request.request_method) : safe_string(env["REQUEST_METHOD"])
|
|
74
|
+
path = request ? safe_string(request.path) : safe_string(env["PATH_INFO"] || env["REQUEST_URI"])
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
method: method,
|
|
78
|
+
path: path,
|
|
79
|
+
format: extract_format(request, env),
|
|
80
|
+
controller: extract_controller(event),
|
|
81
|
+
source_location: extract_source_location(event),
|
|
82
|
+
action: extract_action(event) || "unknown",
|
|
83
|
+
status: status,
|
|
84
|
+
host: extract_host(rails_request, request),
|
|
85
|
+
remote_addr: extract_remote_addr(rails_request, request),
|
|
86
|
+
request_id: extract_request_id(rails_request, env),
|
|
87
|
+
duration: total_runtime.round(2),
|
|
88
|
+
db: db_runtime.round(2),
|
|
89
|
+
db_calls: db_calls,
|
|
90
|
+
params: filter_params(extract_params(request, env))
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def rails_request_for(env)
|
|
95
|
+
return nil unless defined?(ActionDispatch::Request)
|
|
96
|
+
|
|
97
|
+
ActionDispatch::Request.new(env)
|
|
98
|
+
rescue
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def extract_host(rails_request, grape_request)
|
|
103
|
+
# Prefer Rails ActionDispatch::Request#host if available
|
|
104
|
+
if rails_request&.respond_to?(:host)
|
|
105
|
+
safe_string(rails_request.host)
|
|
106
|
+
else
|
|
107
|
+
safe_string(grape_request.host)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def extract_remote_addr(rails_request, grape_request)
|
|
112
|
+
# Prefer Rails ActionDispatch::Request#remote_ip if available
|
|
113
|
+
if rails_request&.respond_to?(:remote_ip)
|
|
114
|
+
safe_string(rails_request.remote_ip)
|
|
115
|
+
elsif rails_request&.respond_to?(:ip)
|
|
116
|
+
safe_string(rails_request.ip)
|
|
117
|
+
elsif grape_request&.respond_to?(:ip)
|
|
118
|
+
safe_string(grape_request.ip)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def extract_request_id(rails_request, env)
|
|
123
|
+
# Prefer Rails ActionDispatch::Request#request_id if available
|
|
124
|
+
if rails_request&.respond_to?(:request_id)
|
|
125
|
+
safe_string(rails_request.request_id)
|
|
126
|
+
else
|
|
127
|
+
safe_string(env["action_dispatch.request_id"])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def log_data(data, exception_object, logger = nil)
|
|
132
|
+
if exception_object
|
|
133
|
+
data[:exception] = build_exception_data(exception_object)
|
|
134
|
+
safe_log(:error, data, logger)
|
|
135
|
+
else
|
|
136
|
+
safe_log(:info, data, logger)
|
|
137
|
+
end
|
|
138
|
+
# Mark that we successfully logged to prevent fallback from duplicating
|
|
139
|
+
true
|
|
140
|
+
rescue => e
|
|
141
|
+
# If logging itself fails, don't log again in fallback
|
|
142
|
+
# Mark that we tried to log so fallback knows not to duplicate
|
|
143
|
+
raise LoggingError.new(e)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def build_exception_data(exception)
|
|
147
|
+
data = {
|
|
148
|
+
class: exception.class.name,
|
|
149
|
+
message: exception.message
|
|
150
|
+
}
|
|
151
|
+
# Use Rails.env for environment checks (Rails-native)
|
|
152
|
+
if defined?(Rails) && Rails.respond_to?(:env) && !Rails.env.production?
|
|
153
|
+
data[:backtrace] = exception.backtrace&.first(10)
|
|
154
|
+
end
|
|
155
|
+
data
|
|
156
|
+
rescue => e
|
|
157
|
+
{class: "Unknown", message: "Failed to extract exception data: #{e.message}"}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def safe_log(level, data, logger = nil)
|
|
161
|
+
# Get logger from parameter, config, or default to Rails.logger
|
|
162
|
+
effective_logger = logger || resolve_effective_logger
|
|
163
|
+
return unless effective_logger
|
|
164
|
+
|
|
165
|
+
# Get tag from config
|
|
166
|
+
tag = resolve_tag
|
|
167
|
+
|
|
168
|
+
if effective_logger.respond_to?(:tagged) && tag
|
|
169
|
+
effective_logger.tagged(tag) do
|
|
170
|
+
effective_logger.public_send(level, data)
|
|
171
|
+
end
|
|
172
|
+
else
|
|
173
|
+
effective_logger.public_send(level, data)
|
|
174
|
+
end
|
|
175
|
+
rescue => e
|
|
176
|
+
# Fallback to stderr if logging fails - never raise
|
|
177
|
+
# Only warn in development to avoid noise in production
|
|
178
|
+
if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
|
|
179
|
+
Rails.logger&.warn("GrapeRailsLogger log error: #{e.message}")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def resolve_tag
|
|
184
|
+
# First check Rails config if available
|
|
185
|
+
if defined?(Rails) && Rails.application && Rails.application.config.respond_to?(:grape_rails_logger)
|
|
186
|
+
config_tag = Rails.application.config.grape_rails_logger.tag
|
|
187
|
+
return config_tag if config_tag
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Fall back to module config
|
|
191
|
+
if defined?(GrapeRailsLogger) && GrapeRailsLogger.respond_to?(:effective_config)
|
|
192
|
+
config = GrapeRailsLogger.effective_config
|
|
193
|
+
return config.tag if config.respond_to?(:tag) && config.tag
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Default tag
|
|
197
|
+
"Grape"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def resolve_effective_logger
|
|
201
|
+
# First check Rails config if available
|
|
202
|
+
if defined?(Rails) && Rails.application && Rails.application.config.respond_to?(:grape_rails_logger)
|
|
203
|
+
config_logger = Rails.application.config.grape_rails_logger.logger
|
|
204
|
+
return config_logger if config_logger
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Fall back to Rails.logger if available
|
|
208
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
209
|
+
return Rails.logger
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def log_fallback_subscriber_error(event, error, logger = nil)
|
|
216
|
+
# Try to build a proper HTTP request log even when subscriber processing fails
|
|
217
|
+
# This should only be called if there's an error in the subscriber itself,
|
|
218
|
+
# not if there's a request exception (which is already logged by log_data)
|
|
219
|
+
env = event.payload[:env]
|
|
220
|
+
return unless env.is_a?(Hash)
|
|
221
|
+
|
|
222
|
+
# If the error is the same as the exception_object, it means we already logged it
|
|
223
|
+
# in the normal path, so skip duplicate logging
|
|
224
|
+
return if error == event.payload[:exception_object]
|
|
225
|
+
|
|
226
|
+
# If the error is a LoggingError, it means logging already failed and was handled
|
|
227
|
+
# Don't try to log again as it would cause duplicates
|
|
228
|
+
return if error.is_a?(LoggingError)
|
|
229
|
+
|
|
230
|
+
# If we successfully logged in the normal path, don't log again
|
|
231
|
+
# This prevents duplicates when logging succeeds but something else fails
|
|
232
|
+
return if event.payload[:logged_successfully] == true
|
|
233
|
+
|
|
234
|
+
# Double-check: if we can see the logged_successfully flag was set, definitely skip
|
|
235
|
+
# This is a safety check in case the flag was set but we're still being called
|
|
236
|
+
return if event.payload.key?(:logged_successfully) && event.payload[:logged_successfully]
|
|
237
|
+
|
|
238
|
+
# Get logger from parameter or event payload
|
|
239
|
+
# Make sure we use the tagged logger if available, not raw Rails.logger
|
|
240
|
+
effective_logger = logger || event.payload[:logger] || resolve_effective_logger
|
|
241
|
+
|
|
242
|
+
begin
|
|
243
|
+
# Extract status using the same priority as extract_status
|
|
244
|
+
# This ensures consistency - always use response status, not exception status
|
|
245
|
+
status = extract_status(event)
|
|
246
|
+
|
|
247
|
+
total_runtime = begin
|
|
248
|
+
event.duration || 0
|
|
249
|
+
rescue
|
|
250
|
+
0
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Try to build request, but don't fail if it doesn't work
|
|
254
|
+
request = begin
|
|
255
|
+
build_request(env)
|
|
256
|
+
rescue
|
|
257
|
+
# If build_request fails, we'll extract from env directly
|
|
258
|
+
nil
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Build minimal log data - extract what we can even if request building fails
|
|
262
|
+
rails_request = begin
|
|
263
|
+
rails_request_for(env)
|
|
264
|
+
rescue
|
|
265
|
+
nil
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Safely extract all fields with fallbacks
|
|
269
|
+
method = begin
|
|
270
|
+
request ? safe_string(request.request_method) : safe_string(env["REQUEST_METHOD"])
|
|
271
|
+
rescue
|
|
272
|
+
safe_string(env["REQUEST_METHOD"])
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
path = begin
|
|
276
|
+
request ? safe_string(request.path) : safe_string(env["PATH_INFO"] || env["REQUEST_URI"])
|
|
277
|
+
rescue
|
|
278
|
+
safe_string(env["PATH_INFO"] || env["REQUEST_URI"])
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
format_val = begin
|
|
282
|
+
request ? extract_format(request, env) : extract_format_from_env(env)
|
|
283
|
+
rescue
|
|
284
|
+
extract_format_from_env(env)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
host = begin
|
|
288
|
+
request ? extract_host(rails_request, request) : extract_host_from_env(rails_request, env)
|
|
289
|
+
rescue
|
|
290
|
+
extract_host_from_env(rails_request, env)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
remote_addr = begin
|
|
294
|
+
request ? extract_remote_addr(rails_request, request) : extract_remote_addr_from_env(rails_request, env)
|
|
295
|
+
rescue
|
|
296
|
+
extract_remote_addr_from_env(rails_request, env)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
request_id = begin
|
|
300
|
+
extract_request_id(rails_request, env)
|
|
301
|
+
rescue
|
|
302
|
+
safe_string(env["action_dispatch.request_id"])
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Extract db_runtime and db_calls from event payload if available
|
|
306
|
+
db_runtime = begin
|
|
307
|
+
(event.payload[:db_runtime] || 0).round(2)
|
|
308
|
+
rescue
|
|
309
|
+
0
|
|
310
|
+
end
|
|
311
|
+
db_calls = begin
|
|
312
|
+
event.payload[:db_calls] || 0
|
|
313
|
+
rescue
|
|
314
|
+
0
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Try to extract action and controller even in fallback mode
|
|
318
|
+
action = begin
|
|
319
|
+
extract_action(event) || "unknown"
|
|
320
|
+
rescue
|
|
321
|
+
"unknown"
|
|
322
|
+
end
|
|
323
|
+
controller = begin
|
|
324
|
+
extract_controller(event)
|
|
325
|
+
rescue
|
|
326
|
+
nil
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
data = {
|
|
330
|
+
method: method,
|
|
331
|
+
path: path,
|
|
332
|
+
format: format_val,
|
|
333
|
+
status: status,
|
|
334
|
+
host: host,
|
|
335
|
+
remote_addr: remote_addr,
|
|
336
|
+
request_id: request_id,
|
|
337
|
+
duration: total_runtime.round(2),
|
|
338
|
+
db: db_runtime,
|
|
339
|
+
db_calls: db_calls,
|
|
340
|
+
action: action,
|
|
341
|
+
controller: controller,
|
|
342
|
+
exception: {
|
|
343
|
+
class: error.class.name,
|
|
344
|
+
message: error.message
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
# Use the original exception if available, otherwise use the subscriber error
|
|
349
|
+
# Only include exception details if this is a subscriber error, not a request exception
|
|
350
|
+
# (request exceptions are already logged by the normal path)
|
|
351
|
+
if (original_exception = event.payload[:exception_object])
|
|
352
|
+
# If there's an original exception AND the error is the same, we already logged it
|
|
353
|
+
# Skip logging to avoid duplicates
|
|
354
|
+
if error == original_exception
|
|
355
|
+
return
|
|
356
|
+
end
|
|
357
|
+
# If they're different, include the original exception details
|
|
358
|
+
data[:exception][:class] = original_exception.class.name
|
|
359
|
+
data[:exception][:message] = original_exception.message
|
|
360
|
+
if defined?(Rails) && Rails.respond_to?(:env) && !Rails.env.production?
|
|
361
|
+
data[:exception][:backtrace] = original_exception.backtrace&.first(10)
|
|
362
|
+
end
|
|
363
|
+
elsif defined?(Rails) && Rails.respond_to?(:env) && !Rails.env.production?
|
|
364
|
+
data[:exception][:backtrace] = error.backtrace&.first(10)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
safe_log(:error, data, effective_logger)
|
|
368
|
+
rescue
|
|
369
|
+
# If even fallback logging fails, don't try to log again
|
|
370
|
+
# Silently fail to avoid infinite loops or noise
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def extract_format_from_env(env)
|
|
375
|
+
# First check Grape's api.format
|
|
376
|
+
env_fmt = env["api.format"]
|
|
377
|
+
if env_fmt
|
|
378
|
+
fmt = env_fmt.to_s.sub(/^\./, "").downcase
|
|
379
|
+
return fmt unless fmt.empty?
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Try to determine format from Content-Type using Grape's content type mappings
|
|
383
|
+
format_from_content_type = extract_format_from_content_type(env)
|
|
384
|
+
return format_from_content_type if format_from_content_type
|
|
385
|
+
|
|
386
|
+
# Fall back to rack.request.formats
|
|
387
|
+
rack_fmt = env["rack.request.formats"]&.first
|
|
388
|
+
if rack_fmt
|
|
389
|
+
fmt = rack_fmt.to_s.sub(/^\./, "").downcase
|
|
390
|
+
return fmt unless fmt.empty?
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Fall back to content type string parsing
|
|
394
|
+
content_type = env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"]
|
|
395
|
+
if content_type
|
|
396
|
+
fmt = content_type.to_s.sub(/^\./, "").downcase
|
|
397
|
+
return fmt unless fmt.empty?
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
"json"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def extract_host_from_env(rails_request, env)
|
|
404
|
+
if rails_request&.respond_to?(:host)
|
|
405
|
+
safe_string(rails_request.host)
|
|
406
|
+
else
|
|
407
|
+
safe_string(env["HTTP_HOST"] || env["SERVER_NAME"])
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def extract_remote_addr_from_env(rails_request, env)
|
|
412
|
+
if rails_request&.respond_to?(:remote_ip)
|
|
413
|
+
safe_string(rails_request.remote_ip)
|
|
414
|
+
elsif rails_request&.respond_to?(:ip)
|
|
415
|
+
safe_string(rails_request.ip)
|
|
416
|
+
else
|
|
417
|
+
x_forwarded_for = env["HTTP_X_FORWARDED_FOR"]
|
|
418
|
+
if x_forwarded_for
|
|
419
|
+
first_ip = x_forwarded_for.split(",").first&.strip
|
|
420
|
+
return safe_string(first_ip) if first_ip
|
|
421
|
+
end
|
|
422
|
+
safe_string(env["REMOTE_ADDR"])
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def safe_string(value)
|
|
427
|
+
return nil if value.nil?
|
|
428
|
+
value.to_s
|
|
429
|
+
rescue
|
|
430
|
+
nil
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def extract_status(event)
|
|
434
|
+
# PRIORITY 1: Status already captured from response (most reliable)
|
|
435
|
+
# This is set in capture_response_metadata AFTER Grape's rescue_from handlers
|
|
436
|
+
# This is the SINGLE SOURCE OF TRUTH - the response from Error middleware
|
|
437
|
+
status = event.payload[:status]
|
|
438
|
+
return status if status.is_a?(Integer)
|
|
439
|
+
|
|
440
|
+
# PRIORITY 2: Extract from response array directly
|
|
441
|
+
# This is the actual Rack response that Error middleware returned
|
|
442
|
+
response = event.payload[:response]
|
|
443
|
+
if response.is_a?(Array) && response[0].is_a?(Integer)
|
|
444
|
+
return response[0]
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# PRIORITY 3: Get from endpoint status (set by Error middleware's error_response)
|
|
448
|
+
# Error middleware sets env[Grape::Env::API_ENDPOINT].status(status) when processing errors
|
|
449
|
+
# This should have been captured in capture_response_metadata, but we check again here
|
|
450
|
+
# as a fallback in case capture_response_metadata didn't run or failed
|
|
451
|
+
env = event.payload[:env]
|
|
452
|
+
if env.is_a?(Hash) && env[Grape::Env::API_ENDPOINT]
|
|
453
|
+
endpoint = env[Grape::Env::API_ENDPOINT]
|
|
454
|
+
if endpoint.respond_to?(:status)
|
|
455
|
+
endpoint_status = begin
|
|
456
|
+
endpoint.status
|
|
457
|
+
rescue
|
|
458
|
+
# If status method raises, ignore it
|
|
459
|
+
nil
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
if endpoint_status.is_a?(Integer) && endpoint_status >= 400
|
|
463
|
+
# Only use endpoint status if it's an error status (4xx or 5xx)
|
|
464
|
+
# This ensures we're not using stale status from a previous request
|
|
465
|
+
return endpoint_status
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# PRIORITY 4: Fall back to exception-based status (only if no response available)
|
|
471
|
+
# This should rarely happen - Grape's rescue_from should return a response
|
|
472
|
+
if (exception = event.payload[:exception_object])
|
|
473
|
+
status = StatusExtractor.extract_status_from_exception(exception)
|
|
474
|
+
return status if status.is_a?(Integer)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Default fallback - if we have an exception, assume 500
|
|
478
|
+
# If no exception, assume 200 (success)
|
|
479
|
+
event.payload[:exception_object] ? 500 : 200
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def extract_action(event)
|
|
483
|
+
endpoint = event.payload.dig(:env, "api.endpoint")
|
|
484
|
+
return "unknown" unless endpoint
|
|
485
|
+
return "unknown" unless endpoint.respond_to?(:options)
|
|
486
|
+
return "unknown" unless endpoint.options
|
|
487
|
+
|
|
488
|
+
method = endpoint.options[:method]&.first
|
|
489
|
+
path = endpoint.options[:path]&.first
|
|
490
|
+
return "unknown" unless method && path
|
|
491
|
+
|
|
492
|
+
return method.downcase.to_s if path == "/" || path.empty?
|
|
493
|
+
|
|
494
|
+
clean_path = path.to_s.delete_prefix("/").gsub(/[:\/]/, "_").squeeze("_").gsub(/^_+|_+$/, "")
|
|
495
|
+
"#{method.downcase}_#{clean_path}"
|
|
496
|
+
rescue
|
|
497
|
+
"unknown"
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def extract_controller(event)
|
|
501
|
+
endpoint = event.payload.dig(:env, "api.endpoint")
|
|
502
|
+
return nil unless endpoint&.source&.source_location
|
|
503
|
+
|
|
504
|
+
file_path = endpoint.source.source_location.first
|
|
505
|
+
return nil unless file_path
|
|
506
|
+
|
|
507
|
+
rails_root = safe_rails_root
|
|
508
|
+
return nil unless rails_root
|
|
509
|
+
|
|
510
|
+
prefix = File.join(rails_root, "app/api/")
|
|
511
|
+
path_without_prefix = file_path.delete_prefix(prefix).sub(/\.rb\z/, "")
|
|
512
|
+
|
|
513
|
+
# Use ActiveSupport::Inflector.camelize (Rails-native) which converts
|
|
514
|
+
# file paths to class names (e.g., "users" -> "Users", "users/profile" -> "Users::Profile")
|
|
515
|
+
# This matches Rails generator conventions for converting file paths to class names
|
|
516
|
+
if defined?(ActiveSupport::Inflector)
|
|
517
|
+
ActiveSupport::Inflector.camelize(path_without_prefix)
|
|
518
|
+
else
|
|
519
|
+
# Fallback for non-Rails: split by /, capitalize each part, join with ::
|
|
520
|
+
path_without_prefix.split("/").map(&:capitalize).join("::")
|
|
521
|
+
end
|
|
522
|
+
rescue
|
|
523
|
+
nil
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def extract_source_location(event)
|
|
527
|
+
endpoint = event.payload.dig(:env, "api.endpoint")
|
|
528
|
+
return nil unless endpoint&.source&.source_location
|
|
529
|
+
|
|
530
|
+
loc = endpoint.source.source_location.first
|
|
531
|
+
line = endpoint.source.source_location.last
|
|
532
|
+
return nil unless loc
|
|
533
|
+
|
|
534
|
+
rails_root = safe_rails_root
|
|
535
|
+
if rails_root && loc.start_with?(rails_root)
|
|
536
|
+
loc = loc.delete_prefix(rails_root + "/")
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
"#{loc}:#{line}"
|
|
540
|
+
rescue
|
|
541
|
+
nil
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def safe_rails_root
|
|
545
|
+
return nil unless defined?(Rails)
|
|
546
|
+
return nil unless Rails.respond_to?(:root)
|
|
547
|
+
|
|
548
|
+
root = Rails.root
|
|
549
|
+
return nil if root.nil?
|
|
550
|
+
|
|
551
|
+
# Use Rails.root.to_s which handles Pathname correctly
|
|
552
|
+
root.to_s
|
|
553
|
+
rescue
|
|
554
|
+
nil
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def extract_params(request, env = nil)
|
|
558
|
+
return {} unless env.is_a?(Hash)
|
|
559
|
+
|
|
560
|
+
endpoint = env[Grape::Env::API_ENDPOINT]
|
|
561
|
+
return {} unless endpoint&.respond_to?(:request) && endpoint.request
|
|
562
|
+
|
|
563
|
+
endpoint_params = endpoint.request.params
|
|
564
|
+
return {} if endpoint_params.nil? || endpoint_params.empty?
|
|
565
|
+
|
|
566
|
+
params_hash = if endpoint_params.respond_to?(:to_unsafe_h)
|
|
567
|
+
endpoint_params.to_unsafe_h
|
|
568
|
+
elsif endpoint_params.respond_to?(:to_h)
|
|
569
|
+
endpoint_params.to_h
|
|
570
|
+
else
|
|
571
|
+
endpoint_params.is_a?(Hash) ? endpoint_params : {}
|
|
572
|
+
end
|
|
573
|
+
return params_hash.except("route_info", :route_info) unless params_hash.empty?
|
|
574
|
+
|
|
575
|
+
# Fallback: Parse JSON body for non-standard JSON content types
|
|
576
|
+
req = endpoint.request
|
|
577
|
+
content_type = req.content_type || req.env["CONTENT_TYPE"] || ""
|
|
578
|
+
return {} if !((content_type.include?("json") || content_type.include?("application/vnd.api")) && req.env["rack.input"])
|
|
579
|
+
|
|
580
|
+
begin
|
|
581
|
+
original_pos = begin
|
|
582
|
+
req.env["rack.input"].pos
|
|
583
|
+
rescue
|
|
584
|
+
0
|
|
585
|
+
end
|
|
586
|
+
begin
|
|
587
|
+
req.env["rack.input"].rewind
|
|
588
|
+
rescue
|
|
589
|
+
nil
|
|
590
|
+
end
|
|
591
|
+
body_content = begin
|
|
592
|
+
req.env["rack.input"].read
|
|
593
|
+
rescue
|
|
594
|
+
nil
|
|
595
|
+
end
|
|
596
|
+
req.env["rack.input"].pos = begin
|
|
597
|
+
original_pos
|
|
598
|
+
rescue
|
|
599
|
+
nil
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
if body_content && !body_content.empty?
|
|
603
|
+
parsed_json = begin
|
|
604
|
+
JSON.parse(body_content)
|
|
605
|
+
rescue
|
|
606
|
+
nil
|
|
607
|
+
end
|
|
608
|
+
return parsed_json.except("route_info", :route_info) if parsed_json.is_a?(Hash)
|
|
609
|
+
end
|
|
610
|
+
rescue
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
{}
|
|
614
|
+
rescue
|
|
615
|
+
{}
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def extract_format(request, env = nil)
|
|
619
|
+
env ||= request.env if request.respond_to?(:env)
|
|
620
|
+
|
|
621
|
+
# First check Grape's api.format (most reliable for Grape APIs)
|
|
622
|
+
# This is set by Grape based on Content-Type and Accept headers
|
|
623
|
+
if env.is_a?(Hash)
|
|
624
|
+
env_fmt = env["api.format"]
|
|
625
|
+
if env_fmt
|
|
626
|
+
# Remove leading dot if present and convert to string
|
|
627
|
+
fmt = env_fmt.to_s.sub(/^\./, "").downcase
|
|
628
|
+
return fmt unless fmt.empty?
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# Try Grape's request.format method
|
|
633
|
+
fmt = request.try(:format)
|
|
634
|
+
if fmt
|
|
635
|
+
fmt_str = fmt.to_s.sub(/^\./, "").downcase
|
|
636
|
+
return fmt_str unless fmt_str.empty?
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Try to determine format from Content-Type using Grape's content type mappings
|
|
640
|
+
# This is generic and works with any custom content types defined in the API
|
|
641
|
+
if env.is_a?(Hash)
|
|
642
|
+
format_from_content_type = extract_format_from_content_type(env)
|
|
643
|
+
return format_from_content_type if format_from_content_type
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Fall back to Rails ActionDispatch::Request#format
|
|
647
|
+
if defined?(ActionDispatch::Request) && env.is_a?(Hash)
|
|
648
|
+
begin
|
|
649
|
+
rails_request = ActionDispatch::Request.new(env)
|
|
650
|
+
if rails_request.respond_to?(:format) && rails_request.format
|
|
651
|
+
fmt_str = rails_request.format.to_sym.to_s.downcase
|
|
652
|
+
return fmt_str unless fmt_str.empty?
|
|
653
|
+
end
|
|
654
|
+
rescue
|
|
655
|
+
# Fall through to other detection methods
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# Fall back to rack.request.formats
|
|
660
|
+
if env.is_a?(Hash)
|
|
661
|
+
rack_fmt = env["rack.request.formats"]&.first
|
|
662
|
+
if rack_fmt
|
|
663
|
+
fmt_str = rack_fmt.to_s.sub(/^\./, "").downcase
|
|
664
|
+
return fmt_str unless fmt_str.empty?
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Default to json if nothing found
|
|
669
|
+
"json"
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def extract_format_from_content_type(env)
|
|
673
|
+
return nil unless env.is_a?(Hash)
|
|
674
|
+
|
|
675
|
+
content_type = env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"]
|
|
676
|
+
accept = env["HTTP_ACCEPT"]
|
|
677
|
+
return nil unless content_type || accept
|
|
678
|
+
|
|
679
|
+
endpoint = env["api.endpoint"]
|
|
680
|
+
return nil unless endpoint
|
|
681
|
+
|
|
682
|
+
api_class = endpoint.respond_to?(:options) ? endpoint.options[:for] : nil
|
|
683
|
+
api_class ||= endpoint.respond_to?(:namespace) ? endpoint.namespace&.options&.dig(:for) : nil
|
|
684
|
+
api_class ||= endpoint.respond_to?(:route) ? endpoint.route&.options&.dig(:for) : nil
|
|
685
|
+
return nil unless api_class&.respond_to?(:content_types, true)
|
|
686
|
+
|
|
687
|
+
begin
|
|
688
|
+
content_types = api_class.content_types
|
|
689
|
+
return nil unless content_types.is_a?(Hash)
|
|
690
|
+
|
|
691
|
+
content_types.each do |format_name, mime_type|
|
|
692
|
+
mime_types = mime_type.is_a?(Array) ? mime_type : [mime_type]
|
|
693
|
+
if content_type && mime_types.any? { |mime| content_type.include?(mime.to_s) }
|
|
694
|
+
return format_name.to_s.downcase
|
|
695
|
+
end
|
|
696
|
+
if accept && mime_types.any? { |mime| accept.include?(mime.to_s) }
|
|
697
|
+
return format_name.to_s.downcase
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
rescue
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
nil
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def filter_params(params)
|
|
707
|
+
return {} unless params
|
|
708
|
+
return {} unless params.is_a?(Hash)
|
|
709
|
+
|
|
710
|
+
cleaned = if (filter = rails_parameter_filter)
|
|
711
|
+
# Create a deep copy since filter modifies in place (Rails 6+)
|
|
712
|
+
params_copy = params.respond_to?(:deep_dup) ? params.deep_dup : params.dup
|
|
713
|
+
filter.filter(params_copy)
|
|
714
|
+
else
|
|
715
|
+
filter_parameters_manually(params)
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
cleaned.is_a?(Hash) ? cleaned.reject { |key, _| PARAM_EXCEPTIONS.include?(key.to_s) } : {}
|
|
719
|
+
rescue
|
|
720
|
+
# Don't log - just fallback to manual filtering
|
|
721
|
+
# Logging happens only in subscriber
|
|
722
|
+
filter_parameters_manually(params).reject { |key, _| PARAM_EXCEPTIONS.include?(key.to_s) }
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def rails_parameter_filter
|
|
726
|
+
# Use Rails.application.config.filter_parameters (Rails-native configuration)
|
|
727
|
+
return nil unless defined?(Rails) && defined?(Rails.application)
|
|
728
|
+
return nil unless Rails.application.config.respond_to?(:filter_parameters)
|
|
729
|
+
|
|
730
|
+
filter_parameters = Rails.application.config.filter_parameters
|
|
731
|
+
return nil if filter_parameters.nil? || filter_parameters.empty?
|
|
732
|
+
|
|
733
|
+
# Prefer ActiveSupport::ParameterFilter (Rails 6.1+)
|
|
734
|
+
# This is the Rails-native parameter filtering system
|
|
735
|
+
if defined?(ActiveSupport::ParameterFilter)
|
|
736
|
+
ActiveSupport::ParameterFilter.new(filter_parameters)
|
|
737
|
+
# Fall back to ActionDispatch::Http::ParameterFilter (Rails 6.0 and earlier)
|
|
738
|
+
elsif defined?(ActionDispatch::Http::ParameterFilter)
|
|
739
|
+
ActionDispatch::Http::ParameterFilter.new(filter_parameters)
|
|
740
|
+
end
|
|
741
|
+
rescue
|
|
742
|
+
# Log but don't fail - fall back to manual filtering
|
|
743
|
+
# Don't log - just return nil
|
|
744
|
+
# Logging happens only in subscriber
|
|
745
|
+
nil
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def filter_parameters_manually(params, depth = 0)
|
|
749
|
+
return {} unless params
|
|
750
|
+
return {"[FILTERED]" => "[max_depth_exceeded]"} if depth > 10
|
|
751
|
+
|
|
752
|
+
return {} unless params.is_a?(Hash)
|
|
753
|
+
|
|
754
|
+
params.each_with_object({}) do |(key, value), result|
|
|
755
|
+
next if result.size >= 50 # Limit hash size
|
|
756
|
+
|
|
757
|
+
result[key] = if should_filter_key?(key)
|
|
758
|
+
# When key should be filtered, replace the value (not the key name)
|
|
759
|
+
"[FILTERED]"
|
|
760
|
+
else
|
|
761
|
+
case value
|
|
762
|
+
when Hash
|
|
763
|
+
filter_parameters_manually(value, depth + 1)
|
|
764
|
+
when Array
|
|
765
|
+
value.first(100).map { |v| v.is_a?(Hash) ? filter_parameters_manually(v, depth + 1) : filter_value(v) }
|
|
766
|
+
else
|
|
767
|
+
filter_value(value)
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
rescue
|
|
772
|
+
# Don't log - just return empty hash
|
|
773
|
+
# Logging happens only in subscriber
|
|
774
|
+
{}
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def filter_value(value)
|
|
778
|
+
return value unless value.is_a?(String)
|
|
779
|
+
value_lower = value.downcase
|
|
780
|
+
return "[FILTERED]" if FILTERED_PARAMS.any? { |param| value_lower.include?(param.downcase) }
|
|
781
|
+
|
|
782
|
+
value
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
def should_filter_key?(key)
|
|
786
|
+
key_lower = key.to_s.downcase
|
|
787
|
+
FILTERED_PARAMS.any? { |param| key_lower.include?(param.downcase) }
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
end
|