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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68f6936b492e804cacc457cb32a3c211f46a1200b57efe2c478fa3c6769fa7e2
4
- data.tar.gz: 85fcf9d42acb0def8427800684cffffccfbe03b63791bbe4c3d1938c9cef33da
3
+ metadata.gz: 906957a4703c0c553eca5cb3966cccec4c71b117a07653e17c648ff96098e819
4
+ data.tar.gz: 2bc584d69992b1c1dd9947b266db3541ced28b24a9be7af5dddc909ad9a2eb05
5
5
  SHA512:
6
- metadata.gz: 0fe9502389240d9c4f486613ca0730da70fa0f4d932b0b86359f6d8587d5c456e16ac90f8caaeea9c9bb204e77a359c3fbf493ebeb5244119a1071d228ccac01
7
- data.tar.gz: 7c6bab5a7baafe7dbc87fc89d2c6cf2de10137906cc910b4b2fcbb5f935f4ec89923ee6d97fa592ffc3ebbd6eca4e9e38a1e8ac07d1b96bcac1212f680a3f276
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::Backend
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 = pagy(cron_jobs, items: page_size)
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 = pagy(@q.result(distinct: true), items: page_size)
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 unless method_defined?(:cause_chain)
9
- serialize :breadcrumbs, coder: JSON unless method_defined?(:breadcrumbs)
10
- serialize :system_health, coder: JSON unless method_defined?(:system_health)
11
- serialize :local_variables, coder: JSON unless method_defined?(:local_variables)
12
- serialize :instance_variables, coder: JSON unless method_defined?(:instance_variables)
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.respond_to?(:current_user, true) ? controller.current_user : nil
61
- user_id = user&.id&.to_s || data[:user_id]&.to_s rescue nil
62
- user_type = user.class.name rescue nil
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
- controller.controller_path,
68
- controller.action_name
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: controller.controller_path,
77
- action_name: controller.action_name,
86
+ controller_name: controller_name,
87
+ action_name: action_name,
78
88
  message: message,
79
89
  backtrace: exception.backtrace,
80
- request: controller.request,
81
- user_info: user,
82
- remote_ip: controller.request.remote_ip,
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
- cause_chain: attrs[:cause_chain],
104
- system_health: attrs[:system_health]
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
- Digest::SHA256.hexdigest("#{exception_class}#{controller_name}#{action_name}")[0..15]
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
- if user_agent.blank? || request.format&.ref == "json" || request.format&.ref == "xml"
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
- filtered_env = RailsNexus.filter_sensitive_data(request.env)
501
- keys = filtered_env.keys.map(&:to_s).sort
502
- max_length = keys.map(&:length).max || 0
503
- env = keys.each_with_object([]) do |key, memo|
504
- value = filtered_env[key] || filtered_env[key.to_sym]
505
- memo << ("* %-*s: %s" % [max_length, key, value.to_s.strip])
506
- end
507
- write_attribute(:environment, (env << "* Process: #{$$}" << "* Server : #{self.class.host_name}").join("\n"))
508
-
509
- method_str = request.respond_to?(:get?) && request.get? ? "" : " #{request.respond_to?(:method) ? request.method.to_s.upcase : "GET"}"
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
- "* URL:#{method_str} #{request.respond_to?(:protocol) ? request.protocol : "http://"}#{filtered_env["HTTP_HOST"] || "localhost"}#{request_path}",
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
@@ -133,7 +133,7 @@
133
133
  <%# Pagination %>
134
134
  <% if @pagy.pages > 1 %>
135
135
  <div class="rn-pagination">
136
- <%== pagy_nav(@pagy) %>
136
+ <%== rails_nexus_pagy_nav(@pagy) %>
137
137
  </div>
138
138
  <% end %>
139
139
  <% 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><%== pagy_nav(@pagy) %></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 ────────────────────────────────────── %>
@@ -115,7 +115,7 @@
115
115
  of <span class="font-medium text-gray-600"><%= @pagy.count %></span>
116
116
  </p>
117
117
  <div>
118
- <%== pagy_nav(@pagy) %>
118
+ <%== rails_nexus_pagy_nav(@pagy) %>
119
119
  </div>
120
120
  </div>
121
121
  <% end %>
@@ -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
- entry = build_entry(:info, name, nil, extra: data)
41
- write_to_output(entry)
42
- write_to_file(entry) if RailsNexus.configuration.log_file.present?
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.where(exception_class: level.to_s.camelize) if level.present?
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 { |e| e.created_at.hour }.transform_values(&:count),
71
- top_exceptions: scope.group(:exception_class).count.sort_by { |_, v| -v }.first(10)
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
- message = exception_or_message.is_a?(Exception) ? exception_or_message.message : exception_or_message.to_s
79
- exception_class = exception_or_message.is_a?(Exception) ? exception_or_message.class.name : nil
76
+ return unless loggable?(level)
80
77
 
81
- entry = build_entry(level, message, exception_class,
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.merge(kwargs),
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, exception_class, controller: nil, extra: {}, backtrace: nil)
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
- entry[:exception_class] = exception_class if exception_class.present?
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
- # Add controller context
102
- if controller.present?
109
+ if controller
110
+ entry[:request] = request_context(controller)
103
111
  entry[:controller] = {
104
112
  class: controller.class.name,
105
- action: controller.action_name,
106
- path: controller.request&.path,
107
- method: controller.request&.request_method,
108
- remote_ip: controller.request&.remote_ip,
109
- user_agent: controller.request&.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
- # Add user context
113
- if controller.respond_to?(:current_user, true) && controller.current_user.present?
114
- entry[:user] = {
115
- id: controller.current_user.id,
116
- email: controller.current_user.email
117
- }
118
- end
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
- # Add request params (filtered)
121
- if controller.request.present?
122
- entry[:params] = filter_params(controller.request.filtered_parameters)
123
- end
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
- # Add extra metadata
127
- entry[:extra] = extra if extra.present?
128
- entry[:backtrace] = backtrace if backtrace.present?
183
+ context.compact
184
+ end
129
185
 
130
- entry
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
- level = entry[:level]
135
- color = case level
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
- Rails.logger.send(level, line)
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") do |f|
166
- f.puts(entry.to_json)
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
- rescue StandardError => e
169
- Rails.logger.error("[RailsNexus] Failed to write to log file: #{e.message}")
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 filter_params(params)
173
- RailsNexus.filter_sensitive_data(params)
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
@@ -1,3 +1,3 @@
1
1
  module RailsNexus
2
- VERSION = "2.1.4"
2
+ VERSION = "2.1.5"
3
3
  end
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
- log_exception(exception) if exception.is_a?(StandardError)
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
- log_exception(exception) if exception.is_a?(StandardError) && status != :not_found
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 = case deliverer
126
- when nil then {}
127
- when Symbol then send(deliverer)
128
- when Proc then deliverer.call(self)
129
- end
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", "~> 6.5"
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
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: '6.5'
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: '6.5'
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: 3.6.9
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: []