rails_nexus 2.1.4 → 2.1.5
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/CHANGELOG.md +10 -0
- data/README.md +11 -0
- data/app/controllers/rails_nexus/application_controller.rb +6 -1
- data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -3
- data/app/controllers/rails_nexus/logged_exceptions_controller.rb +4 -4
- data/app/helpers/rails_nexus/application_helper.rb +4 -2
- data/app/models/rails_nexus/logged_exception.rb +165 -40
- data/app/views/rails_nexus/cron_jobs/index.html.erb +1 -1
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +1 -1
- data/app/views/rails_nexus/logged_exceptions/_show.html.erb +51 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_exceptions.html.erb +1 -1
- data/lib/rails_nexus/logger.rb +218 -88
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/rails_nexus.rb +25 -7
- data/rails_nexus.gemspec +1 -1
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 906957a4703c0c553eca5cb3966cccec4c71b117a07653e17c648ff96098e819
|
|
4
|
+
data.tar.gz: 2bc584d69992b1c1dd9947b266db3541ced28b24a9be7af5dddc909ad9a2eb05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 178c8c34f47ad84234bc910222c9fc507b89869bb09c6b93b1c88c424277859696aebedf4dd7c2d1f838a13ea9768bc1b747a8ccc47c45e169e91f594815a149
|
|
7
|
+
data.tar.gz: dd78c3b2c913ee5f2f15957b074a7c10083dd3e26eb81bfb2c1aa0cb1081076676a4e03d23d698774a2892a6fa5eb69b2e9baed8ed5d6159bbd2141869918724
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ All notable changes to RailsNexus will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Rich exception diagnostics** — Structured logs and persisted exception records now capture filtered request/user context, cause chains, runtime details, app version, custom exception data, and exception instance variables with bounded serialization.
|
|
12
|
+
- **Diagnostic context UI** — Exception details expose fingerprint, occurrence, platform, app/user metadata, and a dedicated context tab.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **Safe exception boundary** — Capture and custom-context failures no longer replace the original application exception.
|
|
16
|
+
- **Precise exception grouping** — Fingerprints now include normalized messages and source frames so unrelated failures in the same action are not merged.
|
|
17
|
+
|
|
8
18
|
## [2.1.4] - 2026-08-24
|
|
9
19
|
|
|
10
20
|
### Changed
|
data/README.md
CHANGED
|
@@ -144,6 +144,17 @@ end
|
|
|
144
144
|
|
|
145
145
|
RailsNexus logs the exception and then re-raises it so Rails keeps its normal error handling.
|
|
146
146
|
|
|
147
|
+
Each capture includes the full filtered request context, request ID, user agent,
|
|
148
|
+
current-user identity, normalized fingerprint, cause chain, bounded backtrace,
|
|
149
|
+
exception instance variables, runtime/system health, and any `exception_data`
|
|
150
|
+
returned by your configuration. Custom and exception data appear in the
|
|
151
|
+
**Context** tab. Passwords, tokens, cookies, authorization headers, and the host
|
|
152
|
+
application's configured filter parameters are replaced with `[FILTERED]`.
|
|
153
|
+
|
|
154
|
+
When structured logging is enabled, the Rails logger receives the same context
|
|
155
|
+
as a single JSON event. `log_backtrace_limit`, `log_params`, and
|
|
156
|
+
`log_user_info` control how much is included in that event.
|
|
157
|
+
|
|
147
158
|
### API controllers
|
|
148
159
|
|
|
149
160
|
For `ActionController::API` subclasses (e.g., API namespaces):
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
module RailsNexus
|
|
2
2
|
class ApplicationController < ActionController::Base
|
|
3
|
-
include Pagy::
|
|
3
|
+
include Pagy::Method
|
|
4
4
|
protect_from_forgery with: :exception
|
|
5
5
|
before_action :rails_nexus_require_auth!
|
|
6
6
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
|
+
def rails_nexus_pagy(collection, **options)
|
|
10
|
+
limit = options.delete(:limit) || RailsNexus.configuration.per_page || 30
|
|
11
|
+
pagy(:offset, collection, limit: limit, **options)
|
|
12
|
+
end
|
|
13
|
+
|
|
9
14
|
def rails_nexus_require_auth!
|
|
10
15
|
auth_block = RailsNexus.configuration.auth_block
|
|
11
16
|
return if auth_block&.call(self)
|
|
@@ -23,7 +23,7 @@ module RailsNexus
|
|
|
23
23
|
|
|
24
24
|
requested_size = params[:per_page].to_i
|
|
25
25
|
page_size = requested_size.between?(1, 100) ? requested_size : 30
|
|
26
|
-
@pagy, @cron_jobs =
|
|
26
|
+
@pagy, @cron_jobs = rails_nexus_pagy(cron_jobs, limit: page_size)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def show
|
|
@@ -42,7 +42,6 @@ module RailsNexus
|
|
|
42
42
|
|
|
43
43
|
def retry_job
|
|
44
44
|
cron_job = CronJob.find(params[:id])
|
|
45
|
-
# Re-run the job (implementation depends on job scheduler)
|
|
46
45
|
redirect_to cron_jobs_path, notice: "Retrying job: #{cron_job.name}"
|
|
47
46
|
end
|
|
48
47
|
|
|
@@ -51,6 +50,5 @@ module RailsNexus
|
|
|
51
50
|
def set_cron_job
|
|
52
51
|
@cron_job = CronJob.find(params[:id])
|
|
53
52
|
end
|
|
54
|
-
|
|
55
53
|
end
|
|
56
54
|
end
|
|
@@ -96,9 +96,7 @@ module RailsNexus
|
|
|
96
96
|
load_filter_options
|
|
97
97
|
@q = ransack_search
|
|
98
98
|
load_stats if RailsNexus.configuration.show_stats
|
|
99
|
-
@pagy, @exceptions =
|
|
100
|
-
rescue Pagy::OverflowError
|
|
101
|
-
@pagy, @exceptions = pagy(@q.result(distinct: true), page: 1, items: page_size)
|
|
99
|
+
@pagy, @exceptions = rails_nexus_pagy(@q.result(distinct: true), limit: page_size)
|
|
102
100
|
end
|
|
103
101
|
|
|
104
102
|
def ransack_search
|
|
@@ -218,7 +216,9 @@ module RailsNexus
|
|
|
218
216
|
backtrace: @exception.backtrace.present?,
|
|
219
217
|
request: @exception.request.present?,
|
|
220
218
|
environment: @exception.environment.present?,
|
|
221
|
-
user_info: @exception.user_info.present
|
|
219
|
+
user_info: @exception.user_info.present?,
|
|
220
|
+
diagnostic_context: (@exception.respond_to?(:local_variables) && @exception.local_variables.present?) ||
|
|
221
|
+
(@exception.respond_to?(:instance_variables) && @exception.instance_variables.present?)
|
|
222
222
|
}
|
|
223
223
|
end
|
|
224
224
|
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
module RailsNexus
|
|
2
2
|
module ApplicationHelper
|
|
3
|
-
include Pagy::Frontend
|
|
4
|
-
|
|
5
3
|
def page_title(text)
|
|
6
4
|
title = [RailsNexus.application_name.presence, text].compact.join(" :: ")
|
|
7
5
|
content_for(:title, title)
|
|
8
6
|
end
|
|
7
|
+
|
|
8
|
+
def rails_nexus_pagy_nav(pagy)
|
|
9
|
+
pagy.series_nav
|
|
10
|
+
end
|
|
9
11
|
end
|
|
10
12
|
end
|
|
@@ -5,11 +5,13 @@ module RailsNexus
|
|
|
5
5
|
self.table_name = "rails_nexus_exceptions"
|
|
6
6
|
HOSTNAME = Socket.gethostname
|
|
7
7
|
|
|
8
|
-
serialize :cause_chain, coder: JSON
|
|
9
|
-
serialize :breadcrumbs, coder: JSON
|
|
10
|
-
serialize :system_health, coder: JSON
|
|
11
|
-
serialize :local_variables, coder: JSON
|
|
12
|
-
|
|
8
|
+
serialize :cause_chain, coder: JSON
|
|
9
|
+
serialize :breadcrumbs, coder: JSON
|
|
10
|
+
serialize :system_health, coder: JSON
|
|
11
|
+
serialize :local_variables, coder: JSON
|
|
12
|
+
# This column intentionally overrides Object#instance_variables so the
|
|
13
|
+
# captured exception context is readable through the normal model API.
|
|
14
|
+
serialize :instance_variables, coder: JSON
|
|
13
15
|
|
|
14
16
|
# ─── Associations ──────────────────────────────────────────────
|
|
15
17
|
has_many :comments, class_name: "RailsNexus::Comment", dependent: :destroy
|
|
@@ -45,8 +47,13 @@ module RailsNexus
|
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
data = normalize_context_data(data)
|
|
48
51
|
message = exception.message.to_s
|
|
49
|
-
message += "\n* Extra Data\n\n#{data}" unless data.blank?
|
|
52
|
+
message += "\n* Extra Data\n\n#{data.inspect}" unless data.blank?
|
|
53
|
+
|
|
54
|
+
request = safe_call(controller, :request)
|
|
55
|
+
controller_name = safe_call(controller, :controller_path) || controller.class.name
|
|
56
|
+
action_name = safe_call(controller, :action_name)
|
|
50
57
|
|
|
51
58
|
# Detect platform from user agent and request format
|
|
52
59
|
platform_data = detect_platform(controller)
|
|
@@ -57,15 +64,18 @@ module RailsNexus
|
|
|
57
64
|
end
|
|
58
65
|
|
|
59
66
|
# Extract user info for impact scoring
|
|
60
|
-
user = controller
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
user = current_user_for(controller)
|
|
68
|
+
user_context = capture_user_context(user, data)
|
|
69
|
+
user_id = user_context[:id]&.to_s || data[:user_id]&.to_s || data["user_id"]&.to_s
|
|
70
|
+
user_type = user_context[:type]
|
|
63
71
|
|
|
64
72
|
# Generate fingerprint for grouping
|
|
65
73
|
fingerprint = generate_fingerprint(
|
|
66
74
|
exception.class.name,
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
controller_name,
|
|
76
|
+
action_name,
|
|
77
|
+
message: exception.message,
|
|
78
|
+
backtrace: exception.backtrace
|
|
69
79
|
)
|
|
70
80
|
|
|
71
81
|
# Check if this fingerprint already exists (occurrence counting)
|
|
@@ -73,36 +83,34 @@ module RailsNexus
|
|
|
73
83
|
|
|
74
84
|
attrs = {
|
|
75
85
|
exception_class: exception.class.name,
|
|
76
|
-
controller_name:
|
|
77
|
-
action_name:
|
|
86
|
+
controller_name: controller_name,
|
|
87
|
+
action_name: action_name,
|
|
78
88
|
message: message,
|
|
79
89
|
backtrace: exception.backtrace,
|
|
80
|
-
request:
|
|
81
|
-
user_info:
|
|
82
|
-
|
|
90
|
+
request: request,
|
|
91
|
+
user_info: user_context.except(:id, :type).presence&.to_json,
|
|
92
|
+
user_agent: safe_call(request, :user_agent),
|
|
93
|
+
remote_ip: safe_call(request, :remote_ip),
|
|
83
94
|
user_id: user_id,
|
|
84
95
|
user_type: user_type,
|
|
85
96
|
fingerprint: fingerprint,
|
|
86
97
|
platform: platform_data[:platform],
|
|
87
98
|
platform_version: platform_data[:platform_version],
|
|
88
99
|
device_type: platform_data[:device_type],
|
|
100
|
+
app_version: app_version(request, data),
|
|
89
101
|
cause_chain: extract_cause_chain(exception),
|
|
90
102
|
breadcrumbs: breadcrumbs_data.presence,
|
|
91
103
|
system_health: capture_system_health,
|
|
104
|
+
local_variables: data.presence,
|
|
105
|
+
instance_variables: capture_exception_variables(exception).presence,
|
|
92
106
|
occurrence_count: existing ? existing.occurrence_count + 1 : 1
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
if existing
|
|
96
|
-
existing.update!(
|
|
97
|
-
message: message,
|
|
98
|
-
backtrace: exception.backtrace,
|
|
99
|
-
request: controller.request,
|
|
100
|
-
user_info: user,
|
|
101
|
-
remote_ip: controller.request.remote_ip,
|
|
110
|
+
existing.update!(attrs.except(:fingerprint, :occurrence_count).merge(
|
|
102
111
|
occurrence_count: existing.occurrence_count + 1,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
)
|
|
112
|
+
created_at: Time.current
|
|
113
|
+
))
|
|
106
114
|
RailsNexus::StormProtection.record_captured if defined?(RailsNexus::StormProtection)
|
|
107
115
|
existing
|
|
108
116
|
else
|
|
@@ -115,6 +123,84 @@ module RailsNexus
|
|
|
115
123
|
def host_name
|
|
116
124
|
HOSTNAME
|
|
117
125
|
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def normalize_context_data(data)
|
|
130
|
+
data = data.to_unsafe_h if data.respond_to?(:to_unsafe_h)
|
|
131
|
+
data = data.to_h if !data.is_a?(Hash) && data.respond_to?(:to_h)
|
|
132
|
+
data = { value: data } unless data.is_a?(Hash)
|
|
133
|
+
safe_context_value(RailsNexus.filter_sensitive_data(data))
|
|
134
|
+
rescue StandardError
|
|
135
|
+
{}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def current_user_for(controller)
|
|
139
|
+
controller.send(:current_user) if controller.respond_to?(:current_user, true)
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def capture_user_context(user, data)
|
|
145
|
+
return {} unless RailsNexus.configuration.user_impact_tracking
|
|
146
|
+
|
|
147
|
+
context = {
|
|
148
|
+
id: safe_call(user, :id),
|
|
149
|
+
type: user&.class&.name,
|
|
150
|
+
email: safe_call(user, :email),
|
|
151
|
+
username: safe_call(user, :username),
|
|
152
|
+
name: safe_call(user, :name)
|
|
153
|
+
}.compact
|
|
154
|
+
context[:id] ||= data[:user_id] || data["user_id"]
|
|
155
|
+
safe_context_value(RailsNexus.filter_sensitive_data(context))
|
|
156
|
+
rescue StandardError
|
|
157
|
+
{}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def capture_exception_variables(exception)
|
|
161
|
+
values = exception.instance_variables.each_with_object({}) do |name, result|
|
|
162
|
+
result[name.to_s.delete_prefix("@")] = safe_context_value(exception.instance_variable_get(name))
|
|
163
|
+
rescue StandardError
|
|
164
|
+
result[name.to_s.delete_prefix("@")] = "[unavailable]"
|
|
165
|
+
end
|
|
166
|
+
RailsNexus.filter_sensitive_data(values)
|
|
167
|
+
rescue StandardError
|
|
168
|
+
{}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def app_version(request, data)
|
|
172
|
+
data[:app_version] || data["app_version"] ||
|
|
173
|
+
safe_call(request, :get_header, "HTTP_X_APP_VERSION") ||
|
|
174
|
+
safe_call(request, :get_header, "HTTP_X_CLIENT_VERSION")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def safe_call(object, method_name, *args)
|
|
178
|
+
object.public_send(method_name, *args) if object
|
|
179
|
+
rescue StandardError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def safe_context_value(value, depth = 0, seen = {})
|
|
184
|
+
return "[MAX DEPTH]" if depth >= 5
|
|
185
|
+
return value if value.nil? || value == true || value == false || value.is_a?(Numeric)
|
|
186
|
+
return value.to_s.truncate(10_000) if value.is_a?(String) || value.is_a?(Symbol)
|
|
187
|
+
return value.iso8601 if value.respond_to?(:iso8601) && (value.is_a?(Time) || value.is_a?(Date))
|
|
188
|
+
|
|
189
|
+
object_id = value.object_id
|
|
190
|
+
return "[CIRCULAR]" if seen[object_id]
|
|
191
|
+
seen[object_id] = true
|
|
192
|
+
|
|
193
|
+
case value
|
|
194
|
+
when Hash
|
|
195
|
+
value.first(100).to_h.transform_values { |child| safe_context_value(child, depth + 1, seen) }
|
|
196
|
+
when Array
|
|
197
|
+
value.first(100).map { |child| safe_context_value(child, depth + 1, seen) }
|
|
198
|
+
else
|
|
199
|
+
value.to_s.truncate(10_000)
|
|
200
|
+
end
|
|
201
|
+
ensure
|
|
202
|
+
seen&.delete(object_id) if defined?(object_id) && object_id
|
|
203
|
+
end
|
|
118
204
|
end
|
|
119
205
|
|
|
120
206
|
scope :by_exception_class, lambda { |exception_class| where(:exception_class => exception_class) }
|
|
@@ -147,18 +233,30 @@ module RailsNexus
|
|
|
147
233
|
end
|
|
148
234
|
|
|
149
235
|
# Generate fingerprint for grouping similar exceptions
|
|
150
|
-
def self.generate_fingerprint(exception_class, controller_name, action_name)
|
|
236
|
+
def self.generate_fingerprint(exception_class, controller_name, action_name, message: nil, backtrace: nil)
|
|
151
237
|
require "digest"
|
|
152
|
-
|
|
238
|
+
normalized_message = message.to_s
|
|
239
|
+
.gsub(/\b\d+\b/, "?")
|
|
240
|
+
.gsub(/\b[0-9a-f]{8,}\b/i, "?")
|
|
241
|
+
.gsub(/\s+/, " ")
|
|
242
|
+
.strip
|
|
243
|
+
source = Array(backtrace).find { |line| line.to_s.include?(Rails.root.to_s) } || Array(backtrace).first
|
|
244
|
+
normalized_source = source.to_s.sub(/:\d+(?::in .*)?\z/, "")
|
|
245
|
+
parts = [exception_class, controller_name, action_name, normalized_message, normalized_source]
|
|
246
|
+
Digest::SHA256.hexdigest(parts.join("\0"))[0, 16]
|
|
153
247
|
end
|
|
154
248
|
|
|
155
249
|
# Detect platform from user_agent and request
|
|
156
250
|
def self.detect_platform(controller)
|
|
157
251
|
request = controller.request
|
|
252
|
+
return { platform: "unknown", platform_version: nil, device_type: "unknown", app_version: nil } unless request
|
|
253
|
+
|
|
158
254
|
user_agent = request.respond_to?(:user_agent) ? request.user_agent.to_s.downcase : ""
|
|
159
255
|
|
|
160
256
|
# API detection: no user_agent or JSON/XML format
|
|
161
|
-
|
|
257
|
+
format = request.respond_to?(:format) ? request.format : nil
|
|
258
|
+
format_ref = format.respond_to?(:ref) ? format.ref.to_s : format.to_s
|
|
259
|
+
if user_agent.blank? || %w[json xml].include?(format_ref)
|
|
162
260
|
return { platform: "api", platform_version: nil, device_type: "server", app_version: nil }
|
|
163
261
|
end
|
|
164
262
|
|
|
@@ -196,6 +294,8 @@ module RailsNexus
|
|
|
196
294
|
|
|
197
295
|
# Default: web
|
|
198
296
|
{ platform: "web", platform_version: nil, device_type: "unknown", app_version: nil }
|
|
297
|
+
rescue StandardError
|
|
298
|
+
{ platform: "unknown", platform_version: nil, device_type: "unknown", app_version: nil }
|
|
199
299
|
end
|
|
200
300
|
|
|
201
301
|
# Platform analytics
|
|
@@ -497,16 +597,35 @@ module RailsNexus
|
|
|
497
597
|
if request.is_a?(String)
|
|
498
598
|
write_attribute :request, request
|
|
499
599
|
elsif request.respond_to?(:env) && request.env.is_a?(Hash)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
600
|
+
captured_env_keys = %w[
|
|
601
|
+
REQUEST_METHOD HTTP_HOST SERVER_NAME SERVER_PORT HTTPS
|
|
602
|
+
HTTP_X_FORWARDED_PROTO HTTP_X_FORWARDED_HOST HTTP_X_REQUEST_ID
|
|
603
|
+
HTTP_X_CORRELATION_ID HTTP_TRACEPARENT HTTP_TRACESTATE HTTP_BAGGAGE
|
|
604
|
+
action_dispatch.request_id HTTP_REFERER HTTP_ORIGIN CONTENT_TYPE
|
|
605
|
+
CONTENT_LENGTH HTTP_ACCEPT HTTP_ACCEPT_LANGUAGE HTTP_USER_AGENT
|
|
606
|
+
HTTP_X_APP_VERSION HTTP_X_CLIENT_VERSION HTTP_AUTHORIZATION HTTP_COOKIE
|
|
607
|
+
]
|
|
608
|
+
captured_env = request.env.slice(*captured_env_keys)
|
|
609
|
+
filtered_env = RailsNexus.filter_sensitive_data(captured_env)
|
|
610
|
+
environment = {
|
|
611
|
+
"Rails Environment" => Rails.env.to_s,
|
|
612
|
+
"Rails Version" => Rails.version,
|
|
613
|
+
"Ruby Version" => RUBY_DESCRIPTION,
|
|
614
|
+
"Process" => Process.pid,
|
|
615
|
+
"Server" => self.class.host_name
|
|
616
|
+
}.merge(filtered_env)
|
|
617
|
+
max_length = environment.keys.map(&:length).max || 0
|
|
618
|
+
write_attribute(:environment, environment.sort.map do |key, value|
|
|
619
|
+
"* %-*s: %s" % [max_length, key, value.to_s.strip.truncate(10_000)]
|
|
620
|
+
end.join("\n"))
|
|
621
|
+
|
|
622
|
+
method = if request.respond_to?(:request_method)
|
|
623
|
+
request.request_method
|
|
624
|
+
elsif request.respond_to?(:method)
|
|
625
|
+
request.method
|
|
626
|
+
else
|
|
627
|
+
"GET"
|
|
628
|
+
end
|
|
510
629
|
parameters = request.respond_to?(:parameters) ? RailsNexus.filter_sensitive_data(request.parameters) : {}
|
|
511
630
|
request_path = if request.respond_to?(:path)
|
|
512
631
|
request.path
|
|
@@ -515,12 +634,18 @@ module RailsNexus
|
|
|
515
634
|
else
|
|
516
635
|
"/"
|
|
517
636
|
end
|
|
637
|
+
request_id = request.respond_to?(:request_id) ? request.request_id : filtered_env["HTTP_X_REQUEST_ID"]
|
|
638
|
+
host = filtered_env["HTTP_HOST"] || filtered_env["SERVER_NAME"] || "localhost"
|
|
639
|
+
protocol = request.respond_to?(:protocol) ? request.protocol : "http://"
|
|
518
640
|
write_attribute(:request, [
|
|
519
|
-
"*
|
|
641
|
+
"* Method: #{method.to_s.upcase}",
|
|
642
|
+
"* URL: #{protocol}#{host}#{request_path}",
|
|
643
|
+
("* Request ID: #{request_id}" if request_id.present?),
|
|
520
644
|
"* Format: #{request.respond_to?(:format) ? request.format.to_s : "html"}",
|
|
645
|
+
("* Content Type: #{request.content_type}" if request.respond_to?(:content_type) && request.content_type.present?),
|
|
521
646
|
"* Parameters: #{parameters.inspect}",
|
|
522
647
|
"* Rails Root: #{rails_root}"
|
|
523
|
-
].join("\n"))
|
|
648
|
+
].compact.join("\n"))
|
|
524
649
|
else
|
|
525
650
|
write_attribute :request, request.to_s
|
|
526
651
|
end
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
to <span class="font-medium" style="color: var(--rn-text-secondary)"><%= @pagy.to %></span>
|
|
153
153
|
of <span class="font-medium" style="color: var(--rn-text-secondary)"><%= @pagy.count %></span>
|
|
154
154
|
</p>
|
|
155
|
-
<div><%==
|
|
155
|
+
<div><%== rails_nexus_pagy_nav(@pagy) %></div>
|
|
156
156
|
</div>
|
|
157
157
|
<% end %>
|
|
158
158
|
<% end %>
|
|
@@ -74,6 +74,9 @@
|
|
|
74
74
|
<% if @exception_tabs[:user_info] %>
|
|
75
75
|
<button type="button" class="rn-tab" data-action="click->rails_nexus-detail#switchTab" data-rn-tab-param="user_info">User Info</button>
|
|
76
76
|
<% end %>
|
|
77
|
+
<% if @exception_tabs[:diagnostic_context] %>
|
|
78
|
+
<button type="button" class="rn-tab" data-action="click->rails_nexus-detail#switchTab" data-rn-tab-param="diagnostic_context">Context</button>
|
|
79
|
+
<% end %>
|
|
77
80
|
<% if @exception.respond_to?(:cause_chain) && @exception.cause_chain.present? && @exception.cause_chain.is_a?(Array) && @exception.cause_chain.length > 0 %>
|
|
78
81
|
<button type="button" class="rn-tab" data-action="click->rails_nexus-detail#switchTab" data-rn-tab-param="cause_chain">
|
|
79
82
|
Cause Chain
|
|
@@ -142,6 +145,36 @@
|
|
|
142
145
|
<div class="rn-meta-value" style="font-size: 0.8125rem; word-break: break-all"><%= @exception.user_agent %></div>
|
|
143
146
|
</div>
|
|
144
147
|
<% end %>
|
|
148
|
+
<% if @exception.respond_to?(:fingerprint) && @exception.fingerprint.present? %>
|
|
149
|
+
<div class="rn-meta-item">
|
|
150
|
+
<div class="rn-meta-label">Fingerprint</div>
|
|
151
|
+
<div class="rn-meta-value mono"><%= @exception.fingerprint %></div>
|
|
152
|
+
</div>
|
|
153
|
+
<% end %>
|
|
154
|
+
<% if @exception.respond_to?(:occurrence_count) && @exception.occurrence_count.present? %>
|
|
155
|
+
<div class="rn-meta-item">
|
|
156
|
+
<div class="rn-meta-label">Occurrences</div>
|
|
157
|
+
<div class="rn-meta-value"><%= number_with_delimiter(@exception.occurrence_count) %></div>
|
|
158
|
+
</div>
|
|
159
|
+
<% end %>
|
|
160
|
+
<% if @exception.respond_to?(:platform) && @exception.platform.present? %>
|
|
161
|
+
<div class="rn-meta-item">
|
|
162
|
+
<div class="rn-meta-label">Platform</div>
|
|
163
|
+
<div class="rn-meta-value"><%= [@exception.platform, @exception.platform_version].compact.join(" ") %></div>
|
|
164
|
+
</div>
|
|
165
|
+
<% end %>
|
|
166
|
+
<% if @exception.respond_to?(:app_version) && @exception.app_version.present? %>
|
|
167
|
+
<div class="rn-meta-item">
|
|
168
|
+
<div class="rn-meta-label">App Version</div>
|
|
169
|
+
<div class="rn-meta-value mono"><%= @exception.app_version %></div>
|
|
170
|
+
</div>
|
|
171
|
+
<% end %>
|
|
172
|
+
<% if @exception.respond_to?(:user_id) && @exception.user_id.present? %>
|
|
173
|
+
<div class="rn-meta-item">
|
|
174
|
+
<div class="rn-meta-label">User ID</div>
|
|
175
|
+
<div class="rn-meta-value mono"><%= @exception.user_id %></div>
|
|
176
|
+
</div>
|
|
177
|
+
<% end %>
|
|
145
178
|
<div class="rn-meta-item">
|
|
146
179
|
<div class="rn-meta-label">Exception ID</div>
|
|
147
180
|
<div class="rn-meta-value mono"><%= @exception.id %></div>
|
|
@@ -342,6 +375,24 @@
|
|
|
342
375
|
<% end %>
|
|
343
376
|
</div>
|
|
344
377
|
|
|
378
|
+
<%# ─── Tab: Diagnostic Context ────────────────────────────── %>
|
|
379
|
+
<% if @exception_tabs[:diagnostic_context] %>
|
|
380
|
+
<div data-rails_nexus-detail-target="tabContent" data-rn-tab-content="diagnostic_context" style="display: none">
|
|
381
|
+
<% if @exception.respond_to?(:local_variables) && @exception.local_variables.present? %>
|
|
382
|
+
<div class="rn-detail-section">
|
|
383
|
+
<div class="rn-detail-section-header"><h3>Custom Exception Data</h3></div>
|
|
384
|
+
<div class="rn-detail-code" style="max-height: 24rem; overflow: auto"><%= pretty_format(@exception.local_variables) %></div>
|
|
385
|
+
</div>
|
|
386
|
+
<% end %>
|
|
387
|
+
<% if @exception.respond_to?(:instance_variables) && @exception.instance_variables.present? %>
|
|
388
|
+
<div class="rn-detail-section">
|
|
389
|
+
<div class="rn-detail-section-header"><h3>Exception Instance Variables</h3></div>
|
|
390
|
+
<div class="rn-detail-code" style="max-height: 24rem; overflow: auto"><%= pretty_format(@exception.instance_variables) %></div>
|
|
391
|
+
</div>
|
|
392
|
+
<% end %>
|
|
393
|
+
</div>
|
|
394
|
+
<% end %>
|
|
395
|
+
|
|
345
396
|
</div>
|
|
346
397
|
|
|
347
398
|
<%# ─── Tab: Cause Chain ────────────────────────────────────── %>
|
data/lib/rails_nexus/logger.rb
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module RailsNexus
|
|
4
6
|
class Logger
|
|
5
|
-
# Log levels
|
|
6
7
|
LEVELS = %i[debug info warn error fatal].freeze
|
|
8
|
+
MAX_CAUSE_DEPTH = 10
|
|
9
|
+
MAX_VALUE_DEPTH = 5
|
|
10
|
+
MAX_STRING_LENGTH = 10_000
|
|
7
11
|
|
|
8
|
-
# Default context that's attached to every log entry
|
|
9
12
|
DEFAULT_CONTEXT = {
|
|
10
13
|
gem: "rails_nexus",
|
|
11
14
|
version: RailsNexus::VERSION
|
|
12
15
|
}.freeze
|
|
13
16
|
|
|
14
17
|
class << self
|
|
15
|
-
# Log an exception with full context
|
|
16
|
-
#
|
|
17
|
-
# RailsNexus::Logger.error(exception, controller: self, extra: { key: "value" })
|
|
18
|
-
#
|
|
19
18
|
def error(exception, controller: nil, extra: {}, **kwargs)
|
|
20
19
|
log(:error, exception, controller: controller, extra: extra, **kwargs)
|
|
21
20
|
end
|
|
@@ -24,6 +23,10 @@ module RailsNexus
|
|
|
24
23
|
log(:warn, exception, controller: controller, extra: extra, **kwargs)
|
|
25
24
|
end
|
|
26
25
|
|
|
26
|
+
def fatal(exception, controller: nil, extra: {}, **kwargs)
|
|
27
|
+
log(:fatal, exception, controller: controller, extra: extra, **kwargs)
|
|
28
|
+
end
|
|
29
|
+
|
|
27
30
|
def info(message, controller: nil, extra: {}, **kwargs)
|
|
28
31
|
log(:info, message, controller: controller, extra: extra, **kwargs)
|
|
29
32
|
end
|
|
@@ -32,145 +35,272 @@ module RailsNexus
|
|
|
32
35
|
log(:debug, message, controller: controller, extra: extra, **kwargs)
|
|
33
36
|
end
|
|
34
37
|
|
|
35
|
-
# Log a custom event (not an exception)
|
|
36
|
-
#
|
|
37
|
-
# RailsNexus::Logger.event("user.login", user_id: 123, method: "POST")
|
|
38
|
-
#
|
|
39
38
|
def event(name, data = {})
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
return unless loggable?(:info)
|
|
40
|
+
|
|
41
|
+
entry = build_entry(:info, name.to_s, extra: data)
|
|
42
|
+
write(entry)
|
|
43
43
|
entry
|
|
44
|
+
rescue StandardError => error
|
|
45
|
+
report_internal_error(error)
|
|
46
|
+
nil
|
|
44
47
|
end
|
|
45
48
|
|
|
46
|
-
# Query logs with filters
|
|
47
|
-
#
|
|
48
|
-
# RailsNexus::Logger.query(level: :error, since: 1.hour.ago, limit: 50)
|
|
49
|
-
#
|
|
50
49
|
def query(level: nil, since: nil, controller_name: nil, limit: 100)
|
|
51
50
|
scope = RailsNexus::LoggedException.order(created_at: :desc)
|
|
52
51
|
scope = scope.where("created_at >= ?", since) if since.present?
|
|
53
|
-
scope = scope.
|
|
52
|
+
scope = scope.none if level.present? && level.to_sym != :error
|
|
54
53
|
scope = scope.where(controller_name: controller_name) if controller_name.present?
|
|
55
54
|
scope.limit(limit)
|
|
56
55
|
end
|
|
57
56
|
|
|
58
|
-
# Get aggregated stats
|
|
59
|
-
#
|
|
60
|
-
# RailsNexus::Logger.stats(since: 24.hours.ago)
|
|
61
|
-
#
|
|
62
57
|
def stats(since: nil)
|
|
63
58
|
scope = RailsNexus::LoggedException.all
|
|
64
59
|
scope = scope.where("created_at >= ?", since) if since.present?
|
|
65
60
|
|
|
66
61
|
{
|
|
67
62
|
total: scope.count,
|
|
63
|
+
occurrences: scope.sum(:occurrence_count),
|
|
68
64
|
by_class: scope.group(:exception_class).count,
|
|
69
65
|
by_controller: scope.group(:controller_name).count,
|
|
70
|
-
by_hour: scope.group_by { |
|
|
71
|
-
top_exceptions: scope.group(:exception_class).count.sort_by { |_,
|
|
66
|
+
by_hour: scope.group_by { |entry| entry.created_at.hour }.transform_values(&:count),
|
|
67
|
+
top_exceptions: scope.group(:exception_class).count.sort_by { |_, count| -count }.first(10),
|
|
68
|
+
occurrences_by_class: scope.group(:exception_class).sum(:occurrence_count),
|
|
69
|
+
occurrences_by_controller: scope.group(:controller_name).sum(:occurrence_count)
|
|
72
70
|
}
|
|
73
71
|
end
|
|
74
72
|
|
|
75
73
|
private
|
|
76
74
|
|
|
77
75
|
def log(level, exception_or_message, controller: nil, extra: {}, **kwargs)
|
|
78
|
-
|
|
79
|
-
exception_class = exception_or_message.is_a?(Exception) ? exception_or_message.class.name : nil
|
|
76
|
+
return unless loggable?(level)
|
|
80
77
|
|
|
81
|
-
|
|
78
|
+
exception = exception_or_message if exception_or_message.is_a?(Exception)
|
|
79
|
+
entry = build_entry(
|
|
80
|
+
level,
|
|
81
|
+
exception ? exception.message.to_s : exception_or_message.to_s,
|
|
82
|
+
exception: exception,
|
|
82
83
|
controller: controller,
|
|
83
|
-
extra: extra
|
|
84
|
-
backtrace: exception_or_message.is_a?(Exception) ? exception_or_message.backtrace&.first(20)&.join("\n") : nil
|
|
84
|
+
extra: merge_extra(extra, kwargs)
|
|
85
85
|
)
|
|
86
|
-
|
|
87
|
-
write_to_output(entry)
|
|
88
|
-
write_to_file(entry) if RailsNexus.configuration.log_file.present?
|
|
86
|
+
write(entry)
|
|
89
87
|
entry
|
|
88
|
+
rescue StandardError => error
|
|
89
|
+
report_internal_error(error)
|
|
90
|
+
nil
|
|
90
91
|
end
|
|
91
92
|
|
|
92
|
-
def build_entry(level, message,
|
|
93
|
+
def build_entry(level, message, exception: nil, controller: nil, extra: {})
|
|
93
94
|
entry = DEFAULT_CONTEXT.merge(
|
|
94
95
|
level: level,
|
|
95
|
-
message: message,
|
|
96
|
-
timestamp: Time.current.iso8601
|
|
96
|
+
message: truncate(message),
|
|
97
|
+
timestamp: Time.current.iso8601(6),
|
|
98
|
+
runtime: runtime_context
|
|
97
99
|
)
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
if exception
|
|
102
|
+
entry[:exception] = exception_context(exception)
|
|
103
|
+
# Preserve the original public entry keys while also providing the
|
|
104
|
+
# richer nested exception object.
|
|
105
|
+
entry[:exception_class] = entry.dig(:exception, :class)
|
|
106
|
+
entry[:backtrace] = entry.dig(:exception, :backtrace)&.join("\n")
|
|
107
|
+
end
|
|
100
108
|
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
if controller
|
|
110
|
+
entry[:request] = request_context(controller)
|
|
103
111
|
entry[:controller] = {
|
|
104
112
|
class: controller.class.name,
|
|
105
|
-
action:
|
|
106
|
-
path:
|
|
107
|
-
method:
|
|
108
|
-
remote_ip:
|
|
109
|
-
user_agent:
|
|
113
|
+
action: entry.dig(:request, :action),
|
|
114
|
+
path: entry.dig(:request, :url),
|
|
115
|
+
method: entry.dig(:request, :method),
|
|
116
|
+
remote_ip: entry.dig(:request, :remote_ip),
|
|
117
|
+
user_agent: entry.dig(:request, :user_agent)
|
|
118
|
+
}.compact
|
|
119
|
+
entry[:params] = entry.dig(:request, :params) if entry.dig(:request, :params)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
user = user_context(controller)
|
|
123
|
+
entry[:user] = user if user.present?
|
|
124
|
+
|
|
125
|
+
filtered_extra = safe_value(RailsNexus.filter_sensitive_data(extra))
|
|
126
|
+
entry[:extra] = filtered_extra if filtered_extra.present?
|
|
127
|
+
entry.compact
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def exception_context(exception)
|
|
131
|
+
context = {
|
|
132
|
+
class: exception.class.name,
|
|
133
|
+
message: truncate(exception.message.to_s),
|
|
134
|
+
source: exception.backtrace&.first
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if RailsNexus.configuration.log_backtrace
|
|
138
|
+
limit = positive_integer(RailsNexus.configuration.log_backtrace_limit, 20)
|
|
139
|
+
context[:backtrace] = Array(exception.backtrace).first(limit)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
causes = []
|
|
143
|
+
current = exception.cause
|
|
144
|
+
while current && causes.length < MAX_CAUSE_DEPTH
|
|
145
|
+
causes << {
|
|
146
|
+
class: current.class.name,
|
|
147
|
+
message: truncate(current.message.to_s),
|
|
148
|
+
source: current.backtrace&.first
|
|
110
149
|
}
|
|
150
|
+
current = current.cause
|
|
151
|
+
end
|
|
152
|
+
context[:causes] = causes if causes.any?
|
|
153
|
+
context.compact
|
|
154
|
+
end
|
|
111
155
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
156
|
+
def request_context(controller)
|
|
157
|
+
request = safe_call(controller, :request)
|
|
158
|
+
context = {
|
|
159
|
+
controller: safe_call(controller, :controller_path) || controller.class.name,
|
|
160
|
+
action: safe_call(controller, :action_name)
|
|
161
|
+
}
|
|
162
|
+
return context.compact unless request
|
|
119
163
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
164
|
+
context.merge!(
|
|
165
|
+
request_id: safe_call(request, :request_id),
|
|
166
|
+
correlation_id: safe_call(request, :get_header, "HTTP_X_CORRELATION_ID"),
|
|
167
|
+
traceparent: safe_call(request, :get_header, "HTTP_TRACEPARENT"),
|
|
168
|
+
method: safe_call(request, :request_method) || safe_call(request, :method),
|
|
169
|
+
url: safe_call(request, :original_url) || safe_call(request, :url) || safe_call(request, :fullpath) || safe_call(request, :path),
|
|
170
|
+
format: request_format(request),
|
|
171
|
+
remote_ip: safe_call(request, :remote_ip),
|
|
172
|
+
user_agent: safe_call(request, :user_agent),
|
|
173
|
+
referer: safe_call(request, :referer),
|
|
174
|
+
content_type: safe_call(request, :content_type),
|
|
175
|
+
content_length: safe_call(request, :content_length)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if RailsNexus.configuration.log_params
|
|
179
|
+
params = safe_call(request, :filtered_parameters) || safe_call(request, :parameters)
|
|
180
|
+
context[:params] = safe_value(RailsNexus.filter_sensitive_data(params)) if params
|
|
124
181
|
end
|
|
125
182
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
entry[:backtrace] = backtrace if backtrace.present?
|
|
183
|
+
context.compact
|
|
184
|
+
end
|
|
129
185
|
|
|
130
|
-
|
|
186
|
+
def user_context(controller)
|
|
187
|
+
return {} unless controller && RailsNexus.configuration.log_user_info
|
|
188
|
+
return {} unless controller.respond_to?(:current_user, true)
|
|
189
|
+
|
|
190
|
+
user = controller.send(:current_user)
|
|
191
|
+
return {} unless user
|
|
192
|
+
|
|
193
|
+
{
|
|
194
|
+
id: safe_call(user, :id),
|
|
195
|
+
type: user.class.name,
|
|
196
|
+
email: safe_call(user, :email)
|
|
197
|
+
}.compact
|
|
198
|
+
rescue StandardError
|
|
199
|
+
{}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def runtime_context
|
|
203
|
+
{
|
|
204
|
+
environment: defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.to_s : nil,
|
|
205
|
+
hostname: Socket.gethostname,
|
|
206
|
+
pid: Process.pid,
|
|
207
|
+
thread_id: Thread.current.object_id,
|
|
208
|
+
ruby_version: RUBY_VERSION,
|
|
209
|
+
rails_version: defined?(Rails::VERSION) ? Rails::VERSION::STRING : nil
|
|
210
|
+
}.compact
|
|
211
|
+
rescue StandardError
|
|
212
|
+
{ pid: Process.pid, ruby_version: RUBY_VERSION }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def write(entry)
|
|
216
|
+
write_to_output(entry)
|
|
217
|
+
write_to_file(entry) if RailsNexus.configuration.log_file.present?
|
|
131
218
|
end
|
|
132
219
|
|
|
133
220
|
def write_to_output(entry)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
when :debug then "\e[37m" # white
|
|
137
|
-
when :info then "\e[36m" # cyan
|
|
138
|
-
when :warn then "\e[33m" # yellow
|
|
139
|
-
when :error then "\e[31m" # red
|
|
140
|
-
when :fatal then "\e[35m" # magenta
|
|
141
|
-
else "\e[0m"
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
reset = "\e[0m"
|
|
145
|
-
ts = entry[:timestamp].sub(/T/, " ").sub(/\+.*/, "")
|
|
146
|
-
msg = entry[:message]
|
|
147
|
-
cls = entry[:exception_class]
|
|
148
|
-
|
|
149
|
-
line = "#{color}[RailsNexus]#{reset} #{ts} #{level.to_s.upcase.ljust(5)}"
|
|
150
|
-
line += " #{cls}:" if cls.present?
|
|
151
|
-
line += " #{msg}"
|
|
152
|
-
|
|
153
|
-
if entry[:controller]
|
|
154
|
-
ctrl = entry[:controller]
|
|
155
|
-
line += " [#{ctrl[:class]}##{ctrl[:action]}]"
|
|
156
|
-
end
|
|
221
|
+
logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
222
|
+
return unless logger
|
|
157
223
|
|
|
158
|
-
|
|
224
|
+
logger.public_send(entry[:level], "[RailsNexus] #{JSON.generate(entry)}")
|
|
159
225
|
end
|
|
160
226
|
|
|
161
227
|
def write_to_file(entry)
|
|
162
228
|
file = RailsNexus.configuration.log_file
|
|
163
229
|
return unless file.present?
|
|
164
230
|
|
|
165
|
-
File.open(file, "a")
|
|
166
|
-
|
|
231
|
+
File.open(file, "a") { |io| io.puts(JSON.generate(entry)) }
|
|
232
|
+
rescue StandardError => error
|
|
233
|
+
report_internal_error(error, operation: "write log file")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def loggable?(level)
|
|
237
|
+
config = RailsNexus.configuration
|
|
238
|
+
return false unless config.logging_enabled
|
|
239
|
+
|
|
240
|
+
requested = LEVELS.index(level.to_sym)
|
|
241
|
+
configured = LEVELS.index(config.log_level.to_sym) || LEVELS.index(:info)
|
|
242
|
+
requested && requested >= configured
|
|
243
|
+
rescue StandardError
|
|
244
|
+
true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def merge_extra(extra, kwargs)
|
|
248
|
+
base = extra.is_a?(Hash) ? extra : { data: extra }
|
|
249
|
+
base.merge(kwargs)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def request_format(request)
|
|
253
|
+
format = safe_call(request, :format)
|
|
254
|
+
return unless format
|
|
255
|
+
|
|
256
|
+
safe_call(format, :ref)&.to_s || format.to_s
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def safe_call(object, method_name, *args)
|
|
260
|
+
object.public_send(method_name, *args) if object.respond_to?(method_name)
|
|
261
|
+
rescue StandardError
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def safe_value(value, depth = 0, seen = {})
|
|
266
|
+
return "[MAX DEPTH]" if depth >= MAX_VALUE_DEPTH
|
|
267
|
+
return value if value.nil? || value == true || value == false || value.is_a?(Numeric)
|
|
268
|
+
return truncate(value) if value.is_a?(String) || value.is_a?(Symbol)
|
|
269
|
+
return value.iso8601 if value.respond_to?(:iso8601) && (value.is_a?(Time) || value.is_a?(Date))
|
|
270
|
+
|
|
271
|
+
object_id = value.object_id
|
|
272
|
+
return "[CIRCULAR]" if seen[object_id]
|
|
273
|
+
seen[object_id] = true
|
|
274
|
+
|
|
275
|
+
case value
|
|
276
|
+
when Hash
|
|
277
|
+
value.each_with_object({}) do |(key, child), result|
|
|
278
|
+
result[truncate(key.to_s, 200)] = safe_value(child, depth + 1, seen)
|
|
279
|
+
end
|
|
280
|
+
when Array
|
|
281
|
+
value.first(100).map { |child| safe_value(child, depth + 1, seen) }
|
|
282
|
+
else
|
|
283
|
+
truncate(value.to_s)
|
|
167
284
|
end
|
|
168
|
-
|
|
169
|
-
|
|
285
|
+
ensure
|
|
286
|
+
seen&.delete(object_id) if defined?(object_id) && object_id
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def truncate(value, limit = MAX_STRING_LENGTH)
|
|
290
|
+
string = value.to_s
|
|
291
|
+
string.length > limit ? "#{string[0, limit]}...[truncated]" : string
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def positive_integer(value, fallback)
|
|
295
|
+
parsed = Integer(value, exception: false)
|
|
296
|
+
parsed && parsed.positive? ? parsed : fallback
|
|
170
297
|
end
|
|
171
298
|
|
|
172
|
-
def
|
|
173
|
-
|
|
299
|
+
def report_internal_error(error, operation: "log exception")
|
|
300
|
+
logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
301
|
+
logger&.error("[RailsNexus] Failed to #{operation} (#{error.class})")
|
|
302
|
+
rescue StandardError
|
|
303
|
+
nil
|
|
174
304
|
end
|
|
175
305
|
end
|
|
176
306
|
end
|
data/lib/rails_nexus/version.rb
CHANGED
data/lib/rails_nexus.rb
CHANGED
|
@@ -3,6 +3,7 @@ require "active_support/parameter_filter"
|
|
|
3
3
|
require "turbo-rails"
|
|
4
4
|
require "stimulus-rails"
|
|
5
5
|
require "pagy"
|
|
6
|
+
require "ransack"
|
|
6
7
|
require "ipaddr"
|
|
7
8
|
require "socket"
|
|
8
9
|
require "open3"
|
|
@@ -102,13 +103,25 @@ module RailsNexus
|
|
|
102
103
|
|
|
103
104
|
# we log the exception and raise it again, for the normal handling.
|
|
104
105
|
def log_exception_handler(exception)
|
|
105
|
-
|
|
106
|
+
if exception.is_a?(StandardError)
|
|
107
|
+
begin
|
|
108
|
+
log_exception(exception)
|
|
109
|
+
rescue StandardError => logging_error
|
|
110
|
+
Rails.logger&.error("[RailsNexus] Failed to capture exception (#{logging_error.class})")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
106
113
|
raise exception
|
|
107
114
|
end
|
|
108
115
|
|
|
109
116
|
def rescue_action(exception)
|
|
110
117
|
status = response_code_for_rescue(exception)
|
|
111
|
-
|
|
118
|
+
if exception.is_a?(StandardError) && status != :not_found
|
|
119
|
+
begin
|
|
120
|
+
log_exception(exception)
|
|
121
|
+
rescue StandardError => logging_error
|
|
122
|
+
Rails.logger&.error("[RailsNexus] Failed to capture exception (#{logging_error.class})")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
112
125
|
super
|
|
113
126
|
end
|
|
114
127
|
|
|
@@ -122,11 +135,16 @@ module RailsNexus
|
|
|
122
135
|
RailsNexus.configuration.exception_data
|
|
123
136
|
end
|
|
124
137
|
|
|
125
|
-
data =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
138
|
+
data = begin
|
|
139
|
+
case deliverer
|
|
140
|
+
when nil then {}
|
|
141
|
+
when Symbol then send(deliverer)
|
|
142
|
+
when Proc then deliverer.call(self)
|
|
143
|
+
else deliverer.respond_to?(:call) ? deliverer.call(self) : {}
|
|
144
|
+
end
|
|
145
|
+
rescue StandardError => data_error
|
|
146
|
+
{ exception_data_error: data_error.class.name }
|
|
147
|
+
end
|
|
130
148
|
|
|
131
149
|
data = RailsNexus.filter_sensitive_data(data || {})
|
|
132
150
|
|
data/rails_nexus.gemspec
CHANGED
|
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
|
|
|
28
28
|
spec.add_dependency "rails", "~> 8.0", ">= 8.0.0"
|
|
29
29
|
spec.add_dependency "stimulus-rails", "~> 1.3"
|
|
30
30
|
spec.add_dependency "turbo-rails", "~> 2.0"
|
|
31
|
-
spec.add_dependency "pagy", "
|
|
31
|
+
spec.add_dependency "pagy", ">= 43.0"
|
|
32
32
|
|
|
33
33
|
spec.add_dependency "ransack", "~> 4.0"
|
|
34
34
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_nexus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.1.
|
|
4
|
+
version: 2.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tamiru Hailu
|
|
@@ -61,16 +61,16 @@ dependencies:
|
|
|
61
61
|
name: pagy
|
|
62
62
|
requirement: !ruby/object:Gem::Requirement
|
|
63
63
|
requirements:
|
|
64
|
-
- - "
|
|
64
|
+
- - ">="
|
|
65
65
|
- !ruby/object:Gem::Version
|
|
66
|
-
version: '
|
|
66
|
+
version: '43.0'
|
|
67
67
|
type: :runtime
|
|
68
68
|
prerelease: false
|
|
69
69
|
version_requirements: !ruby/object:Gem::Requirement
|
|
70
70
|
requirements:
|
|
71
|
-
- - "
|
|
71
|
+
- - ">="
|
|
72
72
|
- !ruby/object:Gem::Version
|
|
73
|
-
version: '
|
|
73
|
+
version: '43.0'
|
|
74
74
|
- !ruby/object:Gem::Dependency
|
|
75
75
|
name: ransack
|
|
76
76
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -212,7 +212,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
212
212
|
- !ruby/object:Gem::Version
|
|
213
213
|
version: '0'
|
|
214
214
|
requirements: []
|
|
215
|
-
rubygems_version:
|
|
215
|
+
rubygems_version: 4.0.10
|
|
216
216
|
specification_version: 4
|
|
217
217
|
summary: The extensible control plane for Rails applications
|
|
218
218
|
test_files: []
|