rails_error_dashboard 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -3
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +29 -2
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
  5. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +7 -14
  6. data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
  7. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +5 -0
  8. data/app/models/rails_error_dashboard/error_log.rb +11 -8
  9. data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
  10. data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
  11. data/app/views/layouts/rails_error_dashboard.html.erb +3 -1
  12. data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
  13. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +10 -3
  14. data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
  15. data/app/views/rails_error_dashboard/errors/overview.html.erb +10 -7
  16. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
  17. data/app/views/rails_error_dashboard/errors/settings.html.erb +6 -5
  18. data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
  19. data/config/locales/de.yml +19 -0
  20. data/config/locales/en.yml +21 -0
  21. data/config/locales/es.yml +20 -0
  22. data/config/locales/fr.yml +20 -0
  23. data/config/locales/it.yml +20 -0
  24. data/config/locales/ja.yml +18 -0
  25. data/config/locales/pl.yml +21 -0
  26. data/config/locales/pt-BR.yml +20 -0
  27. data/config/locales/ru.yml +21 -0
  28. data/config/locales/uk.yml +21 -0
  29. data/config/locales/zh-CN.yml +18 -0
  30. data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
  31. data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
  32. data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
  33. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +14 -5
  34. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
  35. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +7 -2
  36. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
  37. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
  38. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +2 -4
  39. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
  40. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
  41. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
  42. data/lib/rails_error_dashboard/commands/log_error.rb +19 -7
  43. data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
  44. data/lib/rails_error_dashboard/configuration.rb +19 -3
  45. data/lib/rails_error_dashboard/error_reporter.rb +4 -2
  46. data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
  47. data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
  48. data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
  49. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
  50. data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
  51. data/lib/rails_error_dashboard/services/backtrace_processor.rb +16 -7
  52. data/lib/rails_error_dashboard/services/crash_capture.rb +5 -0
  53. data/lib/rails_error_dashboard/services/github_link_generator.rb +1 -6
  54. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
  55. data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
  56. data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
  57. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
  58. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
  59. data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
  60. data/lib/rails_error_dashboard/version.rb +1 -1
  61. data/lib/rails_error_dashboard.rb +16 -0
  62. data/lib/tasks/error_dashboard.rake +2 -3
  63. metadata +10 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4e3190d52a36d5c36abb07855ac5090194c0e7d3e426f38565724d60c4d54c2
4
- data.tar.gz: 22c63cf4f49ee2f97b6898b3ab19e81bdf45f5c382a29c6c771c5d462d760dec
3
+ metadata.gz: 84ad49626b8dddc0665ed515f3fc8e3357adadddcb5991679324cffa40d2aa2e
4
+ data.tar.gz: af06a63fb80ded2960e23f6e6c25ace1a741b85e13f014ae4bfbfd4b4b16980a
5
5
  SHA512:
6
- metadata.gz: fd736e679c40f293b58ad44b47f3215d001e45cd13c322d18f4033d178c8bc91242c018ccff8b676797d3282e1aa321e2ab15cc96050eabdd8bb3df42f6b7f63
7
- data.tar.gz: '0696afc78d045942b741eaa166054455bd36b2962fb097cd67b7db8817c749243e3c70de3737c2e04aedd4b7cb52cd18ffb4d360a0b1d4805ff69e1ca4059716'
6
+ metadata.gz: c3154ebfa3b1d46d497a86cfca33dca5a09ed98127a699e7a9eb17ce3d05380f807172b377a2a824fbffff806e6f1e9a6c6ba96e8cb61802368e81ffc56ea93a
7
+ data.tar.gz: 571ca72a5e588371357a427c02a2ebed3bf565cf70f03b8c8e5877245e060bd7d8ddff873ae50235ac1c4103c6944d4f5e852c221766f7292225b4af8406ceca
data/README.md CHANGED
@@ -394,7 +394,7 @@ config.git_repository_url = "https://github.com/user/repo" # enables SHA links
394
394
  <details>
395
395
  <summary><strong>Source Code Integration + Git Blame</strong></summary>
396
396
 
397
- View actual source code directly in error backtraces with +/-7 lines of context. Git blame shows who last modified the code, when, and the commit message. Repository links jump to GitHub/GitLab/Bitbucket at the exact line.
397
+ View actual source code directly in error backtraces, 5 lines before and after the error line by default. Git blame shows who last modified the code, when, and the commit message. Repository links jump to the exact line on GitHub, GitLab, Bitbucket or Codeberg.
398
398
 
399
399
  ```ruby
400
400
  config.enable_source_code_integration = true
@@ -577,14 +577,18 @@ config.enable_crash_capture = true
577
577
  <details>
578
578
  <summary><strong>Plugin System</strong></summary>
579
579
 
580
- Event-driven extensibility with hooks for `on_error_logged`, `on_error_resolved`, `on_threshold_exceeded`. Built-in examples for Jira integration, metrics tracking, and audit logging.
580
+ Event-driven extensibility with hooks such as `on_error_logged`, `on_error_reopened` and `on_error_resolved` (eleven in all). Built-in examples for Jira integration, metrics tracking, and audit logging.
581
581
 
582
582
  ```ruby
583
583
  class MyPlugin < RailsErrorDashboard::Plugin
584
+ def name = "My Plugin"
585
+
584
586
  def on_error_logged(error_log)
585
587
  # Your custom logic
586
588
  end
587
589
  end
590
+
591
+ RailsErrorDashboard.register_plugin(MyPlugin.new)
588
592
  ```
589
593
 
590
594
  [Plugin System guide →](docs/PLUGIN_SYSTEM.md)
@@ -786,7 +790,7 @@ Rails 7.0–8.1 and Ruby 3.2–4.0.
786
790
  ### Advanced
787
791
  - **[Multi-App Support](docs/MULTI_APP_PERFORMANCE.md)** — Track multiple applications
788
792
  - **[Plugin System](docs/PLUGIN_SYSTEM.md)** — Build custom integrations
789
- - **[API Reference](docs/API_REFERENCE.md)** — Complete API documentation
793
+ - **[API Reference](docs/API_REFERENCE.md)** — The dashboard's HTTP routes and the Ruby API
790
794
  - **[Customization](docs/CUSTOMIZATION.md)** — Customize everything
791
795
  - **[Database Options](docs/guides/DATABASE_OPTIONS.md)** — Separate database setup
792
796
  - **[Database Optimization](docs/guides/DATABASE_OPTIMIZATION.md)** — Performance tuning
@@ -119,6 +119,11 @@ module RailsErrorDashboard
119
119
  @related_errors = @error.related_errors(limit: 5, application_id: @current_application_id)
120
120
  @error_markdown = Services::MarkdownErrorFormatter.call(@error, related_errors: @related_errors)
121
121
 
122
+ # ?occurrence=ID: the context cards show that occurrence's stored context
123
+ @occurrence_context = find_occurrence_context(@error)
124
+ @snapshot = @occurrence_context ? ValueObjects::OccurrenceSnapshot.build(@error, @occurrence_context) : @error
125
+ @captured_occurrences = captured_occurrences(@error)
126
+
122
127
  # Fetch platform issue state and comments if linked
123
128
  @platform_issue = fetch_platform_issue(@error)
124
129
  @platform_comments = fetch_platform_comments(@error)
@@ -279,8 +284,6 @@ module RailsErrorDashboard
279
284
  @errors_by_hour = analytics[:errors_by_hour]
280
285
  @top_users = analytics[:top_users]
281
286
  @resolution_rate = analytics[:resolution_rate]
282
- @mobile_errors = analytics[:mobile_errors]
283
- @api_errors = analytics[:api_errors]
284
287
 
285
288
  # Get recurring issues data (pass application filter)
286
289
  recurring = Queries::RecurringIssues.call(days, application_id: @current_application_id)
@@ -901,6 +904,30 @@ module RailsErrorDashboard
901
904
  red_t("red.errors.row.status.#{status}", default: status.to_s.tr("_", " "))
902
905
  end
903
906
 
907
+ # Looked up through the error, so another error's occurrence id finds
908
+ # nothing. nil (the page shows the latest snapshot) when there is no id, it
909
+ # is foreign or unknown, or its context is no longer kept.
910
+ def find_occurrence_context(error)
911
+ return if params[:occurrence].blank?
912
+ # An app that updated the gem but has not migrated yet
913
+ return unless ErrorOccurrenceContext.table_exists?
914
+
915
+ error.occurrence_contexts.find_by(error_occurrence_id: params[:occurrence])
916
+ end
917
+
918
+ # This error's occurrences that still have a stored context, newest first.
919
+ # The context table is read for its occurrence ids only, so listing them
920
+ # never loads the stored payloads.
921
+ def captured_occurrences(error)
922
+ return [] unless ErrorOccurrenceContext.table_exists?
923
+
924
+ error.error_occurrences
925
+ .where(id: ErrorOccurrenceContext.where(error_log_id: error.id).select(:error_occurrence_id))
926
+ .order(occurred_at: :desc, id: :desc)
927
+ .select(:id, :occurred_at, :app_version, :git_sha)
928
+ .to_a
929
+ end
930
+
904
931
  def fetch_platform_issue(error)
905
932
  return nil unless error.external_issue_url.present? && error.external_issue_number.present?
906
933
  return nil unless RailsErrorDashboard.configuration.enable_issue_tracking
@@ -14,7 +14,12 @@ module RailsErrorDashboard
14
14
  class WebhooksController < ActionController::Base
15
15
  skip_before_action :verify_authenticity_token
16
16
 
17
+ PROVIDERS = %w[github gitlab codeberg linear].freeze
18
+
17
19
  before_action :verify_webhook_enabled
20
+ # Before the signature check: an unknown provider is a wrong URL, and a 401
21
+ # would send the sender looking at a secret that is fine
22
+ before_action :verify_provider
18
23
  before_action :verify_signature
19
24
 
20
25
  def receive
@@ -32,9 +37,6 @@ module RailsErrorDashboard
32
37
  handle_codeberg(payload)
33
38
  when "linear"
34
39
  handle_linear(payload)
35
- else
36
- head :not_found
37
- return
38
40
  end
39
41
 
40
42
  head :ok
@@ -52,16 +54,15 @@ module RailsErrorDashboard
52
54
  end
53
55
  end
54
56
 
57
+ def verify_provider
58
+ head :not_found unless PROVIDERS.include?(params[:provider])
59
+ end
60
+
55
61
  def verify_signature
56
62
  provider = params[:provider]
63
+ # Present: verify_webhook_enabled answers 404 without it
57
64
  secret = RailsErrorDashboard.configuration.issue_webhook_secret
58
65
 
59
- unless secret.present?
60
- Rails.logger.warn("[RailsErrorDashboard] Webhook received but no issue_webhook_secret configured")
61
- head :unauthorized
62
- return
63
- end
64
-
65
66
  body = request.body.read
66
67
  request.body.rewind
67
68
 
@@ -292,15 +292,19 @@ module RailsErrorDashboard
292
292
 
293
293
  # Determine which commit SHA to use based on strategy
294
294
  def determine_commit_sha(error_log)
295
- strategy = RailsErrorDashboard.configuration.git_branch_strategy || :commit_sha
295
+ # A String (from ENV, say) means the same as the Symbol
296
+ strategy = (RailsErrorDashboard.configuration.git_branch_strategy || :commit_sha).to_s.to_sym
296
297
 
297
298
  case strategy
298
299
  when :commit_sha
299
300
  # Use the SHA from when the error occurred (most accurate)
300
301
  error_log.respond_to?(:git_sha) ? error_log.git_sha : nil
301
302
  when :current_branch
302
- # Use current branch HEAD
303
- get_current_git_sha
303
+ # The commit this process is running: config.git_sha, the deploy ENV
304
+ # variables, then the .git HEAD read once at boot. Never `git rev-parse`
305
+ # here: this renders on a host request thread, and a subprocess with
306
+ # no timeout is the pattern the capture path already removed.
307
+ Commands::LogError.capture_git_sha
304
308
  when :main
305
309
  # Use main/master branch
306
310
  nil # Will default to "main" in GithubLinkGenerator
@@ -308,16 +312,5 @@ module RailsErrorDashboard
308
312
  nil
309
313
  end
310
314
  end
311
-
312
- # Get current git SHA from repository
313
- def get_current_git_sha
314
- return @current_git_sha if defined?(@current_git_sha)
315
-
316
- @current_git_sha = begin
317
- `git rev-parse HEAD 2>/dev/null`.strip.presence
318
- rescue StandardError
319
- nil
320
- end
321
- end
322
315
  end
323
316
  end
@@ -67,6 +67,8 @@ module RailsErrorDashboard
67
67
  "success"
68
68
  when :warning
69
69
  "warning"
70
+ when :not_rated
71
+ "secondary"
70
72
  else
71
73
  "danger"
72
74
  end
@@ -82,6 +84,8 @@ module RailsErrorDashboard
82
84
  red_t("red.common.health_status.healthy")
83
85
  when :warning
84
86
  red_t("red.common.health_status.warning")
87
+ when :not_rated
88
+ red_t("red.common.health_status.not_rated")
85
89
  else
86
90
  red_t("red.common.health_status.critical")
87
91
  end
@@ -44,6 +44,11 @@ module RailsErrorDashboard
44
44
  def self.delete_with_dependents(scope)
45
45
  ids = scope.select(:id)
46
46
 
47
+ # Each occurrence's stored context. Named here for the same reason as the
48
+ # event counts below: delete_all fires no :dependent callback.
49
+ if ErrorOccurrenceContext.table_exists?
50
+ ErrorOccurrenceContext.where(error_log_id: ids).in_batches(of: 1000).delete_all
51
+ end
47
52
  ErrorOccurrence.where(error_log_id: ids).in_batches(of: 1000).delete_all
48
53
  # Hour buckets for storm-shed events. Listed explicitly because delete_all
49
54
  # does not fire the has_many :dependent callback on ErrorLog -- without
@@ -36,6 +36,8 @@ module RailsErrorDashboard
36
36
 
37
37
  # Association for tracking individual error occurrences
38
38
  has_many :error_occurrences, class_name: "RailsErrorDashboard::ErrorOccurrence", dependent: :destroy
39
+ # Deleted before the occurrences, without loading their payloads
40
+ has_many :occurrence_contexts, class_name: "RailsErrorDashboard::ErrorOccurrenceContext", dependent: :delete_all
39
41
 
40
42
  # Hour buckets for storm-shed events. delete_all, not destroy: these rows
41
43
  # are pure counters with no callbacks, and a group can own one per hour.
@@ -179,6 +181,14 @@ module RailsErrorDashboard
179
181
  Services::SeverityClassifier.critical?(error_type)
180
182
  end
181
183
 
184
+ # Whether the backtrace this row shows was sent by a client through
185
+ # ManualErrorReporter. Its frames describe code that ran on the client, so
186
+ # View Source, git blame and the Markdown formatter read no file for them.
187
+ # nil (a row from before the column, or an app not yet migrated) is server.
188
+ def client_backtrace?
189
+ has_attribute?(:backtrace_source) && backtrace_source == "client"
190
+ end
191
+
182
192
  # Check if error is recent (< 1 hour)
183
193
  def recent?
184
194
  occurred_at >= 1.hour.ago
@@ -339,14 +349,7 @@ module RailsErrorDashboard
339
349
  []
340
350
  end
341
351
 
342
- lines.first(20).map do |line|
343
- # Extract file path and method name, ignore line numbers
344
- if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
345
- "#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
346
- elsif line =~ %r{([^/]+\.rb)}
347
- Regexp.last_match(1)
348
- end
349
- end.compact.uniq
352
+ lines.first(20).map { |line| Services::BacktraceProcessor.frame_key(line) }.compact.uniq
350
353
  end
351
354
 
352
355
  # Calculate backtrace signature — delegates to Service
@@ -10,6 +10,7 @@ module RailsErrorDashboard
10
10
  self.table_name = "rails_error_dashboard_error_occurrences"
11
11
 
12
12
  belongs_to :error_log, class_name: "RailsErrorDashboard::ErrorLog"
13
+ has_one :context, class_name: "RailsErrorDashboard::ErrorOccurrenceContext", dependent: :delete
13
14
 
14
15
  # Only define user association if User model exists
15
16
  if defined?(::User)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # The moment-of-failure context of one occurrence: breadcrumbs, system
5
+ # health, local and instance variables, the same JSON strings the error row
6
+ # holds for its latest capture. Commands::RecordOccurrenceContext writes them
7
+ # and keeps the newest config.occurrence_context_limit per error.
8
+ class ErrorOccurrenceContext < ErrorLogsRecord
9
+ self.table_name = "rails_error_dashboard_error_occurrence_contexts"
10
+
11
+ belongs_to :error_occurrence, class_name: "RailsErrorDashboard::ErrorOccurrence"
12
+ belongs_to :error_log, class_name: "RailsErrorDashboard::ErrorLog"
13
+ end
14
+ end
@@ -2514,7 +2514,9 @@ document.addEventListener('DOMContentLoaded', function() {
2514
2514
  case 'toggle-next':
2515
2515
  var nextEl = actionEl.nextElementSibling;
2516
2516
  if (nextEl) {
2517
- var isHidden = nextEl.style.display === 'none' || nextEl.style.display === '';
2517
+ // The computed display: an open element usually has no inline display
2518
+ // at all, and reading '' as hidden made the first click a no-op.
2519
+ var isHidden = window.getComputedStyle(nextEl).display === 'none';
2518
2520
  nextEl.style.display = isHidden ? (actionEl.dataset.redDisplay || 'block') : 'none';
2519
2521
  var chev = actionEl.querySelector('.chev');
2520
2522
  if (chev) {
@@ -0,0 +1,45 @@
1
+ <!-- Captured occurrences: the ones whose context is still stored -->
2
+ <% if occurrences.any? %>
3
+ <div class="card mb-4" id="section-captured-occurrences">
4
+ <div class="card-header">
5
+ <h5 class="mb-0">
6
+ <i class="bi bi-camera"></i> <%= red_t("red.errors.captured_occurrences.title") %>
7
+ </h5>
8
+ <small class="text-muted"><%= red_t("red.errors.captured_occurrences.hint") %></small>
9
+ </div>
10
+ <div class="card-body p-0">
11
+ <div class="table-responsive">
12
+ <table class="table table-hover mb-0">
13
+ <thead class="table-light">
14
+ <tr>
15
+ <th><%= red_t("red.errors.captured_occurrences.column_occurrence") %></th>
16
+ <th><%= red_t("red.errors.captured_occurrences.column_occurred_at") %></th>
17
+ <th><%= red_t("red.errors.captured_occurrences.column_version") %></th>
18
+ <th></th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ <% occurrences.each do |occurrence| %>
23
+ <tr>
24
+ <td>
25
+ <code>#<%= occurrence.id %></code>
26
+ <% if occurrence.id == current_id %>
27
+ <span class="badge bg-info text-dark ms-1"><%= red_t("red.errors.captured_occurrences.viewing") %></span>
28
+ <% end %>
29
+ </td>
30
+ <td><%= local_time(occurrence.occurred_at) %></td>
31
+ <td>
32
+ <% version = occurrence.app_version.presence || occurrence.git_sha.to_s.first(7).presence %>
33
+ <small class="text-muted"><%= version || red_t("red.common.not_available") %></small>
34
+ </td>
35
+ <td class="text-end">
36
+ <%= link_to red_t("red.errors.captured_occurrences.view"), error_path(error, occurrence: occurrence.id, **app_context), class: "btn btn-sm btn-outline-primary" %>
37
+ </td>
38
+ </tr>
39
+ <% end %>
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ <% end %>
@@ -3,7 +3,7 @@
3
3
  would be served to every other one. %>
4
4
  <%# Plus the source-integration configuration and a TTL, and not at all
5
5
  while coverage is live: see BacktraceHelper#source_view_cache_key. %>
6
- <% cache_if source_view_cacheable?, [error, 'error_details_v3', red_locale, source_view_cache_key], **source_view_cache_options do %>
6
+ <% cache_if source_view_cacheable?, [error, 'error_details_v3', red_locale, source_view_cache_key, error.client_backtrace?], **source_view_cache_options do %>
7
7
  <div class="card mb-4" id="section-error-info">
8
8
  <div class="card-header bg-danger text-white">
9
9
  <div class="d-flex justify-content-between align-items-center">
@@ -108,6 +108,8 @@
108
108
  <% if error.backtrace.present? %>
109
109
  <% frames = parse_backtrace(error.backtrace) %>
110
110
  <% app_frames = filter_app_code(frames) %>
111
+ <%# A client's frames describe code that ran on the client: no file is read for them %>
112
+ <% source_viewable = RailsErrorDashboard.configuration.enable_source_code_integration && !error.client_backtrace? %>
111
113
  <% framework_frames = filter_framework_code(frames) %>
112
114
 
113
115
  <% if app_frames.any? %>
@@ -122,6 +124,11 @@
122
124
  </div>
123
125
  </div>
124
126
  <div class="card-body p-0">
127
+ <% if RailsErrorDashboard.configuration.enable_source_code_integration && error.client_backtrace? %>
128
+ <div class="alert alert-info m-3 mb-0" id="client-backtrace-note" style="font-size: 12px;">
129
+ <i class="bi bi-phone"></i> <%= red_t("red.errors.info.backtrace.client_report_note") %>
130
+ </div>
131
+ <% end %>
125
132
  <div class="code-block p-3" style="max-height: none; overflow-y: visible; overflow-x: auto;">
126
133
  <% app_frames.each_with_index do |frame, index| %>
127
134
  <div class="backtrace-frame-wrapper mb-2">
@@ -133,7 +140,7 @@
133
140
  <%= frame_icon(frame[:category]) %> <%= frame[:short_path] %>:<%= frame[:line_number] %>
134
141
  </span> in <span class="backtrace-method-name">`<%= frame[:method_name] %>'</span>
135
142
  </div>
136
- <% if RailsErrorDashboard.configuration.enable_source_code_integration && frame[:category] == :app %>
143
+ <% if source_viewable && frame[:category] == :app %>
137
144
  <button class="btn btn-sm btn-outline-secondary" type="button"
138
145
  data-bs-toggle="collapse"
139
146
  data-bs-target="#source-code-<%= index %>"
@@ -145,7 +152,7 @@
145
152
  </div>
146
153
 
147
154
  <!-- Collapsible source code (only for app frames) -->
148
- <% if RailsErrorDashboard.configuration.enable_source_code_integration && frame[:category] == :app %>
155
+ <% if source_viewable && frame[:category] == :app %>
149
156
  <div class="collapse" id="source-code-<%= index %>">
150
157
  <%= render "source_code", frame: frame, error: error, index: index %>
151
158
  </div>
@@ -0,0 +1,12 @@
1
+ <% if occurrence_context %>
2
+ <%# Only the context cards follow ?occurrence=; the rest of the page is the error row's latest capture %>
3
+ <div class="alert alert-info mb-3" id="occurrence-banner" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4);">
4
+ <div style="font-size: 12px;">
5
+ <i class="bi bi-clock-history"></i>
6
+ <strong><%= red_t("red.errors.show.occurrence.title", id: occurrence_context.error_occurrence_id) %></strong>
7
+ &middot; <%= local_time(occurrence_context.error_occurrence&.occurred_at || occurrence_context.created_at) %>
8
+ <div><%= red_t("red.errors.show.occurrence.body") %></div>
9
+ </div>
10
+ <%= link_to red_t("red.errors.show.occurrence.show_latest"), error_path(error, **app_context), class: "btn btn-sm" %>
11
+ </div>
12
+ <% end %>
@@ -201,14 +201,17 @@
201
201
  morphology applied to an identifier — it renders "iOS" as
202
202
  "IOS" in some engines and mangles the Turkish dotless i. %>
203
203
  <span style="font-size: 13px; font-weight: 600;"><%= platform %></span>
204
- <span class="badge bg-<%= health_status_color(health[:health_status]) %>"><%= health_status_text(health[:health_status]) %></span>
205
- </div>
206
- <div style="margin-bottom: var(--space-2);">
207
- <div style="font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums;"><%= red_t("red.errors.overview_page.stability_score_html", score: content_tag(:span, health[:stability_score])) %></div>
208
- </div>
209
- <div class="progress" style="height: 4px; margin-bottom: var(--space-3);">
210
- <div class="progress-bar" style="width: <%= health[:stability_score] %>%; background: var(--status-<%= health[:health_status] == :healthy ? 'success' : health[:health_status] == :warning ? 'warning' : 'critical' %>);"></div>
204
+ <span class="badge bg-<%= health_status_color(health[:health_status]) %>"<% if health[:health_status] == :not_rated %> title="<%= red_t("red.common.health_status.not_rated_hint") %>"<% end %>><%= health_status_text(health[:health_status]) %></span>
211
205
  </div>
206
+ <%# A lone platform has no score: a rating compares platforms %>
207
+ <% if health[:stability_score] %>
208
+ <div style="margin-bottom: var(--space-2);">
209
+ <div style="font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums;"><%= red_t("red.errors.overview_page.stability_score_html", score: content_tag(:span, health[:stability_score])) %></div>
210
+ </div>
211
+ <div class="progress" style="height: 4px; margin-bottom: var(--space-3);">
212
+ <div class="progress-bar" style="width: <%= health[:stability_score] %>%; background: var(--status-<%= health[:health_status] == :healthy ? 'success' : health[:health_status] == :warning ? 'warning' : 'critical' %>);"></div>
213
+ </div>
214
+ <% end %>
212
215
  <div style="font-size: 12px; color: var(--text-tertiary);">
213
216
  <%= red_t("red.errors.overview_page.platform_summary", total: health[:total_errors], critical: health[:critical_errors], unresolved: health[:unresolved_errors]) %>
214
217
  </div>
@@ -36,7 +36,7 @@
36
36
  <div class="row mb-4">
37
37
  <% @platform_health.each do |platform, health| %>
38
38
  <div class="col-md-6 col-lg-3 mb-3">
39
- <div class="card h-100 <%= health[:health_status] == :healthy ? 'border-success' : health[:health_status] == :warning ? 'border-warning' : 'border-danger' %>">
39
+ <div class="card h-100 <%= { healthy: 'border-success', warning: 'border-warning', not_rated: 'border-secondary' }.fetch(health[:health_status], 'border-danger') %>">
40
40
  <div class="card-body">
41
41
  <h5 class="card-title text-capitalize">
42
42
  <%= platform || red_t("red.common.unknown") %>
@@ -44,23 +44,28 @@
44
44
  <span class="badge bg-success float-end"><%= red_t("red.analytics.platform_comparison.health_card.healthy") %></span>
45
45
  <% elsif health[:health_status] == :warning %>
46
46
  <span class="badge bg-warning float-end"><%= red_t("red.analytics.platform_comparison.health_card.warning") %></span>
47
+ <% elsif health[:health_status] == :not_rated %>
48
+ <span class="badge bg-secondary float-end" title="<%= red_t("red.common.health_status.not_rated_hint") %>"><%= red_t("red.common.health_status.not_rated") %></span>
47
49
  <% else %>
48
50
  <span class="badge bg-danger float-end"><%= red_t("red.analytics.platform_comparison.health_card.critical") %></span>
49
51
  <% end %>
50
52
  </h5>
51
53
 
52
- <div class="mb-3">
53
- <div class="d-flex justify-content-between align-items-center mb-1">
54
- <small class="text-muted"><%= red_t("red.analytics.platform_comparison.health_card.stability_score") %></small>
55
- <strong><%= health[:stability_score] %>/100</strong>
56
- </div>
57
- <div class="progress" style="height: 8px;">
58
- <div class="progress-bar <%= health[:stability_score] >= 80 ? 'bg-success' : health[:stability_score] >= 60 ? 'bg-warning' : 'bg-danger' %>"
59
- role="progressbar"
60
- style="width: <%= health[:stability_score] %>%">
54
+ <%# A lone platform has no score: a rating compares platforms %>
55
+ <% if health[:stability_score] %>
56
+ <div class="mb-3">
57
+ <div class="d-flex justify-content-between align-items-center mb-1">
58
+ <small class="text-muted"><%= red_t("red.analytics.platform_comparison.health_card.stability_score") %></small>
59
+ <strong><%= health[:stability_score] %>/100</strong>
60
+ </div>
61
+ <div class="progress" style="height: 8px;">
62
+ <div class="progress-bar <%= health[:stability_score] >= 80 ? 'bg-success' : health[:stability_score] >= 60 ? 'bg-warning' : 'bg-danger' %>"
63
+ role="progressbar"
64
+ style="width: <%= health[:stability_score] %>%">
65
+ </div>
61
66
  </div>
62
67
  </div>
63
- </div>
68
+ <% end %>
64
69
 
65
70
  <div class="small">
66
71
  <div class="d-flex justify-content-between mb-1">
@@ -10,6 +10,7 @@
10
10
  enable_middleware: { type: :boolean },
11
11
  enable_error_subscriber: { type: :boolean },
12
12
  retention_days: { type: :retention_days },
13
+ occurrence_context_limit: { type: :integer, unit: :occurrences },
13
14
  max_backtrace_lines: { type: :integer, unit: :lines },
14
15
  sampling_rate: { type: :percentage }
15
16
  }
@@ -195,20 +196,20 @@
195
196
 
196
197
  <!-- Dynamically render setting groups -->
197
198
  <% setting_groups.each do |group_slug, group_config| %>
199
+ <%# One flag, or every flag in an array, must be on for a row to show. The
200
+ count and the rows both use this, so the count says what is shown. %>
201
+ <% shown_settings = group_config[:settings].select { |_key, meta| Array(meta[:show_if]).all? { |flag| @config.send(flag) } } %>
198
202
  <div class="card" style="margin-bottom: var(--space-4);">
199
203
  <button type="button" data-red-action="toggle-next" data-red-display="block" style="display: flex; align-items: center; justify-content: space-between; width: 100%; padding: var(--space-4) var(--space-6); background: none; border: none; cursor: pointer; border-bottom: 1px solid var(--border-primary);">
200
204
  <span style="font-size: 14px; font-weight: 600; color: var(--text-primary);"><i class="<%= group_config[:icon] %>" style="margin-right: 6px;"></i> <%= red_t("red.settings.groups.#{group_slug}") %></span>
201
205
  <div style="display: flex; align-items: center; gap: 8px;">
202
- <span style="font-size: 11px; color: var(--text-tertiary);"><%= red_tp("red.settings.option_count", count: group_config[:settings].size) %></span>
206
+ <span style="font-size: 11px; color: var(--text-tertiary);"><%= red_tp("red.settings.option_count", count: shown_settings.size) %></span>
203
207
  <i class="bi bi-chevron-up chev" style="font-size: 12px; color: var(--text-tertiary);"></i>
204
208
  </div>
205
209
  </button>
206
210
  <div>
207
- <% group_config[:settings].each do |setting_key, setting_meta| %>
211
+ <% shown_settings.each do |setting_key, setting_meta| %>
208
212
  <%
209
- # One flag, or every flag in an array, must be on for the row to show.
210
- should_show = Array(setting_meta[:show_if]).all? { |flag| @config.send(flag) }
211
- next unless should_show
212
213
  value = @config.send(setting_meta[:method] || setting_key)
213
214
  # A credential is shown as Set / Not set whatever shape it has. The
214
215
  # per-row :type documents the value's shape; this one rule is what
@@ -98,6 +98,8 @@
98
98
  </div>
99
99
  </div>
100
100
 
101
+ <%= render "occurrence_banner", error: @error, occurrence_context: @occurrence_context %>
102
+
101
103
  <!-- Main content: tabs + sidebar -->
102
104
  <div class="red-detail-grid">
103
105
  <!-- Left: tabbed content -->
@@ -142,18 +144,19 @@
142
144
  <% end %>
143
145
 
144
146
  <%= render "error_info", error: @error %>
145
- <%= render "local_variables", error: @error %>
146
- <%= render "instance_variables", error: @error %>
147
+ <%= render "local_variables", error: @snapshot %>
148
+ <%= render "instance_variables", error: @snapshot %>
147
149
  </div>
148
150
 
149
151
  <!-- Tab: Context -->
150
152
  <div id="panel-context">
151
153
  <%= render "request_context", error: @error %>
152
- <%= render "breadcrumbs_group", error: @error %>
154
+ <%= render "breadcrumbs_group", error: @snapshot %>
153
155
  </div>
154
156
 
155
157
  <!-- Tab: History -->
156
158
  <div id="panel-history">
159
+ <%= render "captured_occurrences", error: @error, occurrences: @captured_occurrences, current_id: @occurrence_context&.error_occurrence_id %>
157
160
  <%= render "timeline" %>
158
161
  <% unless RailsErrorDashboard.configuration.enable_issue_tracking %>
159
162
  <%= render "discussion", error: @error %>
@@ -175,7 +178,7 @@
175
178
 
176
179
  <!-- Right: sidebar -->
177
180
  <div class="error-sidebar" style="display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; overflow-wrap: break-word;">
178
- <%= render "sidebar_metadata", error: @error, related_errors: @related_errors %>
181
+ <%= render "sidebar_metadata", error: @snapshot, related_errors: @related_errors %>
179
182
 
180
183
  <%= render "llm_summary", error: @error %>
181
184
 
@@ -36,6 +36,8 @@ de:
36
36
  healthy: "✅ Fehlerfrei"
37
37
  warning: "⚠️ Warnung"
38
38
  critical: "\U0001F534 Kritisch"
39
+ not_rated: "Nicht bewertet"
40
+ not_rated_hint: "Eine Bewertung vergleicht Plattformen und braucht daher Fehler auf mindestens zwei."
39
41
  device_type:
40
42
  mobile: Mobil
41
43
  tablet: Tablet
@@ -242,6 +244,10 @@ de:
242
244
  inactive_body: aktivieren, um ausgeführte Zeilen zu sehen
243
245
  enable: Aktivieren
244
246
  disable: Deaktivieren
247
+ occurrence:
248
+ title: "Vorkommen #%{id}"
249
+ body: "Breadcrumbs, Systemzustand und Variablen stammen aus diesem Vorkommen. Alles andere zeigt die neueste Erfassung."
250
+ show_latest: "Neueste anzeigen"
245
251
  baselines:
246
252
  title: Referenzstatistiken
247
253
  anomaly_label: 'Anomalie:'
@@ -497,6 +503,14 @@ de:
497
503
  one: "+%{count} weiterer"
498
504
  other: "+%{count} weitere"
499
505
  view_errors: Fehler ansehen
506
+ captured_occurrences:
507
+ title: "Erfasste Vorkommen"
508
+ hint: "Breadcrumbs, Systemzustand und Variablen werden für die neuesten Vorkommen aufbewahrt (occurrence_context_limit). Öffnen Sie eines, um sie zu sehen."
509
+ column_occurrence: "Vorkommen"
510
+ column_occurred_at: "Aufgetreten"
511
+ column_version: "Version"
512
+ view: "Anzeigen"
513
+ viewing: "Angezeigt"
500
514
  co_occurring:
501
515
  title: Gemeinsam auftretende Fehler
502
516
  badge: Zeitliche Muster
@@ -741,6 +755,7 @@ de:
741
755
  other: "%{count} Frames"
742
756
  click_to_expand: "(zum Aufklappen klicken)"
743
757
  view_source: Quellcode ansehen
758
+ client_report_note: "Diese Frames stammen aus einem Client-Bericht (ManualErrorReporter). Sie beschreiben Code, der auf dem Client lief, daher öffnet RED für sie keine Quelldatei, zeigt keinen Ausschnitt und führt kein git blame aus."
744
759
  none: Kein Backtrace verfügbar
745
760
  row:
746
761
  new_badge: NEU
@@ -1101,6 +1116,9 @@ de:
1101
1116
  events:
1102
1117
  one: "%{count} Ereignis"
1103
1118
  other: "%{count} Ereignisse"
1119
+ occurrences:
1120
+ one: "%{count} Vorkommen"
1121
+ other: "%{count} Vorkommen"
1104
1122
  queries:
1105
1123
  one: "%{count} Abfrage"
1106
1124
  other: "%{count} Abfragen"
@@ -1145,6 +1163,7 @@ de:
1145
1163
  enable_middleware: Fängt unbehandelte Exceptions ab
1146
1164
  enable_error_subscriber: Abonniert Rails-Fehlerereignisse
1147
1165
  retention_days: "Fehler löschen, die so viele Tage nicht mehr aufgetreten sind. Durchgesetzt vom RetentionCleanupJob, den das Gem selbst nicht einplant"
1166
+ occurrence_context_limit: "Für wie viele der neuesten Vorkommen je Fehler Breadcrumbs, Systemzustand und Variablen aufbewahrt werden (0 = aus)"
1148
1167
  max_backtrace_lines: Maximale Backtrace-Tiefe
1149
1168
  sampling_rate: Anteil der erfassten Fehler
1150
1169
  application_name: Name dieser Anwendung (wird automatisch erkannt, wenn nicht