rails_error_dashboard 0.14.4 → 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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -8
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +97 -4
  5. data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
  6. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
  7. data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
  8. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +63 -16
  9. data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
  10. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
  11. data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
  12. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
  13. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
  14. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
  15. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +31 -0
  16. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
  17. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
  18. data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
  19. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
  20. data/app/models/rails_error_dashboard/error_log.rb +54 -20
  21. data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
  22. data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
  23. data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
  24. data/app/views/layouts/rails_error_dashboard.html.erb +14 -2
  25. data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
  26. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +12 -3
  27. data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
  28. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
  29. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
  30. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
  31. data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
  32. data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
  33. data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
  34. data/app/views/rails_error_dashboard/errors/overview.html.erb +10 -7
  35. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
  36. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
  37. data/app/views/rails_error_dashboard/errors/settings.html.erb +23 -8
  38. data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
  39. data/config/locales/de.yml +87 -5
  40. data/config/locales/en.yml +99 -4
  41. data/config/locales/es.yml +88 -5
  42. data/config/locales/fr.yml +88 -5
  43. data/config/locales/it.yml +88 -5
  44. data/config/locales/ja.yml +86 -4
  45. data/config/locales/pl.yml +89 -5
  46. data/config/locales/pt-BR.yml +88 -5
  47. data/config/locales/ru.yml +89 -5
  48. data/config/locales/uk.yml +89 -5
  49. data/config/locales/zh-CN.yml +86 -4
  50. data/config/routes.rb +6 -0
  51. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
  52. data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
  53. data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
  54. data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
  55. data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
  56. data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
  57. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +41 -5
  58. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  59. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
  60. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +16 -3
  61. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
  62. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
  63. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +101 -0
  64. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
  65. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
  66. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
  67. data/lib/rails_error_dashboard/commands/log_error.rb +20 -8
  68. data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
  69. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  70. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  71. data/lib/rails_error_dashboard/configuration.rb +61 -4
  72. data/lib/rails_error_dashboard/engine.rb +42 -1
  73. data/lib/rails_error_dashboard/error_reporter.rb +4 -2
  74. data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
  75. data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
  76. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
  77. data/lib/rails_error_dashboard/plugin.rb +19 -3
  78. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  79. data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
  80. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  81. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
  82. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  83. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
  84. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  85. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  86. data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
  87. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  88. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  89. data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -7
  90. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  91. data/lib/rails_error_dashboard/services/crash_capture.rb +90 -2
  92. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  93. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  94. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -14
  95. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
  96. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  97. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  98. data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
  99. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  100. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  101. data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
  102. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  103. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
  104. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
  105. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  106. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  107. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  108. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  109. data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
  110. data/lib/rails_error_dashboard/version.rb +1 -1
  111. data/lib/rails_error_dashboard.rb +28 -0
  112. data/lib/tasks/error_dashboard.rake +2 -3
  113. metadata +29 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd115b4f0fa37f0b90cc22ceea5d5f8db5bdb59ceb40972c3d9ff70c6055a643
4
- data.tar.gz: b1339930832c6e108d0e55aae93ad2cf14ed1935fa5af23bee24b9f9f4d8943d
3
+ metadata.gz: 84ad49626b8dddc0665ed515f3fc8e3357adadddcb5991679324cffa40d2aa2e
4
+ data.tar.gz: af06a63fb80ded2960e23f6e6c25ace1a741b85e13f014ae4bfbfd4b4b16980a
5
5
  SHA512:
6
- metadata.gz: 9901f9f6fcc64cf53f71cdf43545e2314fe410638eafb414619159d9955763e8251007b13aa5d02e3967ebfbe4b87fb6dff3700694d27031d67ed39c8281e8ac
7
- data.tar.gz: d039b6f3796fad5884ae83bbe374671d5d2f90137747c0f404fd917cbcf8088f3b4101caf5c9b77524562b4e4c2c2ca3581d99abadfc16797d9bd35122c83afc
6
+ metadata.gz: c3154ebfa3b1d46d497a86cfca33dca5a09ed98127a699e7a9eb17ce3d05380f807172b377a2a824fbffff806e6f1e9a6c6ba96e8cb61802368e81ffc56ea93a
7
+ data.tar.gz: 571ca72a5e588371357a427c02a2ebed3bf565cf70f03b8c8e5877245e060bd7d8ddff873ae50235ac1c4103c6944d4f5e852c221766f7292225b4af8406ceca
data/README.md CHANGED
@@ -23,7 +23,7 @@ Open `/red`, then send a test error from its Settings page. No monitoring accoun
23
23
 
24
24
  [Try the live demo](https://rails-error-dashboard.anjan.dev) (`gandalf` / `youshallnotpass`) · [Read the documentation](https://anjanj.github.io/rails_error_dashboard/) · [View on RubyGems](https://rubygems.org/gems/rails_error_dashboard)
25
25
 
26
- > **Beta:** RED is functional and extensively tested, but configuration and APIs may change before 1.0. Supports Rails 7.0–8.1 and Ruby 3.2–4.0 (CI runs Ruby 3.2–3.4 against every supported Rails version; Ruby 4.0 is verified by the maintainer).
26
+ > **Beta:** RED is functional and extensively tested, but configuration and APIs may change before 1.0. Supports Rails 7.0–8.1 and Ruby 3.2–4.0 (CI runs every Ruby from 3.2 to 4.0 against every supported Rails version).
27
27
 
28
28
  ## See the Ruby state and Rails runtime health behind every exception
29
29
 
@@ -208,6 +208,8 @@ Cross-error N+1 detection grouped by SQL fingerprint, and aggregate deprecation
208
208
 
209
209
  Requires breadcrumbs to be enabled. Deprecations are seen only when the host's `ActiveSupport::Deprecation` behaviour includes `:notify` (the production default does not) and only inside requests that later raised.
210
210
 
211
+ **Missing translations** — opt in with `config.enable_missing_translation_tracking = true` and RED counts every key your app looked up and did not have, by locale, with the first call site, on its own page. No breadcrumbs needed; it wraps `I18n.exception_handler` and delegates to the handler you already had. [Details →](docs/FEATURES.md#missing-translation-tracking)
212
+
211
213
  [Complete documentation →](docs/FEATURES.md#n1-query-detection)
212
214
  </details>
213
215
 
@@ -232,12 +234,14 @@ Requires breadcrumbs to be enabled. Deprecations are seen only when the host's `
232
234
  config.enable_actioncable_tracking = true # requires enable_breadcrumbs = true
233
235
  ```
234
236
 
235
- **ActiveStorage Health** — Track file uploads, downloads, deletes, and existence checks across storage services (Disk, S3, GCS, Azure — any ActiveStorage backend). Dashboard page at `/errors/activestorage_health_summary` with per-service operation counts, average and slowest durations. Helps identify slow storage operations correlating with errors.
237
+ **ActiveStorage Health** — Track file uploads, downloads, deletes, and existence checks across storage services (Disk, S3, GCS, Azure — any ActiveStorage backend). Dashboard page at `/errors/activestorage_health_summary` with per-service operation counts, average and slowest durations. Helps identify slow storage operations correlating with errors. The page opens with a live reachability card (one `exist?` for a key that is never stored, against the configured service), and `GET /error_dashboard/health` reports the same probe as its `active_storage` check.
236
238
 
237
239
  ```ruby
238
240
  config.enable_activestorage_tracking = true # requires enable_breadcrumbs = true
239
241
  ```
240
242
 
243
+ **Health Check** — `GET /error_dashboard/health` answers in JSON whether the dashboard itself can still capture and show errors: the error database (`SELECT 1`, tables present), last capture time, the Active Job adapter and Solid Queue config problems, the storm breaker state and the gem version. 503 when the error database is down, so an uptime monitor can watch the watchmen. Authenticated like every other route. [Reference →](docs/API_REFERENCE.md#health-check)
244
+
241
245
  [Complete documentation →](docs/FEATURES.md#job-health-page)
242
246
  </details>
243
247
 
@@ -390,7 +394,7 @@ config.git_repository_url = "https://github.com/user/repo" # enables SHA links
390
394
  <details>
391
395
  <summary><strong>Source Code Integration + Git Blame</strong></summary>
392
396
 
393
- 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.
394
398
 
395
399
  ```ruby
396
400
  config.enable_source_code_integration = true
@@ -430,7 +434,7 @@ config.llm_model = "gpt-5"
430
434
  </details>
431
435
 
432
436
  <details>
433
- <summary><strong>Notifications — Slack, Discord, PagerDuty, Email, Webhooks</strong></summary>
437
+ <summary><strong>Notifications — Slack, Discord, Telegram, PagerDuty, Email, Webhooks</strong></summary>
434
438
 
435
439
  Multi-channel alerting with severity filters, per-error cooldown, milestone threshold alerts, and a per-environment allowlist (`config.notification_environments = %w[production]`) so a staging deploy never pages anyone.
436
440
 
@@ -439,6 +443,8 @@ config.enable_slack_notifications = true
439
443
  config.slack_webhook_url = ENV['SLACK_WEBHOOK_URL']
440
444
  ```
441
445
 
446
+ Custom webhooks can carry an HMAC-SHA256 signature (`config.webhook_signing_secret`) so the receiver can verify the sender and reject replays.
447
+
442
448
  [Notification setup guide →](docs/guides/NOTIFICATIONS.md)
443
449
  </details>
444
450
 
@@ -556,7 +562,8 @@ config.enable_rack_attack_tracking = true
556
562
  Capture unhandled exceptions that crash the Ruby process via an `at_exit` hook — the last line of defense.
557
563
 
558
564
  - Disk-based fallback: writes crash data to disk because the database may be unavailable during shutdown
559
- - Imported automatically on next boot
565
+ - Imported automatically on next boot; a crash that repeats on every restart is one row with a count
566
+ - Boot crashes too: the hook is registered before Rails eager-loads, so a `Zeitwerk::NameError` or a `SyntaxError` that kills the process before it serves a request lands on the **Boot Errors** page under Diagnostics, with the file and constant it names
560
567
  - Captures exception details, backtrace, uptime, GC stats, thread count, and cause chain
561
568
  - Honeybadger, Bugsnag and AppSignal have `at_exit` reporters too; RED's writes to disk and imports at next boot because the database may already be gone during shutdown
562
569
 
@@ -570,14 +577,18 @@ config.enable_crash_capture = true
570
577
  <details>
571
578
  <summary><strong>Plugin System</strong></summary>
572
579
 
573
- 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.
574
581
 
575
582
  ```ruby
576
583
  class MyPlugin < RailsErrorDashboard::Plugin
584
+ def name = "My Plugin"
585
+
577
586
  def on_error_logged(error_log)
578
587
  # Your custom logic
579
588
  end
580
589
  end
590
+
591
+ RailsErrorDashboard.register_plugin(MyPlugin.new)
581
592
  ```
582
593
 
583
594
  [Plugin System guide →](docs/PLUGIN_SYSTEM.md)
@@ -721,7 +732,7 @@ Users can also switch language per-session from the picker in the dashboard navb
721
732
 
722
733
  **Corrections are very welcome, and a one-key PR is a perfectly good PR.** If you read one of these languages, [every unreviewed locale has an open issue](https://github.com/AnjanJ/rails_error_dashboard/issues?q=is%3Aissue+is%3Aopen+label%3Atranslation%3Aneeds-review) tracking its review — comment there, or [report a bad translation](https://github.com/AnjanJ/rails_error_dashboard/issues/new?template=translation_report.yml) without touching any code. You do not need to know Ruby, and you are not expected to review a whole file.
723
734
 
724
- RED translates through its own private I18n backend, so it never reads, writes or mutates your application's `I18n` configuration — your locale and its `available_locales` are untouched.
735
+ RED translates through its own private I18n backend, so it never reads, writes or mutates your application's `I18n` configuration — your locale and its `available_locales` are untouched. The one exception is opt-in: `enable_missing_translation_tracking` wraps `I18n.exception_handler` to count *your* app's missing keys, and delegates every call to the handler you had.
725
736
 
726
737
  [Translations guide →](docs/guides/TRANSLATIONS.md) — how the system works, how to correct a string, and how to add a locale.
727
738
 
@@ -779,7 +790,7 @@ Rails 7.0–8.1 and Ruby 3.2–4.0.
779
790
  ### Advanced
780
791
  - **[Multi-App Support](docs/MULTI_APP_PERFORMANCE.md)** — Track multiple applications
781
792
  - **[Plugin System](docs/PLUGIN_SYSTEM.md)** — Build custom integrations
782
- - **[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
783
794
  - **[Customization](docs/CUSTOMIZATION.md)** — Customize everything
784
795
  - **[Database Options](docs/guides/DATABASE_OPTIONS.md)** — Separate database setup
785
796
  - **[Database Optimization](docs/guides/DATABASE_OPTIMIZATION.md)** — Performance tuning
@@ -227,15 +227,39 @@ module RailsErrorDashboard
227
227
  end
228
228
 
229
229
  # Page size for every paginated action. Only the upper bound is enforced
230
- # here: a value that is not a positive integer is passed through unchanged
231
- # so that Pagy rejects it and the rescue above redirects, as it always has.
230
+ # here: a value Pagy would reject (0, a negative number, a Hash) is passed
231
+ # on so that it does and the rescue above redirects, as it always has.
232
+ #
233
+ # Strings are read the way Pagy reads them, with to_i, so the cap sees the
234
+ # same number Pagy would use: Integer() refused "150.5" and "0999" and the
235
+ # raw string went through, where Pagy's to_i made them 150 and 999.
232
236
  def per_page_param
233
237
  raw = params[:per_page]
234
238
  return DEFAULT_PER_PAGE if raw.blank?
235
239
 
236
- number = Integer(raw, exception: false) if raw.is_a?(String)
237
- number && number > MAX_PER_PAGE ? MAX_PER_PAGE : raw
240
+ value = raw.is_a?(String) ? raw.to_i : raw
241
+ value.is_a?(Integer) && value > MAX_PER_PAGE ? MAX_PER_PAGE : value
242
+ end
243
+
244
+ # The named query-string keys as a symbol-keyed Hash of their String values.
245
+ #
246
+ # Not params.permit: permit judges every key in the request, so a stray
247
+ # utm_source, or the `page` and `locale` the dashboard itself appends, was
248
+ # an "unpermitted parameter" on every index request. Under the default
249
+ # that is a log line; in a host with action_on_unpermitted_parameters =
250
+ # :raise it was a 500 on the errors page. Picking the keys we want and
251
+ # keeping only scalar values does what permit was meant to do here without
252
+ # passing judgement on the rest of the request.
253
+ #
254
+ # @param keys [Array<Symbol>]
255
+ # @return [Hash{Symbol => String}]
256
+ def scalar_params(keys)
257
+ keys.each_with_object({}) do |key, hash|
258
+ value = params[key]
259
+ hash[key] = value if value.is_a?(String)
260
+ end
238
261
  end
262
+ helper_method :scalar_params
239
263
 
240
264
  def render_dashboard_error(icon:, title:, message:, detail: nil, icon_style: nil, status: :internal_server_error)
241
265
  # The styled page runs queries and names the host app's applications. A
@@ -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)
@@ -583,6 +586,52 @@ module RailsErrorDashboard
583
586
  @pagy, @events = pagy(:offset, all_events, limit: per_page_param)
584
587
  end
585
588
 
589
+ def missing_translations
590
+ unless RailsErrorDashboard.configuration.enable_missing_translation_tracking
591
+ flash[:alert] = feature_disabled_message("missing_translations", options: "enable_missing_translation_tracking = true", set: true)
592
+ redirect_to errors_path(**app_context_params)
593
+ return
594
+ end
595
+
596
+ days = days_param(default: 30)
597
+ @days = days
598
+ result = Queries::MissingTranslationSummary.call(days, application_id: @current_application_id)
599
+ all_entries = result[:entries]
600
+
601
+ # Summary stats (computed before pagination). The list is one row per
602
+ # (locale, key), so the badge and pagination count rows; the tile
603
+ # counts distinct keys, which is what "how many keys are missing" means.
604
+ @entry_count = all_entries.size
605
+ @unique_keys = all_entries.map { |e| e[:key] }.uniq.size
606
+ @total_misses = all_entries.sum { |e| e[:count] }
607
+ @locale_count = result[:locales].size
608
+ # Misses the tracker's bounded buffer dropped; shown so the total above
609
+ # is never silently understated.
610
+ @overflow_count = result[:overflow_count].to_i
611
+
612
+ @pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
613
+ end
614
+
615
+ def boot_errors
616
+ unless RailsErrorDashboard.configuration.enable_crash_capture
617
+ flash[:alert] = feature_disabled_message("boot_errors", options: "enable_crash_capture = true", set: true)
618
+ redirect_to errors_path(**app_context_params)
619
+ return
620
+ end
621
+
622
+ days = days_param(default: 30)
623
+ @days = days
624
+ result = Queries::BootErrors.call(days, application_id: @current_application_id)
625
+ all_entries = result[:entries]
626
+
627
+ # Summary stats (computed before pagination)
628
+ @entry_count = all_entries.size
629
+ @unresolved_count = result[:unresolved_count]
630
+ @total_occurrences = all_entries.sum { |e| e[:count] }
631
+
632
+ @pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
633
+ end
634
+
586
635
  def actioncable_health_summary
587
636
  unless RailsErrorDashboard.configuration.enable_actioncable_tracking &&
588
637
  RailsErrorDashboard.configuration.enable_breadcrumbs
@@ -634,6 +683,9 @@ module RailsErrorDashboard
634
683
 
635
684
  days = days_param(default: 30)
636
685
  @days = days
686
+ # Live reachability of the configured service, shown above the
687
+ # historical breadcrumb stats. One existence check per page load.
688
+ @storage_probe = Services::ActiveStorageProbe.call
637
689
  result = Queries::ActiveStorageSummary.call(days, application_id: @current_application_id)
638
690
  all_services = result[:services]
639
691
 
@@ -719,7 +771,17 @@ module RailsErrorDashboard
719
771
  )
720
772
  exception.set_backtrace(caller)
721
773
 
722
- Commands::LogError.call(exception, { request: request, source: "dashboard.test_error" })
774
+ started_at = Time.current
775
+ error_log = Commands::LogError.call(exception, { request: request, source: "dashboard.test_error" })
776
+
777
+ # The capture path notifies on a first occurrence and then on the
778
+ # 10th, 50th... A second click within 24 hours is occurrence two of the
779
+ # same row, so it used to notify nobody, which reads as "notifications
780
+ # are broken". This button exists to prove delivery: if the capture
781
+ # path did not claim a notification in this request, send one now.
782
+ if error_log && !notified_since?(error_log, started_at)
783
+ Services::ErrorNotificationDispatcher.call(error_log)
784
+ end
723
785
 
724
786
  flash[:notice] = red_t("red.flash.test_error.logged")
725
787
  redirect_to errors_path(**app_context_params)
@@ -785,7 +847,14 @@ module RailsErrorDashboard
785
847
  end
786
848
 
787
849
  def filter_params
788
- params.permit(*FILTERABLE_PARAMS).to_h.symbolize_keys
850
+ scalar_params(FILTERABLE_PARAMS)
851
+ end
852
+
853
+ # True when NotificationThrottler claimed a notification for this error
854
+ # at or after +since+. The claim is an update_all, so the row is re-read.
855
+ def notified_since?(error_log, since)
856
+ stamp = error_log.class.where(id: error_log.id).pick(:last_notified_at)
857
+ stamp.present? && stamp >= since
789
858
  end
790
859
 
791
860
  # Coerce params[:days] into a sane integer in [1, 365]. Without clamping,
@@ -835,6 +904,30 @@ module RailsErrorDashboard
835
904
  red_t("red.errors.row.status.#{status}", default: status.to_s.tr("_", " "))
836
905
  end
837
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
+
838
931
  def fetch_platform_issue(error)
839
932
  return nil unless error.external_issue_url.present? && error.external_issue_number.present?
840
933
  return nil unless RailsErrorDashboard.configuration.enable_issue_tracking
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # GET /health — can the dashboard itself still capture and show errors?
5
+ #
6
+ # JSON only, for an uptime monitor: 200 when the error database answers,
7
+ # 503 when it does not (status "down"). "degraded" (a storm in progress,
8
+ # a Solid Queue config that will not run RED's jobs) is still a 200: the
9
+ # dashboard works, with a caveat the body spells out. See
10
+ # Queries::HealthStatus for what is probed and why.
11
+ #
12
+ # Authenticated like every other dashboard route (ApplicationController
13
+ # prepends the filter): the body names the database adapter and the queue
14
+ # backend, which is more than an anonymous caller should learn. Basic auth
15
+ # works from any monitor; with authenticate_with, point the monitor at
16
+ # whatever that lambda accepts.
17
+ #
18
+ # Its own controller rather than an ErrorsController action: no
19
+ # application context, no storm banner, no error data is loaded here.
20
+ class HealthController < ApplicationController
21
+ # ApplicationController's handler renders the HTML error page. A health
22
+ # endpoint must answer in JSON whatever happens, so this one, declared
23
+ # later, takes precedence.
24
+ rescue_from StandardError do |exception|
25
+ Rails.logger.error("[RailsErrorDashboard] Health check failed: #{exception.class} - #{exception.message}")
26
+ render json: { status: Queries::HealthStatus::DOWN, version: RailsErrorDashboard::VERSION,
27
+ error: exception.class.name },
28
+ status: :service_unavailable
29
+ end
30
+
31
+ def show
32
+ result = Queries::HealthStatus.call
33
+
34
+ response.headers["Cache-Control"] = "no-store"
35
+ render json: result, status: Queries::HealthStatus.http_status_for(result)
36
+ end
37
+ end
38
+ end
@@ -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
 
@@ -163,8 +163,8 @@ module RailsErrorDashboard
163
163
  # @return [Hash] Whitelisted params for building URLs
164
164
  def permitted_filter_params(extra_keys: [])
165
165
  base_keys = RailsErrorDashboard::ErrorsController::FILTERABLE_PARAMS + %i[page per_page days]
166
- allowed_keys = base_keys + Array(extra_keys)
167
- params.permit(*allowed_keys).to_h.symbolize_keys
166
+ # scalar_params, not params.permit: see ApplicationController#scalar_params.
167
+ scalar_params(base_keys + Array(extra_keys))
168
168
  end
169
169
 
170
170
  # Generates a sortable column header link
@@ -7,6 +7,59 @@ module RailsErrorDashboard
7
7
  # one view context.
8
8
  include I18nHelper
9
9
 
10
+ # The brand name for a repository link's tooltip, from the URL's host.
11
+ # A substring test on the whole URL called every non-GitHub, non-GitLab
12
+ # host "Bitbucket", Codeberg included, and a path containing "github"
13
+ # mislabelled any host.
14
+ def repository_host_label(url)
15
+ host = URI.parse(url.to_s).host.to_s.downcase
16
+ case host
17
+ when /github/ then "GitHub"
18
+ when /gitlab/ then "GitLab"
19
+ when /bitbucket/ then "Bitbucket"
20
+ when /codeberg/ then "Codeberg"
21
+ when /gitea/ then "Gitea"
22
+ when /forgejo/ then "Forgejo"
23
+ else host.presence || "Repository"
24
+ end
25
+ rescue URI::InvalidURIError
26
+ "Repository"
27
+ end
28
+
29
+ # The error-info fragment renders the source viewer, blame, repository
30
+ # links and the coverage overlay, none of which come from the error row
31
+ # the fragment is keyed on. These three helpers put the rest of the key's
32
+ # inputs in: the configuration those parts depend on, the source cache
33
+ # TTL as the fragment's lifetime, and no caching at all while coverage is
34
+ # active, because that overlay is live data.
35
+ def source_view_cache_key
36
+ config = RailsErrorDashboard.configuration
37
+ # The gem version too, for the same reason as read_source_code's key
38
+ parts = [
39
+ RailsErrorDashboard::VERSION,
40
+ config.enable_source_code_integration, config.enable_git_blame, config.only_show_app_code_source,
41
+ config.git_repository_url, config.git_branch_strategy
42
+ ]
43
+ Digest::SHA1.hexdigest(parts.map(&:to_s).join("|"))[0, 12]
44
+ rescue StandardError
45
+ "nocfg"
46
+ end
47
+
48
+ def source_view_cacheable?
49
+ !(defined?(Services::CoverageTracker) && Services::CoverageTracker.active?)
50
+ rescue StandardError
51
+ true
52
+ end
53
+
54
+ def source_view_cache_options
55
+ config = RailsErrorDashboard.configuration
56
+ return {} unless config.enable_source_code_integration
57
+
58
+ { expires_in: (config.source_code_cache_ttl || 3600).to_i.seconds }
59
+ rescue StandardError
60
+ {}
61
+ end
62
+
10
63
  # Language mapping for syntax highlighting
11
64
  LANGUAGE_MAP = {
12
65
  ".rb" => "ruby",
@@ -152,8 +205,9 @@ module RailsErrorDashboard
152
205
  return nil unless RailsErrorDashboard.configuration.enable_source_code_integration
153
206
  return nil unless frame[:file_path] && frame[:line_number]
154
207
 
155
- # Cache key includes file path, line number, and git SHA if available
156
- cache_key = "source_code/#{frame[:file_path]}/#{frame[:line_number]}"
208
+ # The gem version is in the key: a cached read must not outlive a change
209
+ # to the reader's rules for which files it may read.
210
+ cache_key = "source_code/#{RailsErrorDashboard::VERSION}/#{frame[:file_path]}/#{frame[:line_number]}"
157
211
  cache_ttl = RailsErrorDashboard.configuration.source_code_cache_ttl || 3600
158
212
 
159
213
  Rails.cache.fetch(cache_key, expires_in: cache_ttl) do
@@ -238,15 +292,19 @@ module RailsErrorDashboard
238
292
 
239
293
  # Determine which commit SHA to use based on strategy
240
294
  def determine_commit_sha(error_log)
241
- 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
242
297
 
243
298
  case strategy
244
299
  when :commit_sha
245
300
  # Use the SHA from when the error occurred (most accurate)
246
301
  error_log.respond_to?(:git_sha) ? error_log.git_sha : nil
247
302
  when :current_branch
248
- # Use current branch HEAD
249
- 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
250
308
  when :main
251
309
  # Use main/master branch
252
310
  nil # Will default to "main" in GithubLinkGenerator
@@ -254,16 +312,5 @@ module RailsErrorDashboard
254
312
  nil
255
313
  end
256
314
  end
257
-
258
- # Get current git SHA from repository
259
- def get_current_git_sha
260
- return @current_git_sha if defined?(@current_git_sha)
261
-
262
- @current_git_sha = begin
263
- `git rev-parse HEAD 2>/dev/null`.strip.presence
264
- rescue StandardError
265
- nil
266
- end
267
- end
268
315
  end
269
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
@@ -59,6 +59,11 @@ module RailsErrorDashboard
59
59
  send_discord_notification(error_log, anomaly_data, config, locale)
60
60
  end
61
61
 
62
+ # Telegram notification
63
+ if Services::TelegramDelivery.configured?(config)
64
+ send_telegram_notification(error_log, anomaly_data, config, locale)
65
+ end
66
+
62
67
  # Webhook notification
63
68
  if config.enable_webhook_notifications && config.webhook_urls.any?
64
69
  send_webhook_notification(error_log, anomaly_data, config, locale)
@@ -96,11 +101,24 @@ module RailsErrorDashboard
96
101
  Rails.logger.error("Failed to send baseline alert to Discord: #{e.message}")
97
102
  end
98
103
 
104
+ def send_telegram_notification(error_log, anomaly_data, _config, locale)
105
+ payload = Services::BaselineAlertPayloadBuilder.telegram_payload(error_log, anomaly_data, locale: locale)
106
+
107
+ Services::TelegramDelivery.post(payload)
108
+ rescue => e
109
+ Rails.logger.error("Failed to send baseline alert to Telegram: #{Services::TelegramDelivery.redact(e.message)}")
110
+ end
111
+
99
112
  def send_webhook_notification(error_log, anomaly_data, config, locale)
100
113
  payload = Services::BaselineAlertPayloadBuilder.webhook_payload(error_log, anomaly_data, locale: locale)
101
114
 
115
+ # Signed when webhook_signing_secret is set; Slack and Discord above
116
+ # keep post_json because their payloads are not ours to sign. Rescued
117
+ # per URL so one unreachable receiver does not stop the next.
102
118
  config.webhook_urls.each do |url|
103
- post_json(url, payload)
119
+ Services::WebhookDelivery.post(url, payload)
120
+ rescue => e
121
+ Rails.logger.error("Failed to send baseline alert to webhook: #{e.message}")
104
122
  end
105
123
  rescue => e
106
124
  Rails.logger.error("Failed to send baseline alert to webhook: #{e.message}")
@@ -116,7 +134,7 @@ module RailsErrorDashboard
116
134
  http.use_ssl = uri.scheme == "https"
117
135
  http.open_timeout = 5
118
136
  http.read_timeout = 10
119
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
137
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
120
138
  request.body = payload.to_json
121
139
  http.request(request)
122
140
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Finds error cascades (A reliably followed by B) in recent occurrences and
5
+ # upserts them as CascadePattern rows, which the error page's Cascades card
6
+ # reads.
7
+ #
8
+ # Nothing in the gem schedules this: run it from the host's scheduler like
9
+ # the other periodic jobs (docs/PRODUCTION.md). Before this job existed the
10
+ # docs said cascades were detected "hourly" by a background job that no
11
+ # code path ever ran, so the card stayed empty on every install.
12
+ #
13
+ # Use a lookback equal to the interval it runs at: an hourly run with a
14
+ # 24-hour lookback re-counts the same pairs 24 times a day.
15
+ class CascadeDetectionJob < ApplicationJob
16
+ queue_as :default
17
+
18
+ # @param lookback_hours [Integer] the window of occurrences to scan
19
+ def perform(lookback_hours: 1)
20
+ return { detected: 0, updated: 0, skipped: "enable_error_cascades is off" } unless RailsErrorDashboard.configuration.enable_error_cascades
21
+
22
+ result = Services::CascadeDetector.call(lookback_hours: lookback_hours.to_i.clamp(1, 24 * 30))
23
+ Rails.logger.info("[RailsErrorDashboard] Cascade detection: #{result[:detected]} detected, #{result[:updated]} updated")
24
+ result
25
+ rescue => e
26
+ Rails.logger.error("[RailsErrorDashboard] CascadeDetectionJob failed: #{e.class}: #{e.message}")
27
+ raise
28
+ end
29
+ end
30
+ end